Private
Public Access
1
0

Introduce core components for "Escape From Teacher" game: server, client, physics, asset system, and protocol definitions. Add Docker-Compose setup for Redis and NATS infrastructure.

This commit is contained in:
Sebastian Unterschütz
2026-01-01 15:21:18 +01:00
commit 3099ac42c0
9 changed files with 1384 additions and 0 deletions

328
cmd/client/main.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"encoding/json"
"fmt"
"image/color"
_ "image/png"
"io/ioutil"
"log"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector"
"github.com/nats-io/nats.go"
"golang.org/x/image/font/basicfont"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// --- KONFIGURATION ---
const (
ScreenWidth = 1280
ScreenHeight = 720
StateMenu = 0
StateGame = 1
RefFloorY = 540
)
var (
ColText = color.White
ColBtnNormal = color.RGBA{40, 44, 52, 255}
ColBtnHover = color.RGBA{60, 66, 78, 255}
ColSky = color.RGBA{135, 206, 235, 255}
ColGrass = color.RGBA{34, 139, 34, 255}
ColDirt = color.RGBA{101, 67, 33, 255}
)
// --- GAME STRUCT ---
type Game struct {
appState int
conn *nats.EncodedConn
gameState game.GameState
stateMutex sync.Mutex
connected bool
world *game.World
assetsImages map[string]*ebiten.Image
// Spieler Info
playerName string
roomID string // <-- NEU: Raum ID
activeField string // "name" oder "room"
// Kamera
camX float64
// Touch State
joyBaseX, joyBaseY float64
joyStickX, joyStickY float64
joyActive bool
joyTouchID ebiten.TouchID
btnJumpActive bool
}
func NewGame() *Game {
g := &Game{
appState: StateMenu,
world: game.NewWorld(),
assetsImages: make(map[string]*ebiten.Image),
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
playerName: "Student",
roomID: "room1", // Standard Raum
activeField: "name",
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150,
}
g.loadAssets()
return g
}
func (g *Game) loadAssets() {
// Pfad anpassen: Wir suchen im relativen Pfad
baseDir := "./cmd/client/assets"
manifestPath := filepath.Join(baseDir, "assets.json")
data, err := ioutil.ReadFile(manifestPath)
if err == nil {
var m game.AssetManifest
json.Unmarshal(data, &m)
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen:", len(m.Assets), "Einträge")
} else {
log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath)
// Fallback: Leeres Manifest, damit das Spiel nicht abstürzt
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
}
// Bilder vorladen
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
} else {
// log.Println("Fehler beim Laden von Bild:", def.Filename)
}
}
}
}
// --- UPDATE ---
func (g *Game) Update() error {
switch g.appState {
case StateMenu:
g.handleMenuInput() // Text Eingabe Logik
// Button & Felder Layout
btnW, btnH := 200, 50
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight/2 + 80
// Feld 1: Name
fieldW, fieldH := 250, 40
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 100
// Feld 2: Raum (NEU)
roomX := ScreenWidth/2 - fieldW/2
roomY := ScreenHeight/2 - 20
// Klick Checks (Maus & Touch)
if isHit(nameX, nameY, fieldW, fieldH) {
g.activeField = "name"
} else if isHit(roomX, roomY, fieldW, fieldH) {
g.activeField = "room"
} else if isHit(btnX, btnY, btnW, btnH) {
// START
if g.playerName == "" {
g.playerName = "Player"
}
if g.roomID == "" {
g.roomID = "room1"
}
g.appState = StateGame
go g.connectAndStart()
} else if isHit(0, 0, ScreenWidth, ScreenHeight) {
// Klick ins Leere -> Fokus weg
g.activeField = ""
}
case StateGame:
g.UpdateGame() // In game_render.go
}
return nil
}
// --- DRAW ---
func (g *Game) Draw(screen *ebiten.Image) {
switch g.appState {
case StateMenu:
g.DrawMenu(screen)
case StateGame:
g.DrawGame(screen) // In game_render.go
}
}
func (g *Game) DrawMenu(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
text.Draw(screen, "ESCAPE FROM TEACHER", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-140, ColText)
// Helper zum Zeichnen von Textfeldern
drawField := func(label, value, fieldID string, x, y, w, h int) {
col := color.RGBA{50, 50, 60, 255}
if g.activeField == fieldID {
col = color.RGBA{70, 70, 80, 255}
}
vector.DrawFilledRect(screen, float32(x), float32(y), float32(w), float32(h), col, false)
vector.StrokeRect(screen, float32(x), float32(y), float32(w), float32(h), 1, color.White, false)
display := value
if g.activeField == fieldID && (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
text.Draw(screen, label+": "+display, basicfont.Face7x13, x+10, y+25, ColText)
}
fieldW := 250
drawField("Name", g.playerName, "name", ScreenWidth/2-fieldW/2, ScreenHeight/2-100, fieldW, 40)
drawField("Room Code", g.roomID, "room", ScreenWidth/2-fieldW/2, ScreenHeight/2-20, fieldW, 40)
// Start Button
btnW, btnH := 200, 50
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight/2 + 80
vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "JOIN GAME", basicfont.Face7x13, btnX+65, btnY+30, ColText)
text.Draw(screen, "WASD / Arrows to Move - SPACE to Jump\nMobile: Touch Controls", basicfont.Face7x13, ScreenWidth/2-120, ScreenHeight-50, color.Gray{150})
}
func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight }
// --- HELPER ---
func isHit(x, y, w, h int) bool {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
mx, my := ebiten.CursorPosition()
if mx >= x && mx <= x+w && my >= y && my <= y+h {
return true
}
}
for _, id := range inpututil.JustPressedTouchIDs() {
tx, ty := ebiten.TouchPosition(id)
if tx >= x && tx <= x+w && ty >= y && ty <= y+h {
return true
}
}
return false
}
func (g *Game) handleMenuInput() {
if g.activeField == "" {
return
}
// Text Eingabe
var target *string
if g.activeField == "name" {
target = &g.playerName
}
if g.activeField == "room" {
target = &g.roomID
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
if len(*target) > 0 {
*target = (*target)[:len(*target)-1]
}
} else {
*target += string(ebiten.InputChars())
}
}
func (g *Game) connectAndStart() {
// URL: Wasm -> WS, Desktop -> TCP
serverURL := "nats://localhost:4222"
if runtime.GOARCH == "wasm" || runtime.GOOS == "js" {
serverURL = "ws://localhost:9222"
}
nc, err := nats.Connect(serverURL)
if err != nil {
log.Println("❌ NATS Connect Fehler:", err)
return
}
ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
g.conn = ec
sub, err := g.conn.Subscribe("game.update", func(state *game.GameState) {
g.stateMutex.Lock()
g.gameState = *state
g.stateMutex.Unlock()
log.Printf("📦 State empfangen: Players=%d, Chunks=%d, Status=%s", len(state.Players), len(state.WorldChunks), state.Status)
})
if err != nil {
log.Println("❌ Fehler beim Subscribe:", err)
return
}
log.Printf("👂 Subscription aktiv (Valid: %v)", sub.IsValid())
// Kurze Pause, damit Subscription aktiv ist
time.Sleep(100 * time.Millisecond)
// JOIN MIT ROOM ID SENDEN
joinReq := game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
}
log.Printf("📤 Sende JOIN Request: Name=%s, RoomID=%s", g.playerName, g.roomID)
err = g.conn.Publish("game.join", joinReq)
if err != nil {
log.Println("❌ Fehler beim Publish:", err)
return
}
g.connected = true
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...")
}
func (g *Game) SendCommand(cmdType string) {
if !g.connected {
return
}
// ID Suche (Fallback Name)
myID := ""
g.stateMutex.Lock()
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
myID = id
break
}
}
g.stateMutex.Unlock()
if myID == "" {
myID = g.playerName
}
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
}
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}
}