From 5e6b8a2304ac1707d6ba72917af5c62ff65fbd30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Thu, 1 Jan 2026 16:46:39 +0100 Subject: [PATCH] Implement core game functionalities: client prediction, coin collection, scoring, game state synchronization, and player management. --- cmd/client/assets/assets.json | 2 +- cmd/client/assets/chunks/start.json | 5 + cmd/client/game_render.go | 140 +++++--- cmd/client/main.go | 525 +++++++++++++++++++++++----- cmd/client/prediction.go | 79 +++++ cmd/server/main.go | 1 - pkg/game/data.go | 37 +- pkg/server/room.go | 187 +++++++--- pkg/server/scoring.go | 129 +++++++ 9 files changed, 902 insertions(+), 203 deletions(-) create mode 100644 cmd/client/assets/chunks/start.json create mode 100644 cmd/client/prediction.go create mode 100644 pkg/server/scoring.go diff --git a/cmd/client/assets/assets.json b/cmd/client/assets/assets.json index dab46a9..e5a99ff 100644 --- a/cmd/client/assets/assets.json +++ b/cmd/client/assets/assets.json @@ -20,7 +20,7 @@ }, "coin": { "ID": "coin", - "Type": "obstacle", + "Type": "coin", "Filename": "coin.png", "Scale": 0.1, "ProcWidth": 0, diff --git a/cmd/client/assets/chunks/start.json b/cmd/client/assets/chunks/start.json new file mode 100644 index 0000000..8d9bc31 --- /dev/null +++ b/cmd/client/assets/chunks/start.json @@ -0,0 +1,5 @@ +{ + "ID": "start", + "Width": 20, + "Objects": [] +} diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 2e2cfc6..b9a050a 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -3,6 +3,7 @@ package main import ( "fmt" "image/color" + "log" "math" "github.com/hajimehoshi/ebiten/v2" @@ -24,53 +25,47 @@ func (g *Game) UpdateGame() { // --- 2. TOUCH INPUT HANDLING --- g.handleTouchInput() - // --- 3. INPUTS ZUSAMMENFÜHREN & SENDEN --- - - if g.connected { - // A) BEWEGUNG (Links/Rechts) - // Joystick auswerten (-1 bis 1) - joyDir := 0.0 - if g.joyActive { - diffX := g.joyStickX - g.joyBaseX - if diffX < -20 { - joyDir = -1 - } // Nach Links gezogen - if diffX > 20 { - joyDir = 1 - } // Nach Rechts gezogen + // --- 3. INPUT STATE ERSTELLEN --- + joyDir := 0.0 + if g.joyActive { + diffX := g.joyStickX - g.joyBaseX + if diffX < -20 { + joyDir = -1 } - - // Senden: Keyboard ODER Joystick - if keyLeft || joyDir == -1 { - g.SendCommand("LEFT_DOWN") - } else if keyRight || joyDir == 1 { - g.SendCommand("RIGHT_DOWN") - } else { - // Wenn weder Links noch Rechts gedrückt ist, senden wir STOP. - g.SendCommand("LEFT_UP") - g.SendCommand("RIGHT_UP") - } - - // B) NACH UNTEN (Fast Fall) - // Joystick weit nach unten gezogen? - isJoyDown := false - if g.joyActive && (g.joyStickY-g.joyBaseY) > 40 { - isJoyDown = true - } - - if keyDown || isJoyDown { - g.SendCommand("DOWN") - } - - // C) SPRINGEN - // Keyboard ODER Touch-Button - if keyJump || g.btnJumpActive { - g.SendCommand("JUMP") - g.btnJumpActive = false // Reset (Tap to jump) + if diffX > 20 { + joyDir = 1 } } - // --- 4. KAMERA LOGIK --- + isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > 40 + + // Input State zusammenbauen + input := InputState{ + Sequence: g.inputSequence, + Left: keyLeft || joyDir == -1, + Right: keyRight || joyDir == 1, + Jump: keyJump || g.btnJumpActive, + Down: keyDown || isJoyDown, + } + g.btnJumpActive = false + + // --- 4. CLIENT PREDICTION --- + if g.connected { + // Sequenznummer erhöhen + g.inputSequence++ + input.Sequence = g.inputSequence + + // Input speichern für später Reconciliation + g.pendingInputs[input.Sequence] = input + + // Lokale Physik sofort anwenden (Prediction) + g.ApplyInput(input) + + // Input an Server senden + g.SendInputWithSequence(input) + } + + // --- 5. KAMERA LOGIK --- g.stateMutex.Lock() defer g.stateMutex.Unlock() @@ -176,9 +171,15 @@ func (g *Game) DrawGame(screen *ebiten.Image) { for _, activeChunk := range g.gameState.WorldChunks { chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID] if !exists { + log.Printf("⚠️ Chunk '%s' nicht in Library gefunden!", activeChunk.ChunkID) continue } + // DEBUG: Chunk-Details loggen (nur einmal) + if len(chunkDef.Objects) == 0 { + log.Printf("⚠️ Chunk '%s' hat 0 Objekte! Width=%d", activeChunk.ChunkID, chunkDef.Width) + } + for _, obj := range chunkDef.Objects { // Asset zeichnen g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y) @@ -186,20 +187,36 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } // 3. Spieler + // MyID ohne Lock holen (wir haben bereits den stateMutex) + myID := "" for id, p := range g.gameState.Players { - g.DrawAsset(screen, "player", p.X, p.Y) + if p.Name == g.playerName { + myID = id + break + } + } + + for id, p := range g.gameState.Players { + // Für lokalen Spieler: Verwende vorhergesagte Position + posX, posY := p.X, p.Y + if id == myID && g.connected { + posX = g.predictedX + posY = g.predictedY + } + + g.DrawAsset(screen, "player", posX, posY) // Name Tag name := p.Name if name == "" { name = id } - text.Draw(screen, name, basicfont.Face7x13, int(p.X-g.camX), int(p.Y-25), ColText) + text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(posY-25), ColText) // DEBUG: Rote Hitbox if def, ok := g.world.Manifest.Assets["player"]; ok { - hx := float32(p.X + def.DrawOffX + def.Hitbox.OffsetX - g.camX) - hy := float32(p.Y + def.DrawOffY + def.Hitbox.OffsetY) + hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX) + hy := float32(posY + def.DrawOffY + def.Hitbox.OffsetY) vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 2, color.RGBA{255, 0, 0, 255}, false) } } @@ -211,6 +228,25 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } else if g.gameState.Status == "RUNNING" { dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0) text.Draw(screen, dist, basicfont.Face7x13, ScreenWidth-150, 30, ColText) + + // Score anzeigen + for _, p := range g.gameState.Players { + if p.Name == g.playerName { + scoreStr := fmt.Sprintf("Score: %d", p.Score) + text.Draw(screen, scoreStr, basicfont.Face7x13, ScreenWidth-150, 50, ColText) + break + } + } + } else if g.gameState.Status == "GAMEOVER" { + // Game Over Screen mit allen Scores + text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 100, color.RGBA{255, 0, 0, 255}) + + y := 150 + for _, p := range g.gameState.Players { + scoreMsg := fmt.Sprintf("%s: %d pts", p.Name, p.Score) + text.Draw(screen, scoreMsg, basicfont.Face7x13, ScreenWidth/2-80, y, color.White) + y += 20 + } } // 5. DEBUG: TODES-LINIE @@ -282,6 +318,9 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl if img != nil { op := &ebiten.DrawImageOptions{} + // Filter für bessere Skalierung (besonders bei großen Sprites) + op.Filter = ebiten.FilterLinear + // Skalieren op.GeoM.Scale(def.Scale, def.Scale) @@ -291,8 +330,11 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl screenY+def.DrawOffY, ) - // Farbe anwenden - op.ColorScale.ScaleWithColor(def.Color.ToRGBA()) + // Farbe anwenden (nur wenn explizit gesetzt) + // Wenn Color leer ist (R=G=B=A=0), nicht anwenden (Bild bleibt original) + if def.Color.R != 0 || def.Color.G != 0 || def.Color.B != 0 || def.Color.A != 0 { + op.ColorScale.ScaleWithColor(def.Color.ToRGBA()) + } screen.DrawImage(img, op) } else { diff --git a/cmd/client/main.go b/cmd/client/main.go index 69b6d8b..fff6f46 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -7,8 +7,11 @@ import ( _ "image/png" "io/ioutil" "log" + "math/rand" "path/filepath" "runtime" + "sort" + "strings" "sync" "time" @@ -28,7 +31,8 @@ const ( ScreenWidth = 1280 ScreenHeight = 720 StateMenu = 0 - StateGame = 1 + StateLobby = 1 + StateGame = 2 RefFloorY = 540 ) @@ -41,6 +45,15 @@ var ( ColDirt = color.RGBA{101, 67, 33, 255} ) +// InputState speichert einen einzelnen Input für Replay +type InputState struct { + Sequence uint32 + Left bool + Right bool + Jump bool + Down bool +} + // --- GAME STRUCT --- type Game struct { appState int @@ -53,8 +66,24 @@ type Game struct { // Spieler Info playerName string - roomID string // <-- NEU: Raum ID + roomID string activeField string // "name" oder "room" + gameMode string // "solo" oder "coop" + isHost bool + + // Lobby State (für Change Detection) + lastPlayerCount int + lastStatus string + + // Client Prediction + predictedX float64 // Vorhergesagte Position + predictedY float64 + predictedVX float64 + predictedVY float64 + predictedGround bool + inputSequence uint32 // Sequenznummer für Inputs + pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs + lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz // Kamera camX float64 @@ -74,9 +103,10 @@ func NewGame() *Game { assetsImages: make(map[string]*ebiten.Image), gameState: game.GameState{Players: make(map[string]game.PlayerState)}, - playerName: "Student", - roomID: "room1", // Standard Raum - activeField: "name", + playerName: "Student", + activeField: "name", + gameMode: "", + pendingInputs: make(map[uint32]InputState), joyBaseX: 150, joyBaseY: ScreenHeight - 150, joyStickX: 150, joyStickY: ScreenHeight - 150, @@ -102,109 +132,336 @@ func (g *Game) loadAssets() { g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)} } + // Chunks laden + chunkDir := filepath.Join(baseDir, "chunks") + err = g.world.LoadChunkLibrary(chunkDir) + if err != nil { + log.Println("⚠️ Chunks konnten nicht geladen werden:", err) + } else { + fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge") + // DEBUG: Details der geladenen Chunks + for id, chunk := range g.world.ChunkLibrary { + fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects)) + } + } + // Bilder vorladen + loadedImages := 0 + failedImages := 0 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 + loadedImages++ } else { - // log.Println("Fehler beim Laden von Bild:", def.Filename) + log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err) + failedImages++ } } } + fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages) } // --- 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 = "" - } - + g.updateMenu() + case StateLobby: + g.updateLobby() case StateGame: - g.UpdateGame() // In game_render.go + g.UpdateGame() } return nil } +func (g *Game) updateMenu() { + g.handleMenuInput() + + // Name-Feld + fieldW, fieldH := 250, 40 + nameX := ScreenWidth/2 - fieldW/2 + nameY := ScreenHeight/2 - 150 + + if isHit(nameX, nameY, fieldW, fieldH) { + g.activeField = "name" + return + } + + // Mode-Buttons + btnW, btnH := 200, 60 + soloX := ScreenWidth/2 - btnW - 20 + coopX := ScreenWidth/2 + 20 + btnY := ScreenHeight/2 - 20 + + if isHit(soloX, btnY, btnW, btnH) { + // SOLO MODE + if g.playerName == "" { + g.playerName = "Player" + } + g.gameMode = "solo" + g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano()) + g.isHost = true + g.appState = StateGame + go g.connectAndStart() + } else if isHit(coopX, btnY, btnW, btnH) { + // CO-OP MODE + if g.playerName == "" { + g.playerName = "Player" + } + g.gameMode = "coop" + g.roomID = generateRoomCode() + g.isHost = true + g.appState = StateLobby + go g.connectAndStart() + } + + // Join Button (unten) + joinW, joinH := 300, 50 + joinX := ScreenWidth/2 - joinW/2 + joinY := ScreenHeight/2 + 100 + + if isHit(joinX, joinY, joinW, joinH) { + g.activeField = "room" + } + + // Join Code Feld + if g.activeField == "room" { + roomFieldW := 200 + roomFieldX := ScreenWidth/2 - roomFieldW/2 + roomFieldY := ScreenHeight/2 + 160 + + if isHit(roomFieldX, roomFieldY, roomFieldW, 40) { + // Stay in room field + } else if isHit(roomFieldX+roomFieldW+20, roomFieldY, 100, 40) { + // JOIN button next to code field + if g.roomID != "" && g.playerName != "" { + g.gameMode = "coop" + g.isHost = false + g.appState = StateLobby + go g.connectAndStart() + } + } + } +} + +func (g *Game) updateLobby() { + // Start Button (nur für Host) + if g.isHost { + btnW, btnH := 200, 60 + btnX := ScreenWidth/2 - btnW/2 + btnY := ScreenHeight - 150 + + if isHit(btnX, btnY, btnW, btnH) { + // START GAME + g.SendCommand("START") + } + } + + // Zurück Button + backW, backH := 100, 40 + if isHit(50, 50, backW, backH) { + if g.conn != nil { + g.conn.Close() + } + g.appState = StateMenu + g.connected = false + g.gameState = game.GameState{Players: make(map[string]game.PlayerState)} + } + + // Spiel wurde gestartet? + if g.gameState.Status == "COUNTDOWN" || g.gameState.Status == "RUNNING" { + g.appState = StateGame + } +} + // --- DRAW --- func (g *Game) Draw(screen *ebiten.Image) { switch g.appState { case StateMenu: g.DrawMenu(screen) + case StateLobby: + g.DrawLobby(screen) case StateGame: - g.DrawGame(screen) // In game_render.go + g.DrawGame(screen) } } 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) + // Titel + title := "ESCAPE FROM TEACHER" + text.Draw(screen, title, basicfont.Face7x13, ScreenWidth/2-80, 100, ColText) - display := value - if g.activeField == fieldID && (time.Now().UnixMilli()/500)%2 == 0 { + // Name-Feld + fieldW := 250 + nameX := ScreenWidth/2 - fieldW/2 + nameY := ScreenHeight/2 - 150 + + col := color.RGBA{50, 50, 60, 255} + if g.activeField == "name" { + col = color.RGBA{70, 70, 80, 255} + } + vector.DrawFilledRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, col, false) + vector.StrokeRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, 1, color.White, false) + + display := g.playerName + if g.activeField == "name" && (time.Now().UnixMilli()/500)%2 == 0 { + display += "|" + } + text.Draw(screen, "Name: "+display, basicfont.Face7x13, nameX+10, nameY+25, ColText) + + // Mode Selection + text.Draw(screen, "Select Game Mode:", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-60, ColText) + + // SOLO Button + btnW, btnH := 200, 60 + soloX := ScreenWidth/2 - btnW - 20 + btnY := ScreenHeight/2 - 20 + vector.DrawFilledRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false) + vector.StrokeRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false) + text.Draw(screen, "SOLO", basicfont.Face7x13, soloX+80, btnY+35, ColText) + + // CO-OP Button + coopX := ScreenWidth/2 + 20 + vector.DrawFilledRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false) + vector.StrokeRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false) + text.Draw(screen, "CO-OP (Host)", basicfont.Face7x13, coopX+45, btnY+35, ColText) + + // Join Section + joinY := ScreenHeight/2 + 100 + text.Draw(screen, "Or join a room:", basicfont.Face7x13, ScreenWidth/2-60, joinY, color.Gray{200}) + + if g.activeField == "room" { + roomFieldW := 200 + roomFieldX := ScreenWidth/2 - roomFieldW/2 + roomFieldY := ScreenHeight/2 + 160 + + col := color.RGBA{70, 70, 80, 255} + vector.DrawFilledRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, col, false) + vector.StrokeRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, 1, color.White, false) + + display := g.roomID + if (time.Now().UnixMilli()/500)%2 == 0 { display += "|" } - text.Draw(screen, label+": "+display, basicfont.Face7x13, x+10, y+25, ColText) + text.Draw(screen, display, basicfont.Face7x13, roomFieldX+10, roomFieldY+25, ColText) + + // Join Button + joinBtnX := roomFieldX + roomFieldW + 20 + vector.DrawFilledRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, color.RGBA{0, 150, 0, 255}, false) + vector.StrokeRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, 2, color.White, false) + text.Draw(screen, "JOIN", basicfont.Face7x13, joinBtnX+30, roomFieldY+25, ColText) + } else { + joinBtnW := 300 + joinBtnX := ScreenWidth/2 - joinBtnW/2 + joinBtnY := ScreenHeight/2 + 120 + vector.DrawFilledRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, ColBtnNormal, false) + vector.StrokeRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, 2, color.White, false) + text.Draw(screen, "Join with Code", basicfont.Face7x13, joinBtnX+90, joinBtnY+30, 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) + text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150}) +} - // 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) +func (g *Game) DrawLobby(screen *ebiten.Image) { + screen.Fill(color.RGBA{20, 20, 30, 255}) - text.Draw(screen, "WASD / Arrows to Move - SPACE to Jump\nMobile: Touch Controls", basicfont.Face7x13, ScreenWidth/2-120, ScreenHeight-50, color.Gray{150}) + // Titel + text.Draw(screen, "LOBBY", basicfont.Face7x13, ScreenWidth/2-20, 80, ColText) + + // Room Code (groß anzeigen) + text.Draw(screen, "Room Code:", basicfont.Face7x13, ScreenWidth/2-40, 150, color.Gray{200}) + codeBoxW, codeBoxH := 300, 60 + codeBoxX := ScreenWidth/2 - codeBoxW/2 + codeBoxY := 170 + vector.DrawFilledRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), color.RGBA{50, 50, 60, 255}, false) + vector.StrokeRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), 3, color.RGBA{100, 200, 100, 255}, false) + text.Draw(screen, g.roomID, basicfont.Face7x13, codeBoxX+100, codeBoxY+35, color.RGBA{100, 255, 100, 255}) + + // Spieler-Liste + g.stateMutex.Lock() + playerCount := len(g.gameState.Players) + + // Spieler in sortierte Liste konvertieren (damit sie nicht flackern) + type PlayerEntry struct { + ID string + Name string + IsHost bool + } + players := make([]PlayerEntry, 0, playerCount) + hostID := g.gameState.HostID + + for id, p := range g.gameState.Players { + name := p.Name + if name == "" { + name = id + } + isHost := (id == hostID) + players = append(players, PlayerEntry{ + ID: id, + Name: name, + IsHost: isHost, + }) + + } + g.stateMutex.Unlock() + + // Sortieren: Host zuerst, dann alphabetisch nach Name + sort.SliceStable(players, func(i, j int) bool { + if players[i].IsHost { + return true + } + if players[j].IsHost { + return false + } + return players[i].Name < players[j].Name + }) + + text.Draw(screen, fmt.Sprintf("Players (%d/16):", playerCount), basicfont.Face7x13, 100, 280, ColText) + + y := 310 + for _, p := range players { + name := p.Name + // Host markieren + if p.IsHost { + name += " [HOST]" + } + + text.Draw(screen, "• "+name, basicfont.Face7x13, 120, y, ColText) + y += 25 + if y > ScreenHeight-200 { + break + } + } + + // Status + statusY := ScreenHeight - 180 + statusText := "Waiting for host to start..." + statusCol := color.RGBA{200, 200, 0, 255} + if g.gameState.Status == "COUNTDOWN" { + statusText = fmt.Sprintf("Starting in %d...", g.gameState.TimeLeft) + statusCol = color.RGBA{255, 150, 0, 255} + } + text.Draw(screen, statusText, basicfont.Face7x13, ScreenWidth/2-80, statusY, statusCol) + + // Start Button (nur für Host) + if g.isHost && g.gameState.Status == "LOBBY" { + btnW, btnH := 200, 60 + btnX := ScreenWidth/2 - btnW/2 + btnY := ScreenHeight - 150 + vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), color.RGBA{0, 180, 0, 255}, false) + vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false) + text.Draw(screen, "START GAME", basicfont.Face7x13, btnX+60, btnY+35, ColText) + } + + // Zurück Button + vector.DrawFilledRect(screen, 50, 50, 100, 40, color.RGBA{150, 0, 0, 255}, false) + vector.StrokeRect(screen, 50, 50, 100, 40, 2, color.White, false) + text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText) } func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight } @@ -236,11 +493,14 @@ func (g *Game) handleMenuInput() { var target *string if g.activeField == "name" { target = &g.playerName - } - if g.activeField == "room" { + } else if g.activeField == "room" { target = &g.roomID } + if target == nil { + return + } + if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { g.activeField = "" } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { @@ -248,10 +508,25 @@ func (g *Game) handleMenuInput() { *target = (*target)[:len(*target)-1] } } else { - *target += string(ebiten.InputChars()) + // Für Room Code: Nur Großbuchstaben und Zahlen + chars := string(ebiten.InputChars()) + if g.activeField == "room" { + chars = strings.ToUpper(chars) + } + *target += chars } } +func generateRoomCode() string { + rand.Seed(time.Now().UnixNano()) + chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + code := make([]byte, 6) + for i := range code { + code[i] = chars[rand.Intn(len(chars))] + } + return string(code) +} + func (g *Game) connectAndStart() { // URL: Wasm -> WS, Desktop -> TCP serverURL := "nats://localhost:4222" @@ -267,11 +542,30 @@ func (g *Game) connectAndStart() { ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER) g.conn = ec - sub, err := g.conn.Subscribe("game.update", func(state *game.GameState) { + // Subscribe nur auf Updates für DIESEN Raum + roomChannel := fmt.Sprintf("game.update.%s", g.roomID) + log.Printf("👂 Lausche auf Channel: %s", roomChannel) + + sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) { + // Server Reconciliation für lokalen Spieler (VOR dem Lock) + for _, p := range state.Players { + if p.Name == g.playerName { + // Reconcile mit Server-State (verwendet keinen stateMutex) + g.ReconcileWithServer(p) + break + } + } + g.stateMutex.Lock() + oldPlayerCount := len(g.gameState.Players) + oldStatus := g.gameState.Status g.gameState = *state g.stateMutex.Unlock() - log.Printf("📦 State empfangen: Players=%d, Chunks=%d, Status=%s", len(state.Players), len(state.WorldChunks), state.Status) + + // Nur bei Änderungen loggen + if len(state.Players) != oldPlayerCount || state.Status != oldStatus { + log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, state.Status) + } }) if err != nil { @@ -295,6 +589,14 @@ func (g *Game) connectAndStart() { return } g.connected = true + + // Initiale predicted Position + g.predictedX = 100 + g.predictedY = 200 + g.predictedVX = 0 + g.predictedVY = 0 + g.predictedGround = false + log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...") } @@ -302,26 +604,79 @@ 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 + myID := g.getMyPlayerID() + g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType}) +} + +func (g *Game) SendInputWithSequence(input InputState) { + if !g.connected { + return } - g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType}) + myID := g.getMyPlayerID() + + // Inputs als einzelne Commands senden + if input.Left { + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "LEFT_DOWN", + Sequence: input.Sequence, + }) + } + if input.Right { + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "RIGHT_DOWN", + Sequence: input.Sequence, + }) + } + if input.Jump { + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "JUMP", + Sequence: input.Sequence, + }) + } + if input.Down { + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "DOWN", + Sequence: input.Sequence, + }) + } + + // Wenn weder Links noch Rechts, sende STOP + if !input.Left && !input.Right { + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "LEFT_UP", + Sequence: input.Sequence, + }) + g.conn.Publish("game.input", game.ClientInput{ + PlayerID: myID, + Type: "RIGHT_UP", + Sequence: input.Sequence, + }) + } +} + +func (g *Game) getMyPlayerID() string { + g.stateMutex.Lock() + defer g.stateMutex.Unlock() + + for id, p := range g.gameState.Players { + if p.Name == g.playerName { + return id + } + } + return g.playerName } func main() { ebiten.SetWindowSize(ScreenWidth, ScreenHeight) ebiten.SetWindowTitle("Escape From Teacher") + ebiten.SetTPS(60) // Tick Per Second auf 60 setzen + ebiten.SetVsyncEnabled(true) // VSync aktivieren if err := ebiten.RunGame(NewGame()); err != nil { log.Fatal(err) } diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go new file mode 100644 index 0000000..3bff564 --- /dev/null +++ b/cmd/client/prediction.go @@ -0,0 +1,79 @@ +package main + +import ( + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// ApplyInput wendet einen Input auf den vorhergesagten Zustand an +func (g *Game) ApplyInput(input InputState) { + // Horizontale Bewegung + moveX := 0.0 + if input.Left { + moveX = -1.0 + } else if input.Right { + moveX = 1.0 + } + + speed := config.RunSpeed + (moveX * 4.0) + g.predictedX += speed + + // Gravitation + g.predictedVY += config.Gravity + if g.predictedVY > config.MaxFall { + g.predictedVY = config.MaxFall + } + + // Fast Fall + if input.Down { + g.predictedVY = 15.0 + } + + // Sprung + if input.Jump && g.predictedGround { + g.predictedVY = -14.0 + g.predictedGround = false + } + + // Vertikale Bewegung + g.predictedY += g.predictedVY + + // Einfache Boden-Kollision (hardcoded für jetzt) + if g.predictedY >= 540 { + g.predictedY = 540 + g.predictedVY = 0 + g.predictedGround = true + } else { + g.predictedGround = false + } +} + +// ReconcileWithServer gleicht lokale Prediction mit Server-State ab +func (g *Game) ReconcileWithServer(serverState game.PlayerState) { + // Server-bestätigte Sequenz + g.lastServerSeq = serverState.LastInputSeq + + // Entferne alle bestätigten Inputs + for seq := range g.pendingInputs { + if seq <= g.lastServerSeq { + delete(g.pendingInputs, seq) + } + } + + // Setze vorhergesagte Position auf Server-Position + g.predictedX = serverState.X + g.predictedY = serverState.Y + g.predictedVX = serverState.VX + g.predictedVY = serverState.VY + g.predictedGround = serverState.OnGround + + // Replay alle noch nicht bestätigten Inputs + // (Sortiert nach Sequenz) + if len(g.pendingInputs) > 0 { + for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ { + if input, ok := g.pendingInputs[seq]; ok { + g.ApplyInput(input) + } + } + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go index f43a170..27aa20f 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "log" "path/filepath" - "runtime" "sync" "github.com/nats-io/nats.go" diff --git a/pkg/game/data.go b/pkg/game/data.go index d835924..c943bae 100644 --- a/pkg/game/data.go +++ b/pkg/game/data.go @@ -55,9 +55,10 @@ type LoginPayload struct { // Input vom Spieler während des Spiels type ClientInput struct { - Type string `json:"type"` // "JUMP", "START" + Type string `json:"type"` // "JUMP", "START", "LEFT_DOWN", "RIGHT_DOWN", etc. RoomID string `json:"room_id"` PlayerID string `json:"player_id"` + Sequence uint32 `json:"sequence"` // Sequenznummer für Client Prediction } type JoinRequest struct { @@ -66,21 +67,27 @@ type JoinRequest struct { } type PlayerState struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - State string `json:"state"` - OnGround bool `json:"on_ground"` + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + State string `json:"state"` + OnGround bool `json:"on_ground"` + LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz + Score int `json:"score"` // Punkte des Spielers + IsAlive bool `json:"is_alive"` // Lebt der Spieler noch? + IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus } type GameState struct { - RoomID string `json:"room_id"` - Players map[string]PlayerState `json:"players"` - Status string `json:"status"` - TimeLeft int `json:"time_left"` - WorldChunks []ActiveChunk `json:"world_chunks"` - HostID string `json:"host_id"` - - ScrollX float64 `json:"scroll_x"` + RoomID string `json:"room_id"` + Players map[string]PlayerState `json:"players"` + Status string `json:"status"` + TimeLeft int `json:"time_left"` + WorldChunks []ActiveChunk `json:"world_chunks"` + HostID string `json:"host_id"` + ScrollX float64 `json:"scroll_x"` + CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex) } diff --git a/pkg/server/room.go b/pkg/server/room.go index 49129b8..5f03be6 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -12,28 +12,34 @@ import ( ) type ServerPlayer struct { - ID string - Name string - X, Y float64 - VX, VY float64 - OnGround bool - InputX float64 // -1 (Links), 0, 1 (Rechts) + ID string + Name string + X, Y float64 + VX, VY float64 + OnGround bool + InputX float64 // -1 (Links), 0, 1 (Rechts) + LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz + Score int + IsAlive bool + IsSpectator bool } type Room struct { - ID string - NC *nats.Conn - World *game.World - Mutex sync.RWMutex - Players map[string]*ServerPlayer - ActiveChunks []game.ActiveChunk - Colliders []game.Collider - Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER" - GlobalScrollX float64 - MapEndX float64 - Countdown int - NextStart time.Time - HostID string + ID string + NC *nats.Conn + World *game.World + Mutex sync.RWMutex + Players map[string]*ServerPlayer + ActiveChunks []game.ActiveChunk + Colliders []game.Collider + Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER" + GlobalScrollX float64 + MapEndX float64 + Countdown int + NextStart time.Time + HostID string + CollectedCoins map[string]bool // Key: "chunkID_objectIndex" + ScoreAccum float64 // Akkumulator für Distanz-Score stopChan chan struct{} @@ -48,13 +54,14 @@ type Room struct { // Konstruktor func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { r := &Room{ - ID: id, - NC: nc, - World: w, - Players: make(map[string]*ServerPlayer), - Status: "LOBBY", - stopChan: make(chan struct{}), - pW: 40, pH: 60, // Fallback + ID: id, + NC: nc, + World: w, + Players: make(map[string]*ServerPlayer), + Status: "LOBBY", + stopChan: make(chan struct{}), + CollectedCoins: make(map[string]bool), + pW: 40, pH: 60, // Fallback } // Player Werte aus Manifest laden @@ -72,11 +79,22 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { r.ActiveChunks = append(r.ActiveChunks, startChunk) r.MapEndX = 1280 + // DEBUG: Verfügbare Chunks anzeigen + log.Printf("🗂️ Raum %s: ChunkLibrary hat %d Einträge", id, len(w.ChunkLibrary)) + for cid := range w.ChunkLibrary { + log.Printf(" - %s", cid) + } + // Erste Chunks generieren r.SpawnNextChunk() r.SpawnNextChunk() r.Colliders = r.World.GenerateColliders(r.ActiveChunks) + log.Printf("🎬 Raum %s gestartet mit %d Chunks", id, len(r.ActiveChunks)) + for _, ac := range r.ActiveChunks { + log.Printf(" - Chunk '%s' bei X=%.0f", ac.ChunkID, ac.X) + } + return r } @@ -108,18 +126,27 @@ func (r *Room) AddPlayer(id, name string) { return } + // Spawn-Position berechnen + playerIndex := len(r.Players) + spawnX := 100.0 + float64(playerIndex*150) + p := &ServerPlayer{ - ID: id, - Name: name, - X: 100, - Y: 200, - OnGround: false, + ID: id, + Name: name, + X: spawnX, + Y: 200, + OnGround: false, + Score: 0, + IsAlive: true, + IsSpectator: false, } - // Falls das Spiel schon läuft, spawnen wir weiter rechts - if r.Status == "RUNNING" { - p.X = r.GlobalScrollX + 200 - p.Y = 200 + // Falls das Spiel schon läuft, NICHT joinen erlauben + if r.Status == "RUNNING" || r.Status == "COUNTDOWN" { + // Spieler wird als Zuschauer hinzugefügt + p.IsSpectator = true + p.IsAlive = false + log.Printf("⚠️ Spieler %s joined während des Spiels - wird Zuschauer", name) } r.Players[id] = p @@ -127,6 +154,21 @@ func (r *Room) AddPlayer(id, name string) { // Erster Spieler wird Host if r.HostID == "" { r.HostID = id + + // Auto-Start nur für Solo-Räume (erkennen am "solo_" Prefix) + if len(r.ID) > 5 && r.ID[:5] == "solo_" { + log.Printf("⏰ Solo-Mode: Auto-Start in 2 Sekunden für Raum %s", r.ID) + go func() { + time.Sleep(2 * time.Second) + r.Mutex.Lock() + if r.Status == "LOBBY" { + r.Status = "COUNTDOWN" + r.NextStart = time.Now().Add(3 * time.Second) + log.Printf("🎮 Raum %s: Countdown gestartet!", r.ID) + } + r.Mutex.Unlock() + }() + } } } @@ -150,6 +192,11 @@ func (r *Room) HandleInput(input game.ClientInput) { return } + // Sequenznummer aktualisieren + if input.Sequence > p.LastInputSeq { + p.LastInputSeq = input.Sequence + } + switch input.Type { case "JUMP": if p.OnGround { @@ -203,6 +250,11 @@ func (r *Room) Update() { // 2. Spieler Physik for _, p := range r.Players { + // Skip tote/Zuschauer Spieler + if !p.IsAlive || p.IsSpectator { + continue + } + // Lobby Mode if r.Status != "RUNNING" { p.VY += config.Gravity @@ -217,6 +269,13 @@ func (r *Room) Update() { continue } + // Prüfe aktuelle Position auf Obstacles (falls reingesprungen) + currentHit, currentType := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) + if currentHit && currentType == "obstacle" { + r.KillPlayer(p) + continue + } + // X Bewegung currentSpeed := config.RunSpeed + (p.InputX * 4.0) nextX := p.X + currentSpeed @@ -224,7 +283,7 @@ func (r *Room) Update() { hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) if hitX { if typeX == "obstacle" { - r.ResetPlayer(p) + r.KillPlayer(p) continue } } else { @@ -236,7 +295,7 @@ func (r *Room) Update() { p.X = r.GlobalScrollX + 1200 } // Rechts Block if p.X < r.GlobalScrollX-50 { - r.ResetPlayer(p) + r.KillPlayer(p) continue } // Links Tod @@ -254,7 +313,7 @@ func (r *Room) Update() { hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) if hitY { if typeY == "obstacle" { - r.ResetPlayer(p) + r.KillPlayer(p) continue } if p.VY > 0 { @@ -267,8 +326,16 @@ func (r *Room) Update() { } if p.Y > 1000 { - r.ResetPlayer(p) + r.KillPlayer(p) } + + // Coin Kollision prüfen + r.CheckCoinCollision(p) + } + + // 2b. Distanz-Score updaten + if r.Status == "RUNNING" { + r.UpdateDistanceScore() } // 3. Map Management @@ -289,6 +356,11 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} for _, c := range r.Colliders { if game.CheckRectCollision(playerRect, c.Rect) { + if c.Type == "obstacle" { + log.Printf("🔴 OBSTACLE HIT! Player: (%.0f, %.0f, %.0f, %.0f), Obstacle: (%.0f, %.0f, %.0f, %.0f)", + playerRect.OffsetX, playerRect.OffsetY, playerRect.W, playerRect.H, + c.Rect.OffsetX, c.Rect.OffsetY, c.Rect.W, c.Rect.H) + } return true, c.Type } } @@ -346,30 +418,41 @@ func (r *Room) Broadcast() { defer r.Mutex.RUnlock() state := game.GameState{ - RoomID: r.ID, - Players: make(map[string]game.PlayerState), - Status: r.Status, - TimeLeft: r.Countdown, - WorldChunks: r.ActiveChunks, - HostID: r.HostID, - ScrollX: r.GlobalScrollX, + RoomID: r.ID, + Players: make(map[string]game.PlayerState), + Status: r.Status, + TimeLeft: r.Countdown, + WorldChunks: r.ActiveChunks, + HostID: r.HostID, + ScrollX: r.GlobalScrollX, + CollectedCoins: r.CollectedCoins, } for id, p := range r.Players { state.Players[id] = game.PlayerState{ - ID: id, Name: p.Name, X: p.X, Y: p.Y, OnGround: p.OnGround, + ID: id, + Name: p.Name, + X: p.X, + Y: p.Y, + VX: p.VX, + VY: p.VY, + OnGround: p.OnGround, + LastInputSeq: p.LastInputSeq, + Score: p.Score, + IsAlive: p.IsAlive, + IsSpectator: p.IsSpectator, } } - // DEBUG: Ersten Broadcast loggen - if len(r.Players) > 0 { + // DEBUG: Ersten Broadcast loggen (nur beim ersten Mal) + if len(r.Players) > 0 && r.Status == "LOBBY" { log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status) } - // Senden an "game.update" (Client filtert nicht wirklich, aber für Demo ok) - // Besser wäre "game.update." + // Senden an raum-spezifischen Channel: "game.update." + channel := "game.update." + r.ID ec, _ := nats.NewEncodedConn(r.NC, nats.JSON_ENCODER) - ec.Publish("game.update", state) + ec.Publish(channel, state) } // RemovePlayer entfernt einen Spieler aus dem Raum diff --git a/pkg/server/scoring.go b/pkg/server/scoring.go new file mode 100644 index 0000000..082cca9 --- /dev/null +++ b/pkg/server/scoring.go @@ -0,0 +1,129 @@ +package server + +import ( + "fmt" + "log" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// CheckCoinCollision prüft ob Spieler Coins einsammelt +func (r *Room) CheckCoinCollision(p *ServerPlayer) { + if !p.IsAlive || p.IsSpectator { + return + } + + playerHitbox := game.Rect{ + OffsetX: p.X + r.pDrawOffX + r.pHitboxOffX, + OffsetY: p.Y + r.pDrawOffY + r.pHitboxOffY, + W: r.pW, + H: r.pH, + } + + // Durch alle aktiven Chunks iterieren + for _, activeChunk := range r.ActiveChunks { + chunkDef, exists := r.World.ChunkLibrary[activeChunk.ChunkID] + if !exists { + continue + } + + // Durch alle Objekte im Chunk + for objIdx, obj := range chunkDef.Objects { + assetDef, ok := r.World.Manifest.Assets[obj.AssetID] + if !ok { + continue + } + + // Nur Coins prüfen + if assetDef.Type != "coin" { + continue + } + + // Eindeutiger Key für diesen Coin + coinKey := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx) + + // Wurde bereits eingesammelt? + if r.CollectedCoins[coinKey] { + continue + } + + // Coin-Hitbox + coinHitbox := game.Rect{ + OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX, + OffsetY: obj.Y + assetDef.Hitbox.OffsetY, + W: assetDef.Hitbox.W, + H: assetDef.Hitbox.H, + } + + // Kollision? + if game.CheckRectCollision(playerHitbox, coinHitbox) { + // Coin einsammeln! + r.CollectedCoins[coinKey] = true + p.Score += 200 + log.Printf("💰 %s hat Coin eingesammelt! Score: %d", p.Name, p.Score) + } + } + } +} + +// UpdateDistanceScore aktualisiert Distanz-basierte Punkte +func (r *Room) UpdateDistanceScore() { + if r.Status != "RUNNING" { + return + } + + // Anzahl lebender Spieler zählen + aliveCount := 0 + for _, p := range r.Players { + if p.IsAlive && !p.IsSpectator { + aliveCount++ + } + } + + if aliveCount == 0 { + return + } + + // Multiplier = Anzahl lebender Spieler + multiplier := float64(aliveCount) + + // Akkumulator erhöhen: multiplier Punkte pro Sekunde + // Bei 60 FPS: multiplier / 60.0 Punkte pro Tick + r.ScoreAccum += multiplier / 60.0 + + // Wenn Akkumulator >= 1.0, Punkte vergeben + if r.ScoreAccum >= 1.0 { + pointsToAdd := int(r.ScoreAccum) + r.ScoreAccum -= float64(pointsToAdd) + + for _, p := range r.Players { + if p.IsAlive && !p.IsSpectator { + p.Score += pointsToAdd + } + } + } +} + +// KillPlayer markiert Spieler als tot +func (r *Room) KillPlayer(p *ServerPlayer) { + if !p.IsAlive { + return + } + + p.IsAlive = false + p.IsSpectator = true + log.Printf("💀 %s ist gestorben! Final Score: %d", p.Name, p.Score) + + // Prüfen ob alle tot sind + aliveCount := 0 + for _, pl := range r.Players { + if pl.IsAlive && !pl.IsSpectator { + aliveCount++ + } + } + + if aliveCount == 0 && r.Status == "RUNNING" { + log.Printf("🏁 Alle Spieler tot - Game Over!") + r.Status = "GAMEOVER" + } +}