From ced5011718d747c737a071dad5df80188ee5a3f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sun, 22 Mar 2026 17:43:51 +0100 Subject: [PATCH] fix game --- cmd/client/game_render.go | 800 +++++++++++++++++++------------------- cmd/client/main.go | 105 ++--- 2 files changed, 430 insertions(+), 475 deletions(-) diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 0bd36b4..3bf1893 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -12,8 +12,111 @@ import ( "github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/vector" "golang.org/x/image/font/basicfont" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" ) +// --- KONSTANTEN --- + +const ( + physicsStep = 50 * time.Millisecond // Server-Physics-Rate (20 TPS) + jumpBufferFrames = 6 // Sprung-Buffer: ~300ms bei 20 TPS + coyoteTimeFrames = 4 // Coyote Time: ~200ms bei 20 TPS + inputCap = 60 // Max unbestätigte Inputs (~3s bei 20 TPS) + + joystickDeadzone = 0.15 // Minimale Joystick-Auslenkung + joyDownThreshold = 0.5 // Joystick-Down-Schwellenwert (Anteil am Radius) + correctionDecay = 0.85 // Visueller Korrektur-Decay pro Frame + + // Touch-Control Größen (relativ zur kleineren Screendimension) + joyRadiusScale = 0.13 // Joystick-Außenring + joyKnobRatio = 0.48 // Knob ~halb so groß wie Ring + jumpBtnScale = 0.11 // Sprung-Button + downBtnScale = 0.08 // Down-Button + + // Touch-Control Positionen (X als Anteil der Canvas-Breite) + joyDefaultXRatio = 0.18 + jumpBtnXRatio = 0.82 + downBtnXRatio = 0.62 + + // Culling-Puffer: Objekte die ±800px außerhalb des Canvas liegen werden übersprungen + cullingBuffer = 800.0 +) + +// --- RENDER SNAPSHOT --- + +// renderSnapshot hält eine Momentaufnahme aller für DrawGame benötigten Daten. +// Beide Mutexe werden kurz gehalten, um den Snapshot zu befüllen, und dann +// sofort freigegeben – so gibt es keine Lock-Verschachtelung beim Zeichnen. +type renderSnapshot struct { + // Canvas + canvasW, canvasH int + viewScale float64 + + // Lokaler Spieler + isDead bool + myScore int + + // Spielzustand (aus stateMutex) + status string + timeLeft int + difficultyFactor float64 + chunks []game.ActiveChunk + movingPlatforms []game.MovingPlatformSync + collectedCoins map[string]bool + collectedPowerups map[string]bool + players map[string]game.PlayerState + + // Client-Prediction (aus predictionMutex) + prevPredX, prevPredY float64 + predX, predY float64 + offsetX, offsetY float64 + physicsTime time.Time +} + +// takeRenderSnapshot liest alle benötigten Daten unter den jeweiligen Mutexen +// und gibt einen lock-freien Snapshot zurück. +func (g *Game) takeRenderSnapshot(screen *ebiten.Image) renderSnapshot { + canvasW, canvasH := screen.Size() + snap := renderSnapshot{ + canvasW: canvasW, + canvasH: canvasH, + viewScale: GetScaleFromHeight(canvasH), + } + + // Prediction-Daten (kurzer Lock) + g.predictionMutex.Lock() + snap.prevPredX = g.prevPredictedX + snap.prevPredY = g.prevPredictedY + snap.predX = g.predictedX + snap.predY = g.predictedY + snap.offsetX = g.correctionOffsetX + snap.offsetY = g.correctionOffsetY + snap.physicsTime = g.lastPhysicsTime + g.predictionMutex.Unlock() + + // Spielzustand (kurzer Lock) + g.stateMutex.Lock() + snap.status = g.gameState.Status + snap.timeLeft = g.gameState.TimeLeft + snap.difficultyFactor = g.gameState.DifficultyFactor + snap.chunks = g.gameState.WorldChunks + snap.movingPlatforms = g.gameState.MovingPlatforms + snap.collectedCoins = g.gameState.CollectedCoins + snap.collectedPowerups = g.gameState.CollectedPowerups + snap.players = g.gameState.Players + for _, p := range g.gameState.Players { + if p.Name == g.playerName { + snap.isDead = !p.IsAlive || p.IsSpectator + snap.myScore = p.Score + break + } + } + g.stateMutex.Unlock() + + return snap +} + // --- INPUT & UPDATE LOGIC --- func (g *Game) UpdateGame() { @@ -28,7 +131,6 @@ func (g *Game) UpdateGame() { keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown) keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp) - // Tastatur-Nutzung erkennen (für Mobile Controls ausblenden) if keyLeft || keyRight || keyDown || keyJump { g.keyboardUsed = true } @@ -36,71 +138,56 @@ func (g *Game) UpdateGame() { // --- 3. TOUCH INPUT HANDLING --- g.handleTouchInput() - // --- 4. INPUT STATE ERSTELLEN --- + // --- 4. JOYSTICK RICHTUNG BERECHNEN --- joyDir := 0.0 if g.joyActive { maxDist := g.joyRadius if maxDist == 0 { - maxDist = 60.0 // Fallback + maxDist = 60.0 } - diffX := g.joyStickX - g.joyBaseX - - joyDir = diffX / maxDist + joyDir = (g.joyStickX - g.joyBaseX) / maxDist if joyDir < -1.0 { joyDir = -1.0 - } - if joyDir > 1.0 { + } else if joyDir > 1.0 { joyDir = 1.0 } - // Deadzone - if joyDir > -0.15 && joyDir < 0.15 { + if joyDir > -joystickDeadzone && joyDir < joystickDeadzone { joyDir = 0 } } - // Down: Joystick nach unten ziehen ODER Down-Button - isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > g.joyRadius*0.5 + isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > g.joyRadius*joyDownThreshold wantsJump := keyJump || g.btnJumpActive g.btnJumpActive = false g.btnDownActive = false - // Jump Buffer: Sprung-Wunsch für bis zu 6 Physics-Frames (=300ms) speichern + // Jump Buffer: Sprung-Wunsch für bis zu jumpBufferFrames speichern if wantsJump { - g.jumpBufferFrames = 6 + g.jumpBufferFrames = jumpBufferFrames } - // --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) --- - // Wichtig: Prediction und Send müssen synchron laufen. - // Nur wenn wir senden, speichern wir den Input in pendingInputs. - if g.connected && time.Since(g.lastInputTime) >= 50*time.Millisecond { + // --- 5. INPUT SENDEN (MIT CLIENT PREDICTION, 20 TPS) --- + if g.connected && time.Since(g.lastInputTime) >= physicsStep { g.lastInputTime = time.Now() - // Coyote Time: War auf dem Boden, jetzt nicht mehr → Timer setzen g.predictionMutex.Lock() wasOnGround := g.predictedGround g.predictionMutex.Unlock() - // Coyote Time Countdown if g.coyoteFrames > 0 { g.coyoteFrames-- } - - // Jump Buffer Countdown if g.jumpBufferFrames > 0 { g.jumpBufferFrames-- } - // Effektiven Jump bestimmen: - // - Direkter Druck, ODER - // - Jump-Buffer aktiv UND jetzt auf dem Boden (Sprung kurz vor Landung) g.predictionMutex.Lock() onGround := g.predictedGround g.predictionMutex.Unlock() if wasOnGround && !onGround { - // Gerade von Kante abgegangen → Coyote Time starten - g.coyoteFrames = 4 + g.coyoteFrames = coyoteTimeFrames } effectiveJump := wantsJump || @@ -108,10 +195,9 @@ func (g *Game) UpdateGame() { (wantsJump && g.coyoteFrames > 0) if effectiveJump { - g.jumpBufferFrames = 0 // Buffer verbraucht + g.jumpBufferFrames = 0 } - // Input State zusammenbauen input := InputState{ Sequence: g.inputSequence, Left: keyLeft || joyDir < -0.1, @@ -122,37 +208,28 @@ func (g *Game) UpdateGame() { } g.predictionMutex.Lock() - // Position vor Physics-Step merken (für Interpolation) g.prevPredictedX = g.predictedX g.prevPredictedY = g.predictedY g.lastPhysicsTime = time.Now() - - // Sequenznummer erhöhen g.inputSequence++ input.Sequence = g.inputSequence - - // Lokale Prediction ausführen für sofortiges Feedback g.ApplyInput(input) - - // Input für History speichern (für Server-Reconciliation) g.pendingInputs[input.Sequence] = input - // Cap: nie mehr als 60 unbestätigte Inputs ansammeln (~3 Sek bei 20/sek) - if len(g.pendingInputs) > 60 { - oldest := g.inputSequence - 60 + if len(g.pendingInputs) > inputCap { + oldest := g.inputSequence - inputCap for seq := range g.pendingInputs { if seq < oldest { delete(g.pendingInputs, seq) } } } - g.predictionMutex.Unlock() g.SendInputWithSequence(input) } - // --- 5. KAMERA LOGIK (mit Smoothing) --- + // --- 6. KAMERA LOGIK (mit Smoothing) --- g.stateMutex.Lock() targetCam := g.gameState.ScrollX g.stateMutex.Unlock() @@ -160,15 +237,9 @@ func (g *Game) UpdateGame() { if targetCam < 0 { targetCam = 0 } + g.camX += (targetCam - g.camX) * 0.2 - // Sanftes Kamera-Folgen: 20% pro Frame Richtung Ziel (bei 60fps ≈ 95ms Halbwertszeit) - diff := targetCam - g.camX - g.camX += diff * 0.2 - - // --- 6. CORRECTION OFFSET ABKLINGEN --- - // Der visuelle Offset sorgt dafür dass Server-Korrekturen sanft und unsichtbar sind. - // Decay: 0.85 pro Frame → ~5 Frames zum Halbieren bei 60fps (≈80ms) - const correctionDecay = 0.85 + // --- 7. CORRECTION OFFSET ABKLINGEN --- g.predictionMutex.Lock() g.correctionOffsetX *= correctionDecay g.correctionOffsetY *= correctionDecay @@ -180,29 +251,25 @@ func (g *Game) UpdateGame() { } g.predictionMutex.Unlock() - // --- 7. PARTIKEL UPDATEN --- - g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms - - // --- 7. PARTIKEL SPAWNEN (State Changes Detection) --- + // --- 8. PARTIKEL --- + g.UpdateParticles(1.0 / 60.0) g.DetectAndSpawnParticles() } -// Verarbeitet Touch-Eingaben für Joystick und Buttons +// handleTouchInput verarbeitet Touch-Eingaben für Joystick und Buttons. func (g *Game) handleTouchInput() { touches := ebiten.TouchIDs() - // Linke Hälfte = Joystick-Zone (55% der Breite, damit auf schmalen Screens etwas Platz bleibt) halfW := float64(g.lastCanvasWidth) * 0.55 if halfW == 0 { - halfW = float64(ScreenWidth) * 0.55 // Fallback + halfW = float64(ScreenWidth) * 0.55 } joyRadius := g.joyRadius if joyRadius == 0 { - joyRadius = 60.0 // Fallback + joyRadius = 60.0 } - // Vorab: alle gerade neu gedrückten Touch-IDs sammeln justPressed := inpututil.JustPressedTouchIDs() isJustPressed := func(id ebiten.TouchID) bool { for _, j := range justPressed { @@ -213,7 +280,6 @@ func (g *Game) handleTouchInput() { return false } - // Reset wenn alle Finger weg if len(touches) == 0 { g.joyActive = false g.btnJumpPressed = false @@ -230,10 +296,9 @@ func (g *Game) handleTouchInput() { if fx >= halfW { // ── RECHTE SEITE: Jump und Down ────────────────────────────────────── - g.btnJumpPressed = true // visuelles Feedback solange Finger drauf + g.btnJumpPressed = true if isJustPressed(id) { - // Down-Button: Prüfen ob Finger in der Nähe des Down-Buttons if g.downBtnR > 0 { dx := fx - g.downBtnX dy := fy - g.downBtnY @@ -242,15 +307,12 @@ func (g *Game) handleTouchInput() { continue } } - // Sonst: Sprung g.btnJumpActive = true } continue } // ── LINKE SEITE: Floating Joystick ─────────────────────────────────── - // Floating = Basis springt zu der Stelle wo der Finger aufsetzt. - // Kein fester Ausgangspunkt nötig → komfortabler auf allen Screen-Größen. if !g.joyActive { g.joyActive = true g.joyTouchID = id @@ -285,420 +347,347 @@ func (g *Game) handleTouchInput() { // --- RENDERING LOGIC --- +// DrawGame ist der zentrale Render-Einstiegspunkt für den Spielzustand. +// Es nimmt einen lock-freien Snapshot aller benötigten Daten und delegiert +// das Zeichnen an spezialisierte Sub-Funktionen. func (g *Game) DrawGame(screen *ebiten.Image) { - // WICHTIG: GAMEOVER-Check ZUERST, bevor wir Locks holen! + // GAMEOVER wird separat behandelt und beendet die Funktion früh. g.stateMutex.Lock() status := g.gameState.Status g.stateMutex.Unlock() if status == "GAMEOVER" { - // Game Over Screen - komplett separates Rendering ohne weitere Locks - g.stateMutex.Lock() - myScore := 0 - for _, p := range g.gameState.Players { - if p.Name == g.playerName { - myScore = p.Score - break - } - } - g.stateMutex.Unlock() - - // In WASM: HTML Game Over Screen anzeigen - if !g.scoreSubmitted { - g.submitScore() // submitScore() setzt g.scoreSubmitted intern - g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen - } - - g.drawGameOverScreen(screen, myScore) - return // Früher Return, damit Game-UI nicht mehr gezeichnet wird + g.drawGameOver(screen) + return } - // State Locken für Datenzugriff - g.stateMutex.Lock() + snap := g.takeRenderSnapshot(screen) - // Prüfe ob Spieler tot ist - isDead := false + g.drawBackground(screen, snap) + g.RenderGround(screen, g.camX/snap.viewScale) + g.drawWorldObjects(screen, snap) + g.drawPlayers(screen, snap) + g.drawStatusUI(screen, snap) + g.drawDeathZoneLine(screen, snap.canvasH) + g.RenderParticles(screen) + + if g.showDebug { + g.drawDebugOverlay(screen) + } + if !g.keyboardUsed { + g.drawTouchControls(screen) + } +} + +// drawGameOver behandelt den GAMEOVER-Bildschirm inkl. Score-Übermittlung. +func (g *Game) drawGameOver(screen *ebiten.Image) { + g.stateMutex.Lock() myScore := 0 for _, p := range g.gameState.Players { if p.Name == g.playerName { - isDead = !p.IsAlive || p.IsSpectator myScore = p.Score break } } g.stateMutex.Unlock() - // Canvas-Größe und Scale-Faktor - canvasW, canvasH := screen.Size() - viewScale := GetScaleFromHeight(canvasH) - - // 1. Hintergrund (wechselt alle 5000 Punkte) - backgroundID := "background" - if myScore >= 10000 { - backgroundID = "background2" - } else if myScore >= 5000 { - backgroundID = "background1" + if !g.scoreSubmitted { + g.submitScore() + g.sendGameOverToJS(myScore) } - // Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe) - if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil { - op := &ebiten.DrawImageOptions{} + g.drawGameOverScreen(screen, myScore) +} - bgW, bgH := bgImg.Size() +// drawBackground zeichnet das Hintergrundbild (wechselt nach Score). +func (g *Game) drawBackground(screen *ebiten.Image, snap renderSnapshot) { + bgID := "background" + if snap.myScore >= 10000 { + bgID = "background2" + } else if snap.myScore >= 5000 { + bgID = "background1" + } - // Skalierung berechnen, um Canvas komplett zu füllen - scaleX := float64(canvasW) / float64(bgW) - scaleY := float64(canvasH) / float64(bgH) - scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen - - op.GeoM.Scale(scale, scale) - - // Zentrieren auf Canvas - scaledW := float64(bgW) * scale - scaledH := float64(bgH) * scale - offsetX := (float64(canvasW) - scaledW) / 2 - offsetY := (float64(canvasH) - scaledH) / 2 - op.GeoM.Translate(offsetX, offsetY) - - screen.DrawImage(bgImg, op) - } else { - // Fallback: Einfarbiger Himmel + bgImg, exists := g.assetsImages[bgID] + if !exists || bgImg == nil { screen.Fill(ColSky) + return } - // Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera) - // Mit viewScale multiplizieren damit auf Mobile mehr Welt sichtbar ist - effectiveCamX := g.camX / viewScale - g.RenderGround(screen, effectiveCamX) + bgW, bgH := bgImg.Size() + scaleX := float64(snap.canvasW) / float64(bgW) + scaleY := float64(snap.canvasH) / float64(bgH) + scale := math.Max(scaleX, scaleY) - // Prediction-Snapshot VOR dem stateMutex holen (verhindert Deadlock: - // DrawGame würde sonst stateMutex→predictionMutex halten, während - // ReconcileWithServer predictionMutex→stateMutex hält) - g.predictionMutex.Lock() - snapPrevX := g.prevPredictedX - snapPrevY := g.prevPredictedY - snapPredX := g.predictedX - snapPredY := g.predictedY - snapOffX := g.correctionOffsetX - snapOffY := g.correctionOffsetY - snapPhysTime := g.lastPhysicsTime - g.predictionMutex.Unlock() + op := &ebiten.DrawImageOptions{} + op.GeoM.Scale(scale, scale) + op.GeoM.Translate( + (float64(snap.canvasW)-float64(bgW)*scale)/2, + (float64(snap.canvasH)-float64(bgH)*scale)/2, + ) + screen.DrawImage(bgImg, op) +} - // State Locken für Datenzugriff - g.stateMutex.Lock() - defer g.stateMutex.Unlock() - - // Screen-Höhe für Y-Transformation (canvasH bereits oben definiert) - // _, canvasH = screen.Size() // nicht nötig, bereits definiert - - // 2. Chunks (Welt-Objekte) - for _, activeChunk := range g.gameState.WorldChunks { +// drawWorldObjects zeichnet Chunk-Objekte und bewegende Plattformen. +func (g *Game) drawWorldObjects(screen *ebiten.Image, snap renderSnapshot) { + for _, activeChunk := range snap.chunks { chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID] if !exists { log.Printf("⚠️ Chunk '%s' nicht in Library gefunden!", activeChunk.ChunkID) continue } - - // Start-Chunk hat absichtlich keine Objekte - for objIdx, obj := range chunkDef.Objects { - // Skip Moving Platforms - die werden separat gerendert if obj.MovingPlatform != nil { - continue + continue // Bewegende Plattformen separat gerendert } - - // Prüfe ob Coin/Powerup bereits eingesammelt wurde - assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID] - if hasAsset { + if assetDef, ok := g.world.Manifest.Assets[obj.AssetID]; ok { key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx) - - if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] { - // Coin wurde eingesammelt, nicht zeichnen + if assetDef.Type == "coin" && snap.collectedCoins[key] { continue } - - if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] { - // Powerup wurde eingesammelt, nicht zeichnen + if assetDef.Type == "powerup" && snap.collectedPowerups[key] { continue } } - - // Asset zeichnen (mit Welt-zu-Screen-Y-Transformation) - g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, canvasH)) + g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, snap.canvasH)) } } - // 2.5 Bewegende Plattformen (von Server synchronisiert) - for _, mp := range g.gameState.MovingPlatforms { - g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, canvasH)) + for _, mp := range snap.movingPlatforms { + g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, snap.canvasH)) } - // 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - NUR IM DEBUG-MODUS + // Debug: Boden-Collider visualisieren (GRÜN) if g.showDebug { - vector.StrokeRect(screen, float32(-g.camX), float32(WorldToScreenYWithHeight(540, canvasH)), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false) + vector.StrokeRect(screen, + float32(-g.camX), float32(WorldToScreenYWithHeight(540, snap.canvasH)), + 10000, 5000, 2, color.RGBA{0, 255, 0, 255}, false) } +} - // 3. Spieler - for id, p := range g.gameState.Players { +// drawPlayers zeichnet alle Spieler mit Sprites, Nametags und optionalen Hitboxen. +func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) { + for id, p := range snap.players { posX, posY := p.X, p.Y - vy := p.VY - onGround := p.OnGround - // Für lokalen Spieler: Client-Prediction mit Interpolation zwischen Physics-Steps - // Physics läuft bei 20/sec, Draw bei 60fps → alpha interpoliert dazwischen if p.Name == g.playerName { - // Interpolations-Alpha: wie weit sind wir zwischen letztem und nächstem Physics-Step? - alpha := float64(time.Since(snapPhysTime)) / float64(50*time.Millisecond) + // Lokaler Spieler: Interpolation zwischen Physics-Steps (20 TPS → 60 fps) + alpha := float64(time.Since(snap.physicsTime)) / float64(physicsStep) if alpha > 1 { alpha = 1 } - posX = snapPrevX + (snapPredX-snapPrevX)*alpha + snapOffX - posY = snapPrevY + (snapPredY-snapPrevY)*alpha + snapOffY + posX = snap.prevPredX + (snap.predX-snap.prevPredX)*alpha + snap.offsetX + posY = snap.prevPredY + (snap.predY-snap.prevPredY)*alpha + snap.offsetY } - // Wähle Sprite basierend auf Sprung-Status - sprite := "player" // Default: am Boden - - // Nur Jump-Animation wenn wirklich in der Luft - // Bei 20 TPS größerer Threshold (3.0 statt 1.0) - // OnGround oder sehr kleine VY = am Boden/Plattform - isInAir := !onGround && (vy < -3.0 || vy > 3.0) - - if isInAir { - if vy < -5.0 { - // Springt nach oben - sprite = "jump0" - } else { - // Fällt oder höchster Punkt - sprite = "jump1" - } - } - - // Konvertiere Welt-Y zu Screen-Y für korrektes Rendering - _, canvasH := screen.Size() - screenY := WorldToScreenYWithHeight(posY, canvasH) + sprite := g.selectPlayerSprite(p.OnGround, p.VY) + screenY := WorldToScreenYWithHeight(posY, snap.canvasH) g.DrawAsset(screen, sprite, posX, screenY) - // Name Tag name := p.Name if name == "" { name = id } text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(screenY-25), ColText) - // HITBOX VISUALISIERUNG (NUR IM DEBUG-MODUS) if g.showDebug { - if def, ok := g.world.Manifest.Assets["player"]; ok { - // Spieler-Hitbox (ROT) - mit Screen-Y-Transformation - hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX) - hy := float32(screenY + def.DrawOffY + def.Hitbox.OffsetY) - vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 3, color.RGBA{255, 0, 0, 255}, false) - - // Spieler-Position als Punkt (GELB) - mit Screen-Y-Transformation - vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false) - } + g.drawPlayerHitbox(screen, posX, screenY) } } +} - // 4. UI Status (Canvas-relativ) - canvasW, canvasH = screen.Size() +// selectPlayerSprite gibt den Sprite-Namen basierend auf dem Bewegungszustand zurück. +func (g *Game) selectPlayerSprite(onGround bool, vy float64) string { + if onGround || (vy >= -3.0 && vy <= 3.0) { + return "player" + } + if vy < -5.0 { + return "jump0" // Springt nach oben + } + return "jump1" // Fällt oder höchster Punkt +} - if g.gameState.Status == "COUNTDOWN" { - msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft) - text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255}) - } else if g.gameState.Status == "RUNNING" { - // Danger-Overlay: Ab DifficultyFactor > 0.5 rötlicher Bildschirmrand - // stateMutex ist bereits seit Zeile 388 per defer gehalten – kein erneuter Lock! - df := g.gameState.DifficultyFactor - if df > 0.5 { - // Alpha von 0 (bei df=0.5) bis 60 (bei df=1.0) - dangerAlpha := uint8((df - 0.5) * 2.0 * 60) - canvasWf, canvasHf := float32(canvasW), float32(canvasH) - borderW := float32(8) - col := color.RGBA{200, 0, 0, dangerAlpha} - vector.DrawFilledRect(screen, 0, 0, canvasWf, borderW, col, false) - vector.DrawFilledRect(screen, 0, canvasHf-borderW, canvasWf, borderW, col, false) - vector.DrawFilledRect(screen, 0, 0, borderW, canvasHf, col, false) - vector.DrawFilledRect(screen, canvasWf-borderW, 0, borderW, canvasHf, col, false) - } +// drawPlayerHitbox visualisiert die Spieler-Hitbox im Debug-Modus. +func (g *Game) drawPlayerHitbox(screen *ebiten.Image, posX, screenY float64) { + def, ok := g.world.Manifest.Assets["player"] + if !ok { + return + } + hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX) + hy := float32(screenY + def.DrawOffY + def.Hitbox.OffsetY) + vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 3, color.RGBA{255, 0, 0, 255}, false) + vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false) +} - // Score/Distance Anzeige mit grauem Hintergrund (oben rechts) - dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0) - scoreStr := fmt.Sprintf("Score: %d", myScore) - - // Berechne Textbreiten für dynamische Box-Größe - distLen := len(dist) * 7 // ~7px pro Zeichen - scoreLen := len(scoreStr) * 7 - maxWidth := distLen - if scoreLen > maxWidth { - maxWidth = scoreLen - } - - boxWidth := float32(maxWidth + 20) // 10px Padding links/rechts - boxHeight := float32(50) - boxX := float32(canvasW) - boxWidth - 10 // 10px vom rechten Rand - boxY := float32(10) // 10px vom oberen Rand - - // Grauer halbtransparenter Hintergrund - vector.DrawFilledRect(screen, boxX, boxY, boxWidth, boxHeight, color.RGBA{60, 60, 60, 200}, false) - vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2, color.RGBA{100, 100, 100, 255}, false) - - // Text (zentriert in Box) - textX := int(boxX) + 10 - text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.RGBA{255, 255, 255, 255}) - text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255}) - - // Spectator Overlay wenn tot - if isDead { - // Halbtransparenter roter Overlay (volle Canvas-Breite) - vector.DrawFilledRect(screen, 0, 0, float32(canvasW), 80, color.RGBA{150, 0, 0, 180}, false) - text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, canvasW/2-140, 30, color.White) - text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, canvasW/2-90, 55, color.RGBA{255, 255, 0, 255}) +// drawStatusUI zeichnet das spielzustandsabhängige UI (Countdown, Score, Spectator). +func (g *Game) drawStatusUI(screen *ebiten.Image, snap renderSnapshot) { + switch snap.status { + case "COUNTDOWN": + msg := fmt.Sprintf("GO IN: %d", snap.timeLeft) + text.Draw(screen, msg, basicfont.Face7x13, snap.canvasW/2-40, snap.canvasH/2, color.RGBA{255, 255, 0, 255}) + case "RUNNING": + g.drawDangerOverlay(screen, snap) + g.drawScoreBox(screen, snap) + if snap.isDead { + g.drawSpectatorOverlay(screen, snap) } } +} - // 5. DEBUG: TODES-LINIE (volle Canvas-Höhe) +// drawDangerOverlay zeichnet einen roten Bildschirmrand wenn DifficultyFactor > 0.5. +func (g *Game) drawDangerOverlay(screen *ebiten.Image, snap renderSnapshot) { + if snap.difficultyFactor <= 0.5 { + return + } + alpha := uint8((snap.difficultyFactor - 0.5) * 2.0 * 60) + col := color.RGBA{200, 0, 0, alpha} + w, h := float32(snap.canvasW), float32(snap.canvasH) + bw := float32(8) + vector.DrawFilledRect(screen, 0, 0, w, bw, col, false) + vector.DrawFilledRect(screen, 0, h-bw, w, bw, col, false) + vector.DrawFilledRect(screen, 0, 0, bw, h, col, false) + vector.DrawFilledRect(screen, w-bw, 0, bw, h, col, false) +} + +// drawScoreBox zeichnet die Distanz- und Score-Anzeige oben rechts. +func (g *Game) drawScoreBox(screen *ebiten.Image, snap renderSnapshot) { + dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0) + scoreStr := fmt.Sprintf("Score: %d", snap.myScore) + + maxWidth := len(dist) * 7 + if sw := len(scoreStr) * 7; sw > maxWidth { + maxWidth = sw + } + + boxW := float32(maxWidth + 20) + boxH := float32(50) + boxX := float32(snap.canvasW) - boxW - 10 + boxY := float32(10) + + vector.DrawFilledRect(screen, boxX, boxY, boxW, boxH, color.RGBA{60, 60, 60, 200}, false) + vector.StrokeRect(screen, boxX, boxY, boxW, boxH, 2, color.RGBA{100, 100, 100, 255}, false) + textX := int(boxX) + 10 + text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.White) + text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255}) +} + +// drawSpectatorOverlay zeigt das Spectator-Banner wenn der lokale Spieler tot ist. +func (g *Game) drawSpectatorOverlay(screen *ebiten.Image, snap renderSnapshot) { + vector.DrawFilledRect(screen, 0, 0, float32(snap.canvasW), 80, color.RGBA{150, 0, 0, 180}, false) + text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, snap.canvasW/2-140, 30, color.White) + text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", snap.myScore), basicfont.Face7x13, snap.canvasW/2-90, 55, color.RGBA{255, 255, 0, 255}) +} + +// drawDeathZoneLine zeichnet die rote Todes-Linie am linken Bildschirmrand. +func (g *Game) drawDeathZoneLine(screen *ebiten.Image, canvasH int) { vector.StrokeLine(screen, 0, 0, 0, float32(canvasH), 10, color.RGBA{255, 0, 0, 128}, false) text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, canvasH/2, color.RGBA{255, 0, 0, 255}) +} - // 6. PARTIKEL RENDERN (vor UI) - g.RenderParticles(screen) +// drawTouchControls zeichnet den virtuellen Joystick und die Touch-Buttons. +// Wird nur angezeigt wenn keine Tastatur benutzt wurde. +func (g *Game) drawTouchControls(screen *ebiten.Image) { + tcW, tcH := screen.Size() - // 7. DEBUG OVERLAY (F3 zum Umschalten) - if g.showDebug { - g.drawDebugOverlay(screen) + // Canvas-Maße cachen (werden in handleTouchInput benötigt) + g.lastCanvasHeight = tcH + g.lastCanvasWidth = tcW + + floorY := GetFloorYFromHeight(tcH) + refDim := math.Min(float64(tcW), float64(tcH)) + + joyR := refDim * joyRadiusScale + knobR := joyR * joyKnobRatio + jumpR := refDim * jumpBtnScale + downR := refDim * downBtnScale + + g.joyRadius = joyR + + // ── A) Floating Joystick (links) ───────────────────────────────────────── + if !g.joyActive { + g.joyBaseX = float64(tcW) * joyDefaultXRatio + g.joyBaseY = floorY - joyR - 12 + g.joyStickX = g.joyBaseX + g.joyStickY = g.joyBaseY } - // 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde) - if !g.keyboardUsed { - tcW, tcH := screen.Size() - - // Canvas-Größe cachen (für handleTouchInput im nächsten Frame) - g.lastCanvasHeight = tcH - g.lastCanvasWidth = tcW - - floorY := GetFloorYFromHeight(tcH) - - // Proportionale Größen: basiert auf der kleineren Screen-Dimension - refDim := math.Min(float64(tcW), float64(tcH)) - joyR := refDim * 0.13 // Joystick-Außenring (13% der kleineren Dimension) - knobR := joyR * 0.48 // Knob ~halber Joystick-Radius - jumpR := refDim * 0.11 // Sprung-Button - downR := refDim * 0.08 // Down-Button (kleiner) - - // Werte für Input-Verarbeitung cachen - g.joyRadius = joyR - - // ── A) Floating Joystick ───────────────────────────────────────────── - // Wenn inaktiv: Basis an Default-Position (unten links) zeigen - if !g.joyActive { - g.joyBaseX = float64(tcW) * 0.18 - g.joyBaseY = floorY - joyR - 12 - g.joyStickX = g.joyBaseX - g.joyStickY = g.joyBaseY - } - - // Joystick-Ring (halb transparent, nur wenn aktiv sichtbarer) - ringAlpha := uint8(40) - if g.joyActive { - ringAlpha = 70 - } - vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), color.RGBA{80, 80, 80, ringAlpha}, false) - vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), 2, color.RGBA{120, 120, 120, 90}, false) - - // Joystick-Knob - knobCol := color.RGBA{180, 180, 180, 100} - if g.joyActive { - knobCol = color.RGBA{80, 220, 80, 160} - } - vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), float32(knobR), knobCol, false) - - // ── B) Jump Button (rechts) ────────────────────────────────────────── - jumpX := float64(tcW)*0.82 - jumpY := floorY - jumpR - 12 - - jumpFill := color.RGBA{220, 50, 50, 60} - jumpStroke := color.RGBA{255, 80, 80, 130} - if g.btnJumpPressed { - jumpFill = color.RGBA{255, 80, 80, 130} // heller wenn gedrückt - jumpStroke = color.RGBA{255, 160, 160, 200} - } - vector.DrawFilledCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), jumpFill, false) - vector.StrokeCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), 2.5, jumpStroke, false) - text.Draw(screen, "JUMP", basicfont.Face7x13, - int(jumpX)-14, int(jumpY)+5, color.RGBA{255, 255, 255, 180}) - - // ── C) Down/FastFall Button (links vom Jump) ───────────────────────── - downX := float64(tcW)*0.62 - downY := floorY - downR - 12 - g.downBtnX = downX - g.downBtnY = downY - g.downBtnR = downR - - vector.DrawFilledCircle(screen, float32(downX), float32(downY), float32(downR), color.RGBA{50, 120, 220, 55}, false) - vector.StrokeCircle(screen, float32(downX), float32(downY), float32(downR), 2, color.RGBA{80, 160, 255, 120}, false) - text.Draw(screen, "▼", basicfont.Face7x13, - int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180}) + ringAlpha := uint8(40) + if g.joyActive { + ringAlpha = 70 } + vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), color.RGBA{80, 80, 80, ringAlpha}, false) + vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), 2, color.RGBA{120, 120, 120, 90}, false) + knobCol := color.RGBA{180, 180, 180, 100} + if g.joyActive { + knobCol = color.RGBA{80, 220, 80, 160} + } + vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), float32(knobR), knobCol, false) + + // ── B) Jump Button (rechts) ─────────────────────────────────────────────── + jumpX := float64(tcW) * jumpBtnXRatio + jumpY := floorY - jumpR - 12 + + jumpFill := color.RGBA{220, 50, 50, 60} + jumpStroke := color.RGBA{255, 80, 80, 130} + if g.btnJumpPressed { + jumpFill = color.RGBA{255, 80, 80, 130} + jumpStroke = color.RGBA{255, 160, 160, 200} + } + vector.DrawFilledCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), jumpFill, false) + vector.StrokeCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), 2.5, jumpStroke, false) + text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-14, int(jumpY)+5, color.RGBA{255, 255, 255, 180}) + + // ── C) Down/FastFall Button (mitte-rechts) ──────────────────────────────── + downX := float64(tcW) * downBtnXRatio + downY := floorY - downR - 12 + g.downBtnX = downX + g.downBtnY = downY + g.downBtnR = downR + + vector.DrawFilledCircle(screen, float32(downX), float32(downY), float32(downR), color.RGBA{50, 120, 220, 55}, false) + vector.StrokeCircle(screen, float32(downX), float32(downY), float32(downR), 2, color.RGBA{80, 160, 255, 120}, false) + text.Draw(screen, "▼", basicfont.Face7x13, int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180}) } // --- ASSET HELPER --- +// DrawAsset zeichnet ein Asset an einer Welt-Position auf den Screen. +// Objekte außerhalb des sichtbaren Bereichs (±cullingBuffer) werden übersprungen. func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY float64) { - // 1. Definition laden def, ok := g.world.Manifest.Assets[assetID] if !ok { return } - // Canvas-Größe und View-Scale canvasW, canvasH := screen.Size() viewScale := GetScaleFromHeight(canvasH) + screenX := (worldX - g.camX/viewScale) * viewScale - // 2. Screen Position berechnen (Welt - Kamera, mit Scale) - effectiveCamX := g.camX / viewScale - screenX := (worldX - effectiveCamX) * viewScale - screenY := worldY - - // Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden) - // Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px) - if screenX < -800 || screenX > float64(canvasW)+800 { + if screenX < -cullingBuffer || screenX > float64(canvasW)+cullingBuffer { return } - // 3. Bild holen img := g.assetsImages[assetID] - if img != nil { op := &ebiten.DrawImageOptions{} - - // Filter für bessere Skalierung (besonders bei großen Sprites) op.Filter = ebiten.FilterLinear - - // Skalieren: Asset-Scale * View-Scale (auf Mobile kleiner) finalScale := def.Scale * viewScale op.GeoM.Scale(finalScale, finalScale) - - // Positionieren: ScreenPos + DrawOffset (auch skaliert) op.GeoM.Translate( screenX+(def.DrawOffX*viewScale), - screenY+(def.DrawOffY*viewScale), + worldY+(def.DrawOffY*viewScale), ) - - // 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 { - // FALLBACK (Buntes Rechteck) + // Fallback: farbiges Rechteck wenn Bild fehlt vector.DrawFilledRect(screen, float32(screenX+def.Hitbox.OffsetX), - float32(screenY+def.Hitbox.OffsetY), + float32(worldY+def.Hitbox.OffsetY), float32(def.Hitbox.W), float32(def.Hitbox.H), def.Color.ToRGBA(), @@ -707,90 +696,81 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl } } -// drawDebugOverlay zeigt Performance und Network Stats (F3 zum Umschalten) +// --- DEBUG OVERLAY --- + +// drawDebugOverlay zeigt Performance- und Netzwerk-Stats (F3 zum Umschalten). func (g *Game) drawDebugOverlay(screen *ebiten.Image) { - // Hintergrund (halbtransparent) vector.DrawFilledRect(screen, 10, 80, 350, 170, color.RGBA{0, 0, 0, 180}, false) vector.StrokeRect(screen, 10, 80, 350, 170, 2, color.RGBA{255, 255, 0, 255}, false) y := 95 - lineHeight := 15 + lh := 15 // line height - // Titel text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255}) - y += lineHeight + 5 + y += lh + 5 - // FPS - fpsColor := color.RGBA{0, 255, 0, 255} + fpsCol := color.RGBA{0, 255, 0, 255} if g.currentFPS < 15 { - fpsColor = color.RGBA{255, 0, 0, 255} + fpsCol = color.RGBA{255, 0, 0, 255} } else if g.currentFPS < 30 { - fpsColor = color.RGBA{255, 165, 0, 255} + fpsCol = color.RGBA{255, 165, 0, 255} } - text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsColor) - y += lineHeight + text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsCol) + y += lh - // Server Update Latenz updateAge := time.Since(g.lastUpdateTime).Milliseconds() - latencyColor := color.RGBA{0, 255, 0, 255} + latencyCol := color.RGBA{0, 255, 0, 255} if updateAge > 200 { - latencyColor = color.RGBA{255, 0, 0, 255} + latencyCol = color.RGBA{255, 0, 0, 255} } else if updateAge > 100 { - latencyColor = color.RGBA{255, 165, 0, 255} + latencyCol = color.RGBA{255, 165, 0, 255} } - text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyColor) - y += lineHeight + text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyCol) + y += lh - // Network Stats text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White) - y += lineHeight + y += lh - oooColor := color.RGBA{0, 255, 0, 255} - if g.outOfOrderCount > 10 { - oooColor = color.RGBA{255, 165, 0, 255} - } + oooCol := color.RGBA{0, 255, 0, 255} if g.outOfOrderCount > 50 { - oooColor = color.RGBA{255, 0, 0, 255} + oooCol = color.RGBA{255, 0, 0, 255} + } else if g.outOfOrderCount > 10 { + oooCol = color.RGBA{255, 165, 0, 255} } - text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooColor) - y += lineHeight + text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooCol) + y += lh - // Packet Loss Rate if g.totalUpdates > 0 { lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100 - lossColor := color.RGBA{0, 255, 0, 255} + lossCol := color.RGBA{0, 255, 0, 255} if lossRate > 10 { - lossColor = color.RGBA{255, 0, 0, 255} + lossCol = color.RGBA{255, 0, 0, 255} } else if lossRate > 5 { - lossColor = color.RGBA{255, 165, 0, 255} + lossCol = color.RGBA{255, 165, 0, 255} } - text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossColor) - y += lineHeight + text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossCol) + y += lh } - // Client Prediction Stats text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White) - y += lineHeight + y += lh - corrColor := color.RGBA{0, 255, 0, 255} - if g.correctionCount > 100 { - corrColor = color.RGBA{255, 165, 0, 255} - } + corrCol := color.RGBA{0, 255, 0, 255} if g.correctionCount > 500 { - corrColor = color.RGBA{255, 0, 0, 255} + corrCol = color.RGBA{255, 0, 0, 255} + } else if g.correctionCount > 100 { + corrCol = color.RGBA{255, 165, 0, 255} } - text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrColor) - y += lineHeight + text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrCol) + y += lh - // Current Correction Magnitude corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY) + corrMagCol := color.RGBA{0, 255, 0, 255} if corrMag > 0.1 { - text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, color.RGBA{255, 165, 0, 255}) - } else { - text.Draw(screen, "Corr Mag: 0.0", basicfont.Face7x13, 20, y, color.RGBA{0, 255, 0, 255}) + corrMagCol = color.RGBA{255, 165, 0, 255} } - y += lineHeight + text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, corrMagCol) + y += lh - // Server Sequence text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White) } diff --git a/cmd/client/main.go b/cmd/client/main.go index e4f1f3d..2383bfc 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -16,7 +16,6 @@ import ( "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" @@ -30,14 +29,12 @@ const ( StateLobby = 1 StateGame = 2 StateLeaderboard = 3 - RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich) - RefFloorYMobile = 270 // Nicht mehr verwendet + RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich) ) 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} @@ -55,8 +52,7 @@ type InputState struct { // --- GAME STRUCT --- type Game struct { - appState int - conn *nats.EncodedConn + appState int wsConn *wsConn // WebSocket für WASM connGeneration int // Erhöht bei jedem Disconnect; macht alte WS-Handler ungültig isConnecting bool // Guard gegen mehrfaches connectAndStart() @@ -227,63 +223,42 @@ func (g *Game) Update() error { g.pendingInputCount = len(g.pendingInputs) g.predictionMutex.Unlock() + // Aktuellen Status einmalig (thread-safe) lesen + g.stateMutex.Lock() + currentStatus := g.gameState.Status + g.stateMutex.Unlock() + // Game Over Handling - if g.appState == StateGame && g.gameState.Status == "GAMEOVER" { - // Back Button (oben links) - Touch Support + if g.appState == StateGame && currentStatus == "GAMEOVER" { backBtnW, backBtnH := 120, 40 - backBtnX, backBtnY := 20, 20 - if isHit(backBtnX, backBtnY, backBtnW, backBtnH) { - g.appState = StateMenu - g.connected = false - g.scoreSubmitted = false - g.teamName = "" - g.activeField = "" - if g.conn != nil { - g.conn.Drain() - g.conn.Close() - } - g.gameState = game.GameState{Players: make(map[string]game.PlayerState)} + if isHit(20, 20, backBtnW, backBtnH) { + g.returnToMenu() log.Println("🔙 Zurück zum Menü (Back Button)") return nil } - - // ESC zurück zum Menü if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { - g.appState = StateMenu - g.connected = false - g.scoreSubmitted = false - g.teamName = "" - g.activeField = "" - if g.conn != nil { - g.conn.Drain() - g.conn.Close() - } - g.gameState = game.GameState{Players: make(map[string]game.PlayerState)} + g.returnToMenu() log.Println("🔙 Zurück zum Menü (ESC)") return nil } - - // Host: Team-Name Eingabe if g.isHost { g.handleGameOverInput() } } // COUNTDOWN/RUNNING-Übergang: AppState auf StateGame setzen + JS benachrichtigen - newStatus := g.gameState.Status - if (newStatus == "COUNTDOWN" || newStatus == "RUNNING") && g.appState != StateGame { - log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, newStatus) + if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING") && g.appState != StateGame { + log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus) g.appState = StateGame g.notifyGameStarted() } - if newStatus == "RUNNING" && g.lastStatus != "RUNNING" { + if currentStatus == "RUNNING" && g.lastStatus != "RUNNING" { g.audio.PlayMusic() } - // Musik stoppen wenn Game Over - if g.gameState.Status == "GAMEOVER" && g.lastStatus == "RUNNING" { + if currentStatus == "GAMEOVER" && g.lastStatus == "RUNNING" { g.audio.StopMusic() } - g.lastStatus = g.gameState.Status + g.lastStatus = currentStatus switch g.appState { case StateMenu: @@ -418,14 +393,13 @@ func (g *Game) updateLobby() { } // Zurück Button - backW, backH := 100, 40 - if isHit(50, 50, backW, backH) { - if g.conn != nil { - g.conn.Close() - } + if isHit(50, 50, 100, 40) { + g.disconnectFromServer() g.appState = StateMenu g.connected = false + g.stateMutex.Lock() g.gameState = game.GameState{Players: make(map[string]game.PlayerState)} + g.stateMutex.Unlock() } // Lobby State Change Detection (für HTML-Updates) @@ -692,12 +666,6 @@ func GetFloorYFromHeight(screenHeight int) float64 { return floorY } -// GetFloorY - Wrapper der versucht die Höhe zu bekommen (deprecated, benutze GetFloorYFromHeight) -func GetFloorY() float64 { - _, h := ebiten.WindowSize() - return GetFloorYFromHeight(h) -} - // GetScale gibt den Scale-Faktor zurück um die Spielwelt an den Bildschirm anzupassen // Auf Mobile: Scale < 1.0 (rauszoomen, damit mehr sichtbar ist) // Auf Desktop: Scale = 1.0 (normale Größe) @@ -748,15 +716,6 @@ func WorldToScreenYWithHeight(worldY float64, screenHeight int) float64 { return worldY + yOffset } -// WorldToScreenY - Legacy wrapper (versucht WindowSize zu verwenden, funktioniert nicht in WASM!) -func WorldToScreenY(worldY float64) float64 { - _, h := ebiten.WindowSize() - if h == 0 { - h = ScreenHeight // Fallback - } - return WorldToScreenYWithHeight(worldY, h) -} - func isHit(x, y, w, h int) bool { if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { mx, my := ebiten.CursorPosition() @@ -854,6 +813,19 @@ func (g *Game) handleGameOverInput() { } } +// returnToMenu trennt die Verbindung und setzt den App-State zurück auf das Hauptmenü. +func (g *Game) returnToMenu() { + g.disconnectFromServer() + g.appState = StateMenu + g.connected = false + g.scoreSubmitted = false + g.teamName = "" + g.activeField = "" + g.stateMutex.Lock() + g.gameState = game.GameState{Players: make(map[string]game.PlayerState)} + g.stateMutex.Unlock() +} + func generateRoomCode() string { mrand.Seed(time.Now().UnixNano()) chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -1006,14 +978,17 @@ func (g *Game) DrawLeaderboard(screen *ebiten.Image) { // Titel text.Draw(screen, "=== TOP 10 LEADERBOARD ===", basicfont.Face7x13, ScreenWidth/2-100, 80, color.RGBA{255, 215, 0, 255}) - // Leaderboard abrufen wenn leer + // Leaderboard abrufen wenn leer (prüfen ohne Lock, dann ggf. nachladen) g.leaderboardMutex.Lock() - if len(g.leaderboard) == 0 && g.connected { - g.leaderboardMutex.Unlock() + empty := len(g.leaderboard) == 0 + g.leaderboardMutex.Unlock() + + if empty && g.connected { g.requestLeaderboard() - g.leaderboardMutex.Lock() } + g.leaderboardMutex.Lock() + y := 150 if len(g.leaderboard) == 0 { text.Draw(screen, "Noch keine Einträge...", basicfont.Face7x13, ScreenWidth/2-80, y, color.Gray{150})