diff --git a/cmd/client/connection_native.go b/cmd/client/connection_native.go index 97875b6..87ba918 100644 --- a/cmd/client/connection_native.go +++ b/cmd/client/connection_native.go @@ -88,9 +88,17 @@ func (g *Game) wsReadPump() { // Out-of-Order-Erkennung: Ignoriere alte Updates if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq { // Alte Nachricht - ignorieren + g.outOfOrderCount++ continue } g.lastRecvSeq = state.Sequence + g.totalUpdates++ + g.lastUpdateTime = time.Now() + + // Aktualisiere CurrentSpeed für Client-Prediction + g.predictionMutex.Lock() + g.currentSpeed = state.CurrentSpeed + g.predictionMutex.Unlock() // Server Reconciliation für lokalen Spieler (VOR dem Lock) for _, p := range state.Players { diff --git a/cmd/client/connection_wasm.go b/cmd/client/connection_wasm.go index 1839bec..2c3b6d1 100644 --- a/cmd/client/connection_wasm.go +++ b/cmd/client/connection_wasm.go @@ -75,9 +75,17 @@ func (g *Game) connectToServer() { // Out-of-Order-Erkennung: Ignoriere alte Updates if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq { // Alte Nachricht - ignorieren + g.outOfOrderCount++ return nil } g.lastRecvSeq = state.Sequence + g.totalUpdates++ + g.lastUpdateTime = time.Now() + + // Aktualisiere CurrentSpeed für Client-Prediction + g.predictionMutex.Lock() + g.currentSpeed = state.CurrentSpeed + g.predictionMutex.Unlock() // Server Reconciliation für lokalen Spieler (VOR dem Lock) for _, p := range state.Players { diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 4d731da..8818540 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -5,6 +5,7 @@ import ( "image/color" "log" "math" + "time" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/inpututil" @@ -71,45 +72,18 @@ func (g *Game) UpdateGame() { } g.btnJumpActive = false - // --- 4. CLIENT PREDICTION --- + // --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) --- if g.connected { g.predictionMutex.Lock() // 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) + // Lokale Prediction ausführen für sofortiges Feedback g.ApplyInput(input) - // Sanfte Korrektur anwenden (langsamer bei 20 TPS für weniger Jitter) - const smoothingFactor = 0.15 // Reduziert für 20 TPS (war 0.4 bei 60 TPS) - if g.correctionX != 0 || g.correctionY != 0 { - g.predictedX += g.correctionX * smoothingFactor - g.predictedY += g.correctionY * smoothingFactor - - g.correctionX *= (1.0 - smoothingFactor) - g.correctionY *= (1.0 - smoothingFactor) - - // Korrektur beenden wenn sehr klein - if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 1.0 { - g.correctionX = 0 - g.correctionY = 0 - } - } - - // Landing Detection für Partikel - if !g.lastGroundState && g.predictedGround { - // Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen) - // Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H - // = Y - 231 + 42 + 184 = Y - 5 - feetY := g.predictedY - 231 + 42 + 184 - centerX := g.predictedX - 56 + 68 + 73/2 - g.SpawnLandingParticles(centerX, feetY) - } - g.lastGroundState = g.predictedGround + // Input für History speichern (für Server-Reconciliation) + g.pendingInputs[input.Sequence] = input g.predictionMutex.Unlock() @@ -231,7 +205,7 @@ func (g *Game) DrawGame(screen *ebiten.Image) { // In WASM: HTML Game Over Screen anzeigen if !g.scoreSubmitted { - g.submitScore() // submitScore() setzt g.scoreSubmitted intern + g.submitScore() // submitScore() setzt g.scoreSubmitted intern g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen } @@ -262,23 +236,26 @@ func (g *Game) DrawGame(screen *ebiten.Image) { backgroundID = "background1" } - // Hintergrundbild zeichnen (skaliert auf Bildschirmgröße) + // Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe) if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil { op := &ebiten.DrawImageOptions{} - // Skalierung berechnen, um Bildschirm zu füllen + // Tatsächliche Canvas-Größe verwenden (nicht nur ScreenWidth/Height) + canvasW, canvasH := screen.Size() bgW, bgH := bgImg.Size() - scaleX := float64(ScreenWidth) / float64(bgW) - scaleY := float64(ScreenHeight) / float64(bgH) + + // 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 + // Zentrieren auf Canvas scaledW := float64(bgW) * scale scaledH := float64(bgH) * scale - offsetX := (float64(ScreenWidth) - scaledW) / 2 - offsetY := (float64(ScreenHeight) - scaledH) / 2 + offsetX := (float64(canvasW) - scaledW) / 2 + offsetY := (float64(canvasH) - scaledH) / 2 op.GeoM.Translate(offsetX, offsetY) screen.DrawImage(bgImg, op) @@ -336,26 +313,22 @@ func (g *Game) DrawGame(screen *ebiten.Image) { g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y) } - // 3. Spieler - // MyID ohne Lock holen (wir haben bereits den stateMutex) - myID := "" - for id, p := range g.gameState.Players { - if p.Name == g.playerName { - myID = id - break - } - } + // 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - UNTER dem Gras bis tief in die Erde + vector.StrokeRect(screen, float32(-g.camX), float32(540), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false) + // 3. Spieler for id, p := range g.gameState.Players { - // Für lokalen Spieler: Verwende vorhergesagte Position posX, posY := p.X, p.Y vy := p.VY onGround := p.OnGround - if id == myID && g.connected { + + // Für lokalen Spieler: Verwende Client-Prediction Position + // Die Reconciliation wird in ReconcileWithServer() (connection_*.go) gemacht + if p.Name == g.playerName { + g.predictionMutex.Lock() posX = g.predictedX posY = g.predictedY - vy = g.predictedVY - onGround = g.predictedGround + g.predictionMutex.Unlock() } // Wähle Sprite basierend auf Sprung-Status @@ -385,82 +358,98 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(posY-25), ColText) - // DEBUG: Rote Hitbox + // HITBOX VISUALISIERUNG (IMMER SICHTBAR) if def, ok := g.world.Manifest.Assets["player"]; ok { + // Spieler-Hitbox (ROT) 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) + 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) + vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(posY), 5, color.RGBA{255, 255, 0, 255}, false) } } - // 4. UI Status + // 4. UI Status (Canvas-relativ) + canvasW, canvasH := screen.Size() + if g.gameState.Status == "COUNTDOWN" { msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft) - text.Draw(screen, msg, basicfont.Face7x13, ScreenWidth/2-40, ScreenHeight/2, color.RGBA{255, 255, 0, 255}) + text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255}) } else if g.gameState.Status == "RUNNING" { + // Score/Distance Anzeige mit grauem Hintergrund (oben rechts) dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0) - text.Draw(screen, dist, basicfont.Face7x13, ScreenWidth-150, 30, ColText) - - // Score anzeigen scoreStr := fmt.Sprintf("Score: %d", myScore) - text.Draw(screen, scoreStr, basicfont.Face7x13, ScreenWidth-150, 50, ColText) + + // 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 - vector.DrawFilledRect(screen, 0, 0, ScreenWidth, 80, color.RGBA{150, 0, 0, 180}, false) - text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, ScreenWidth/2-140, 30, color.White) - text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, ScreenWidth/2-90, 55, color.RGBA{255, 255, 0, 255}) + // 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}) } } - // 5. DEBUG: TODES-LINIE - vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false) - text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255}) + // 5. DEBUG: TODES-LINIE (volle Canvas-Höhe) + 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) - // 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde) - if !g.keyboardUsed { - // A) Joystick Base (dunkelgrau und durchsichtig) - baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig - vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false) - vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false) + // 7. DEBUG OVERLAY (F3 zum Umschalten) + if g.showDebug { + g.drawDebugOverlay(screen) + } - // B) Joystick Knob (dunkelgrau, außer wenn aktiv) - knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig + // 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde) + if !g.keyboardUsed { + canvasW, canvasH := screen.Size() + + // A) Joystick Base (unten links, relativ zu Canvas) + joyX := 150.0 + joyY := float64(canvasH) - 150.0 + baseCol := color.RGBA{80, 80, 80, 50} + vector.DrawFilledCircle(screen, float32(joyX), float32(joyY), 60, baseCol, false) + vector.StrokeCircle(screen, float32(joyX), float32(joyY), 60, 2, color.RGBA{100, 100, 100, 100}, false) + + // B) Joystick Knob (relativ zu Base, nicht zu Canvas) + knobCol := color.RGBA{100, 100, 100, 80} if g.joyActive { - knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig + knobCol = color.RGBA{100, 255, 100, 120} } vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false) - // C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung) - jumpX := float32(ScreenWidth - 150) - jumpY := float32(ScreenHeight - 150) + // C) Jump Button (unten rechts, relativ zu Canvas) + jumpX := float32(canvasW) - 150 + jumpY := float32(canvasH) - 150 vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false) vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false) text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150}) } - // 8. DEBUG INFO (Oben Links) - myPosStr := "N/A" - for _, p := range g.gameState.Players { - myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y) - break - } - - debugMsg := fmt.Sprintf( - "FPS: %.2f\nState: %s\nPlayers: %d\nCamX: %.0f\nPos: %s", - ebiten.CurrentFPS(), - g.gameState.Status, - len(g.gameState.Players), - g.camX, - myPosStr, - ) - - vector.DrawFilledRect(screen, 10, 10, 200, 90, color.RGBA{0, 0, 0, 180}, false) - text.Draw(screen, debugMsg, basicfont.Face7x13, 20, 30, color.White) } // --- ASSET HELPER --- @@ -476,8 +465,10 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl screenX := worldX - g.camX screenY := worldY - // Optimierung: Nicht zeichnen, wenn komplett außerhalb - if screenX < -200 || screenX > ScreenWidth+200 { + // Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden) + // Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px) + canvasW, _ := screen.Size() + if screenX < -800 || screenX > float64(canvasW)+800 { return } @@ -519,3 +510,90 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl } } +// drawDebugOverlay zeigt Performance und Network 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 + + // Titel + text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255}) + y += lineHeight + 5 + + // FPS + fpsColor := color.RGBA{0, 255, 0, 255} + if g.currentFPS < 15 { + fpsColor = color.RGBA{255, 0, 0, 255} + } else if g.currentFPS < 30 { + fpsColor = color.RGBA{255, 165, 0, 255} + } + text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsColor) + y += lineHeight + + // Server Update Latenz + updateAge := time.Since(g.lastUpdateTime).Milliseconds() + latencyColor := color.RGBA{0, 255, 0, 255} + if updateAge > 200 { + latencyColor = color.RGBA{255, 0, 0, 255} + } else if updateAge > 100 { + latencyColor = color.RGBA{255, 165, 0, 255} + } + text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyColor) + y += lineHeight + + // Network Stats + text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White) + y += lineHeight + + oooColor := color.RGBA{0, 255, 0, 255} + if g.outOfOrderCount > 10 { + oooColor = color.RGBA{255, 165, 0, 255} + } + if g.outOfOrderCount > 50 { + oooColor = color.RGBA{255, 0, 0, 255} + } + text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooColor) + y += lineHeight + + // Packet Loss Rate + if g.totalUpdates > 0 { + lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100 + lossColor := color.RGBA{0, 255, 0, 255} + if lossRate > 10 { + lossColor = color.RGBA{255, 0, 0, 255} + } else if lossRate > 5 { + lossColor = color.RGBA{255, 165, 0, 255} + } + text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossColor) + y += lineHeight + } + + // Client Prediction Stats + text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White) + y += lineHeight + + corrColor := color.RGBA{0, 255, 0, 255} + if g.correctionCount > 100 { + corrColor = color.RGBA{255, 165, 0, 255} + } + if g.correctionCount > 500 { + corrColor = color.RGBA{255, 0, 0, 255} + } + text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrColor) + y += lineHeight + + // Current Correction Magnitude + corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY) + 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}) + } + y += lineHeight + + // Server Sequence + text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White) +} diff --git a/cmd/client/ground_system.go b/cmd/client/ground_system.go index c36ffa7..aa786a5 100644 --- a/cmd/client/ground_system.go +++ b/cmd/client/ground_system.go @@ -58,9 +58,9 @@ func GenerateGroundTile(tileIdx int) GroundTile { Stones: make([]Stone, 0), } - // Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe) - numDirt := 15 + rng.Intn(10) - dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe + // Zufällige Dirt-Patches generieren (20-30 pro Tile, über die ganze Höhe) + numDirt := 20 + rng.Intn(10) + dirtHeight := 5000.0 // Gesamte Dirt-Höhe bis tief in die Erde for i := 0; i < numDirt; i++ { darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne tile.DirtVariants = append(tile.DirtVariants, DirtPatch{ @@ -72,7 +72,17 @@ func GenerateGroundTile(tileIdx int) GroundTile { }) } - // Keine Steine mehr auf dem Gras + // Steine IN der Erde generieren (10-20 pro Tile, tief verteilt) + numStones := 10 + rng.Intn(10) + for i := 0; i < numStones; i++ { + tile.Stones = append(tile.Stones, Stone{ + X: rng.Float64() * 128, + Y: rng.Float64()*dirtHeight + 20, // Tief in der Erde verteilt + Size: 4 + rng.Float64()*8, // Verschiedene Größen + Color: color.RGBA{100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 255}, + Shape: rng.Intn(2), // 0=rund, 1=eckig + }) + } // In Cache speichern groundCache[tileIdx] = tile @@ -81,20 +91,25 @@ func GenerateGroundTile(tileIdx int) GroundTile { // RenderGround rendert den Boden mit Bewegung func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { + // Tatsächliche Canvas-Größe verwenden + canvasW, _ := screen.Size() + + // Boden bleibt an fester Position (RefFloorY) - wichtig für Spielphysik! + // Erweitere Boden nach unten weit über Canvas-Rand hinaus (5000 Pixel tief) floorY := float32(RefFloorY) - floorH := float32(ScreenHeight - RefFloorY) + floorH := float32(5000) // Tief in die Erde - // 1. Basis Gras-Schicht - vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false) + // 1. Basis Gras-Schicht (volle Canvas-Breite, nur dünne Grasnarbe) + vector.DrawFilledRect(screen, 0, floorY, float32(canvasW), 20, ColGrass, false) - // 2. Dirt-Schicht (Basis) - vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false) + // 2. Dirt-Schicht (Basis, volle Canvas-Breite, tief nach unten) + vector.DrawFilledRect(screen, 0, floorY+20, float32(canvasW), floorH-20, ColDirt, false) // 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera) - // Berechne welche Tiles sichtbar sind + // Berechne welche Tiles sichtbar sind (basierend auf Canvas-Breite) tileWidth := 128.0 startTile := int(math.Floor(cameraX / tileWidth)) - endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth)) + endTile := int(math.Ceil((cameraX + float64(canvasW)) / tileWidth)) // Tiles rendern for tileIdx := startTile; tileIdx <= endTile; tileIdx++ { @@ -106,8 +121,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { screenX := float32(worldX - cameraX) screenY := float32(RefFloorY) + float32(dirt.OffsetY) - // Nur rendern wenn im sichtbaren Bereich - if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) { + // Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden) + if screenX+float32(dirt.Width) > 0 && screenX < float32(canvasW) { vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false) } } @@ -118,8 +133,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { screenX := float32(worldX - cameraX) screenY := float32(RefFloorY) + float32(stone.Y) - // Nur rendern wenn im sichtbaren Bereich - if screenX > -20 && screenX < float32(ScreenWidth)+20 { + // Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden) + if screenX > -20 && screenX < float32(canvasW)+20 { if stone.Shape == 0 { // Runder Stein vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false) diff --git a/cmd/client/main.go b/cmd/client/main.go index 570ce4b..f47b9a4 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -88,11 +88,14 @@ type Game struct { predictedVX float64 predictedVY float64 predictedGround bool + predictedOnWall bool + currentSpeed float64 // Aktuelle Scroll-Geschwindigkeit vom Server inputSequence uint32 // Sequenznummer für Inputs pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz predictionMutex sync.Mutex // Mutex für pendingInputs lastRecvSeq uint32 // Letzte empfangene Server-Sequenznummer (für Out-of-Order-Erkennung) + lastInputTime time.Time // Letzter Input-Send (für 20 TPS Throttling) // Smooth Correction correctionX float64 // Verbleibende Korrektur in X @@ -119,6 +122,18 @@ type Game struct { joyTouchID ebiten.TouchID btnJumpActive bool keyboardUsed bool // Wurde Tastatur benutzt? + + // Debug Stats + showDebug bool // Debug-Overlay anzeigen (F3 zum Umschalten) + fpsCounter int // Frame-Zähler + fpsSampleTime time.Time // Letzter FPS-Sample + currentFPS float64 // Aktuelle FPS + lastUpdateTime time.Time // Letzte Server-Update Zeit + updateLatency float64 // Latenz zum letzten Update (ms) + correctionCount int // Anzahl der Korrekturen + outOfOrderCount int // Anzahl verworfener Out-of-Order Pakete + totalUpdates int // Gesamtzahl empfangener Updates + pendingInputCount int // Anzahl pending Inputs } func NewGame() *Game { @@ -142,6 +157,10 @@ func NewGame() *Game { // Audio System audio: NewAudioSystem(), + // Debug Stats + fpsSampleTime: time.Now(), + lastUpdateTime: time.Now(), + joyBaseX: 150, joyBaseY: ScreenHeight - 150, joyStickX: 150, joyStickY: ScreenHeight - 150, } @@ -161,6 +180,24 @@ func NewGame() *Game { // --- UPDATE --- func (g *Game) Update() error { + // FPS Tracking + g.fpsCounter++ + if time.Since(g.fpsSampleTime) >= time.Second { + g.currentFPS = float64(g.fpsCounter) / time.Since(g.fpsSampleTime).Seconds() + g.fpsCounter = 0 + g.fpsSampleTime = time.Now() + } + + // Debug Toggle (F3) + if inpututil.IsKeyJustPressed(ebiten.KeyF3) { + g.showDebug = !g.showDebug + } + + // Pending Inputs zählen für Debug + g.predictionMutex.Lock() + g.pendingInputCount = len(g.pendingInputs) + g.predictionMutex.Unlock() + // Game Over Handling if g.appState == StateGame && g.gameState.Status == "GAMEOVER" { // Back Button (oben links) - Touch Support @@ -594,7 +631,10 @@ func (g *Game) DrawLobby(screen *ebiten.Image) { text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText) } -func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight } +func (g *Game) Layout(w, h int) (int, int) { + // Nutze die GESAMTE Bildschirmfläche ohne Einschränkungen + return w, h +} // --- HELPER --- diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go index 376b66b..3ef9928 100644 --- a/cmd/client/prediction.go +++ b/cmd/client/prediction.go @@ -1,11 +1,12 @@ package main import ( - "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics" ) // ApplyInput wendet einen Input auf den vorhergesagten Zustand an +// Nutzt die gemeinsame Physik-Engine aus pkg/physics func (g *Game) ApplyInput(input InputState) { // Horizontale Bewegung mit analogem Joystick moveX := 0.0 @@ -20,39 +21,42 @@ func (g *Game) ApplyInput(input InputState) { moveX = input.JoyX } - // Geschwindigkeit skaliert mit Joystick-Intensität - // Bewegung relativ zum Scroll (symmetrisch) - speed := config.RunSpeed + (moveX * config.PlayerSpeed) - g.predictedX += speed - - // Gravitation - g.predictedVY += config.Gravity - if g.predictedVY > config.MaxFall { - g.predictedVY = config.MaxFall + // Physik-State vorbereiten + state := physics.PlayerPhysicsState{ + X: g.predictedX, + Y: g.predictedY, + VX: g.predictedVX, + VY: g.predictedVY, + OnGround: g.predictedGround, + OnWall: g.predictedOnWall, } - // Fast Fall - if input.Down { - g.predictedVY = config.FastFall + // Physik-Input vorbereiten + physicsInput := physics.PhysicsInput{ + InputX: moveX, + Jump: input.Jump, + Down: input.Down, } - // Sprung - if input.Jump && g.predictedGround { - g.predictedVY = -config.JumpVelocity - g.predictedGround = false + // Kollisions-Checker vorbereiten + g.stateMutex.Lock() + collisionChecker := &physics.ClientCollisionChecker{ + World: g.world, + ActiveChunks: g.gameState.WorldChunks, + MovingPlatforms: g.gameState.MovingPlatforms, } + g.stateMutex.Unlock() - // Vertikale Bewegung - g.predictedY += g.predictedVY + // Gemeinsame Physik anwenden (1:1 wie Server) + physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, collisionChecker, physics.DefaultPlayerConstants()) - // Einfache Boden-Kollision (hardcoded für jetzt) - if g.predictedY >= 540 { - g.predictedY = 540 - g.predictedVY = 0 - g.predictedGround = true - } else { - g.predictedGround = false - } + // Ergebnis zurückschreiben + g.predictedX = state.X + g.predictedY = state.Y + g.predictedVX = state.VX + g.predictedVY = state.VY + g.predictedGround = state.OnGround + g.predictedOnWall = state.OnWall } // ReconcileWithServer gleicht lokale Prediction mit Server-State ab @@ -70,14 +74,15 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { } } - // Temporäre Position für Replay + // Temporäre Position für Replay (jetzt MIT Y-Achse) replayX := serverState.X replayY := serverState.Y replayVX := serverState.VX replayVY := serverState.VY replayGround := serverState.OnGround + replayOnWall := serverState.OnWall - // Replay alle noch nicht bestätigten Inputs + // Replay alle noch nicht bestätigten Inputs mit VOLLER Physik if len(g.pendingInputs) > 0 { for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ { if input, ok := g.pendingInputs[seq]; ok { @@ -85,12 +90,14 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { oldX, oldY := g.predictedX, g.predictedY oldVX, oldVY := g.predictedVX, g.predictedVY oldGround := g.predictedGround + oldOnWall := g.predictedOnWall g.predictedX = replayX g.predictedY = replayY g.predictedVX = replayVX g.predictedVY = replayVY g.predictedGround = replayGround + g.predictedOnWall = replayOnWall g.ApplyInput(input) @@ -99,6 +106,7 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { replayVX = g.predictedVX replayVY = g.predictedVY replayGround = g.predictedGround + replayOnWall = g.predictedOnWall // Zurücksetzen g.predictedX = oldX @@ -106,25 +114,44 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { g.predictedVX = oldVX g.predictedVY = oldVY g.predictedGround = oldGround + g.predictedOnWall = oldOnWall } } } - // Berechne Differenz zwischen aktueller Prediction und Server-Replay + // Berechne Differenz zwischen Client-Prediction und Server-Replay (X und Y) diffX := replayX - g.predictedX diffY := replayY - g.predictedY + dist := diffX*diffX + diffY*diffY - // Nur korrigieren wenn Differenz signifikant - // Bei 20 TPS größerer Threshold wegen größerer normaler Abweichungen - const threshold = 5.0 // Erhöht für 20 TPS (war 2.0) - if diffX*diffX+diffY*diffY > threshold*threshold { - // Speichere Korrektur für sanfte Interpolation - g.correctionX = diffX - g.correctionY = diffY + // Speichere Korrektur-Magnitude für Debug + g.correctionX = diffX + g.correctionY = diffY + + // Bei sehr kleinen Abweichungen (<2px): Sofort korrigieren um Drift zu vermeiden + if dist < 4.0 { // 2px threshold + g.predictedX = replayX + g.predictedY = replayY + } else if dist > 100*100 { + // Bei sehr großen Abweichungen (>100px): Sofort korrigieren (Teleport/Respawn) + g.predictedX = replayX + g.predictedY = replayY + g.correctionCount++ + } else if dist > 1.0 { + // Bei normalen Abweichungen: Sanfte Interpolation + // Bei 20 TPS: Aggressivere Interpolation + interpFactor := 0.5 // 50% pro Tick + if dist > 50*50 { + interpFactor = 0.8 // 80% bei großen Abweichungen + } + g.predictedX += diffX * interpFactor + g.predictedY += diffY * interpFactor + g.correctionCount++ } - // Velocity und Ground immer sofort übernehmen + // Velocity und Ground Status vom Server übernehmen g.predictedVX = replayVX g.predictedVY = replayVY g.predictedGround = replayGround + g.predictedOnWall = replayOnWall } diff --git a/cmd/client/web/main.wasm b/cmd/client/web/main.wasm index 926d729..8322b19 100755 Binary files a/cmd/client/web/main.wasm and b/cmd/client/web/main.wasm differ diff --git a/cmd/client/web/style.css b/cmd/client/web/style.css index fcda342..d0d5b23 100644 --- a/cmd/client/web/style.css +++ b/cmd/client/web/style.css @@ -1,7 +1,7 @@ @font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')} body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;font-family:'Press Start 2P',cursive;font-size:14px} #game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000} -canvas{position:fixed!important;top:0!important;left:0!important;width:100%!important;height:100%!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden} +canvas{position:fixed!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;max-width:100vw!important;max-height:100vh!important;object-fit:contain!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden} canvas.game-active{opacity:1;pointer-events:auto;z-index:2000!important;visibility:visible} .overlay-screen{position:fixed!important;top:0;left:0;width:100%;height:100%;background:url('background.jpg') center/cover no-repeat,rgba(0,0,0,.85);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px} .overlay-screen.hidden{display:none!important} diff --git a/cmd/server/main.go b/cmd/server/main.go index 9c17aaa..725115b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -159,7 +159,20 @@ func main() { rooms[roomID] = room // Starte den Game-Loop (Physik) - go room.RunLoop() + go func() { + room.RunLoop() + // Nach Ende des Spiels: Raum aufräumen + mu.Lock() + delete(rooms, roomID) + // Entferne auch alle Spieler-Sessions aus diesem Raum + for playerID, r := range playerSessions { + if r == room { + delete(playerSessions, playerID) + } + } + mu.Unlock() + log.Printf("🧹 Raum '%s' wurde aufgeräumt nach GAMEOVER", roomID) + }() } // Spieler hinzufügen (ID, Name) diff --git a/pkg/game/data.go b/pkg/game/data.go index adc316e..9ace549 100644 --- a/pkg/game/data.go +++ b/pkg/game/data.go @@ -114,6 +114,7 @@ type GameState struct { CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen Sequence uint32 `json:"sequence"` // Sequenznummer für Out-of-Order-Erkennung + CurrentSpeed float64 `json:"current_speed"` // Aktuelle Scroll-Geschwindigkeit (für Client-Prediction) } // MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform diff --git a/pkg/game/world.go b/pkg/game/world.go index 8633e13..43a0a87 100644 --- a/pkg/game/world.go +++ b/pkg/game/world.go @@ -94,9 +94,6 @@ func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider { Type: def.Type, } list = append(list, c) - fmt.Printf("✅ Collider generiert: Type=%s, Asset=%s, Pos=(%.0f,%.0f), DrawOff=(%.0f,%.0f), HitboxOff=(%.0f,%.0f)\n", - def.Type, obj.AssetID, c.Rect.OffsetX, c.Rect.OffsetY, - def.DrawOffX, def.DrawOffY, def.Hitbox.OffsetX, def.Hitbox.OffsetY) } } } diff --git a/pkg/physics/physics.go b/pkg/physics/physics.go new file mode 100644 index 0000000..9579de0 --- /dev/null +++ b/pkg/physics/physics.go @@ -0,0 +1,256 @@ +package physics + +import ( + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// PlayerPhysicsState enthält den kompletten Physik-Zustand eines Spielers +type PlayerPhysicsState struct { + X float64 + Y float64 + VX float64 + VY float64 + OnGround bool + OnWall bool +} + +// PhysicsInput enthält alle Inputs für einen Physik-Tick +type PhysicsInput struct { + InputX float64 // -1.0 bis 1.0 (Links/Rechts) + Jump bool + Down bool +} + +// CollisionChecker ist ein Interface für Kollisionserkennung +// Server und Client können unterschiedliche Implementierungen haben +type CollisionChecker interface { + CheckCollision(x, y, w, h float64) (hit bool, collisionType string) +} + +// PlayerConstants enthält alle Spieler-spezifischen Konstanten +type PlayerConstants struct { + DrawOffX float64 + DrawOffY float64 + HitboxOffX float64 + HitboxOffY float64 + Width float64 + Height float64 +} + +// DefaultPlayerConstants gibt die Standard-Spieler-Konstanten zurück +// WICHTIG: Diese Werte müssen EXAKT mit assets.json übereinstimmen! +func DefaultPlayerConstants() PlayerConstants { + return PlayerConstants{ + DrawOffX: -56.0, // Aus assets.json "player" + DrawOffY: -231.0, // Aus assets.json "player" + HitboxOffX: 68.0, // Aus assets.json "player" Hitbox.OffsetX + HitboxOffY: 42.0, // Aus assets.json "player" Hitbox.OffsetY + Width: 73.0, + Height: 184.0, + } +} + +// ApplyPhysics wendet einen Physik-Tick auf den Spieler an +// Diese Funktion wird 1:1 von Server und Client verwendet +func ApplyPhysics( + state *PlayerPhysicsState, + input PhysicsInput, + currentSpeed float64, + collisionChecker CollisionChecker, + playerConst PlayerConstants, +) { + // --- HORIZONTALE BEWEGUNG MIT KOLLISION --- + playerMovement := input.InputX * config.PlayerSpeed + speed := currentSpeed + playerMovement + nextX := state.X + speed + + // Horizontale Kollisionsprüfung + checkX := nextX + playerConst.DrawOffX + playerConst.HitboxOffX + checkY := state.Y + playerConst.DrawOffY + playerConst.HitboxOffY + + xHit, xCollisionType := collisionChecker.CheckCollision( + checkX, + checkY, + playerConst.Width, + playerConst.Height, + ) + + // Nur X-Bewegung blockieren wenn es eine Wand/Plattform ist (nicht Obstacle) + if xHit && (xCollisionType == "wall" || xCollisionType == "platform") { + // Kollision in X-Richtung -> X nicht ändern + // state.X bleibt wie es ist + } else { + // Keine Kollision -> X-Bewegung durchführen + state.X = nextX + } + + // --- VERTIKALE BEWEGUNG --- + // An der Wand: Reduzierte Gravität + Klettern + if state.OnWall { + state.VY += config.Gravity * 0.3 // 30% Gravität an der Wand + if state.VY > config.WallSlideMax { + state.VY = config.WallSlideMax + } + // Hochklettern wenn Bewegung vorhanden + if input.InputX != 0 { + state.VY = -config.WallClimbSpeed + } + } else { + // Normal: Volle Gravität + state.VY += config.Gravity + if state.VY > config.MaxFall { + state.VY = config.MaxFall + } + } + + // Fast Fall + if input.Down { + state.VY = config.FastFall + } + + // Sprung + if input.Jump && state.OnGround { + state.VY = -config.JumpVelocity + state.OnGround = false + } + + // Vertikale Bewegung mit Kollisionserkennung + nextY := state.Y + state.VY + + // Kollision für AKTUELLE Position prüfen (um OnGround richtig zu setzen) + checkX = state.X + playerConst.DrawOffX + playerConst.HitboxOffX + currentCheckY := state.Y + playerConst.DrawOffY + playerConst.HitboxOffY + + currentHit, currentType := collisionChecker.CheckCollision( + checkX, + currentCheckY, + playerConst.Width, + playerConst.Height, + ) + + // Wenn Spieler aktuell bereits auf dem Boden steht + if currentHit && currentType == "platform" && state.VY >= 0 { + state.OnGround = true + state.VY = 0 + state.OnWall = false + // Y-Position bleibt wo sie ist + return + } + + // Kollision für NÄCHSTE Position prüfen + nextCheckY := nextY + playerConst.DrawOffY + playerConst.HitboxOffY + + hit, collisionType := collisionChecker.CheckCollision( + checkX, + nextCheckY, + playerConst.Width, + playerConst.Height, + ) + + if hit { + if collisionType == "wall" { + // An der Wand: Position halten (Y nicht ändern) + state.VY = 0 + state.OnWall = true + state.OnGround = false + } else if collisionType == "obstacle" { + // Obstacle: Spieler bewegt sich in Obstacle (Server killt dann) + state.Y = nextY + state.VY = 0 + state.OnGround = false + state.OnWall = false + } else if collisionType == "platform" { + // Platform: Blockiert vertikale Bewegung + if state.VY > 0 { + state.OnGround = true + } + state.VY = 0 + state.OnWall = false + // Y-Position bleibt unverändert + } + } else { + // Keine Kollision: Bewegung durchführen + state.Y = nextY + state.OnGround = false + state.OnWall = false + } +} + +// ClientCollisionChecker implementiert CollisionChecker für den Client +type ClientCollisionChecker struct { + World *game.World + ActiveChunks []game.ActiveChunk + MovingPlatforms []game.MovingPlatformSync +} + +func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, string) { + playerRect := game.Rect{ + OffsetX: x, + OffsetY: y, + W: w, + H: h, + } + + // 0. Basis-Boden-Collider (wie Server) - UNTER dem sichtbaren Gras bis tief in die Erde + floorCollider := game.Rect{ + OffsetX: -10000, + OffsetY: 540, // Startet bei Y=540 (Gras-Oberfläche) + W: 100000000, + H: 5000, // Geht tief nach UNTEN (bis Y=5540) - gesamte Erdschicht + } + if game.CheckRectCollision(playerRect, floorCollider) { + return true, "platform" + } + + // 1. Statische Colliders aus Chunks + for _, activeChunk := range c.ActiveChunks { + if chunk, ok := c.World.ChunkLibrary[activeChunk.ChunkID]; ok { + for _, obj := range chunk.Objects { + if assetDef, ok := c.World.Manifest.Assets[obj.AssetID]; ok { + if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 { + colliderRect := game.Rect{ + OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX, + OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY, + W: assetDef.Hitbox.W, + H: assetDef.Hitbox.H, + } + + if game.CheckRectCollision(playerRect, colliderRect) { + return true, assetDef.Hitbox.Type + } + } + } + } + } + } + + // 2. Bewegende Plattformen + for _, mp := range c.MovingPlatforms { + if assetDef, ok := c.World.Manifest.Assets[mp.AssetID]; ok { + if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 { + mpRect := game.Rect{ + OffsetX: mp.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX, + OffsetY: mp.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY, + W: assetDef.Hitbox.W, + H: assetDef.Hitbox.H, + } + + if game.CheckRectCollision(playerRect, mpRect) { + return true, "platform" + } + } + } + } + + return false, "" +} + +// ServerCollisionChecker ist ein Wrapper für die Server CheckCollision-Methode +type ServerCollisionChecker struct { + CheckCollisionFunc func(x, y, w, h float64) (bool, string) +} + +func (s *ServerCollisionChecker) CheckCollision(x, y, w, h float64) (bool, string) { + return s.CheckCollisionFunc(x, y, w, h) +} diff --git a/pkg/server/room.go b/pkg/server/room.go index 81a382a..4bbce4f 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -9,6 +9,7 @@ import ( "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics" "github.com/nats-io/nats.go" ) @@ -21,6 +22,8 @@ type ServerPlayer struct { OnWall bool // Ist an einer Wand OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht InputX float64 // -1 (Links), 0, 1 (Rechts) + InputJump bool // Sprung-Input (für Physik-Engine) + InputDown bool // Nach-Unten-Input (für Fast Fall) LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz Score int DistanceScore int // Score basierend auf zurückgelegter Distanz @@ -122,6 +125,8 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { r.pDrawOffY = def.DrawOffY r.pHitboxOffX = def.Hitbox.OffsetX r.pHitboxOffY = def.Hitbox.OffsetY + log.Printf("🎮 Player Hitbox geladen: DrawOff=(%.1f, %.1f), HitboxOff=(%.1f, %.1f), Size=(%.1f, %.1f)", + r.pDrawOffX, r.pDrawOffY, r.pHitboxOffX, r.pHitboxOffY, r.pW, r.pH) } // Start-Chunk @@ -163,7 +168,7 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { // --- MAIN LOOP --- func (r *Room) RunLoop() { - // 60 Tick pro Sekunde + // 20 Tick pro Sekunde ticker := time.NewTicker(time.Second / 20) defer ticker.Stop() @@ -172,6 +177,17 @@ func (r *Room) RunLoop() { case <-r.stopChan: return case <-ticker.C: + r.Mutex.RLock() + status := r.Status + r.Mutex.RUnlock() + + // Stoppe Updates wenn Spiel vorbei ist + if status == "GAMEOVER" { + r.Broadcast() // Ein letztes Mal broadcasten + time.Sleep(5 * time.Second) // Kurz warten damit Clients den GAMEOVER State sehen + return // Beende Loop + } + r.Update() r.Broadcast() } @@ -196,7 +212,7 @@ func (r *Room) AddPlayer(id, name string) { ID: id, Name: name, X: spawnX, - Y: 200, + Y: 400, // Spawn über dem Gras (Y=540), fällt dann auf den Boden OnGround: false, Score: 0, IsAlive: true, @@ -261,18 +277,15 @@ func (r *Room) HandleInput(input game.ClientInput) { switch input.Type { case "JUMP": - if p.OnGround { - p.VY = -config.JumpVelocity - p.OnGround = false - p.DoubleJumpUsed = false // Reset double jump on ground jump - } else if p.HasDoubleJump && !p.DoubleJumpUsed { - // Double Jump in der Luft + p.InputJump = true // Setze Jump-Flag für Physik-Engine + // Double Jump spezial-Logik (außerhalb der Physik-Engine) + if !p.OnGround && p.HasDoubleJump && !p.DoubleJumpUsed { p.VY = -config.JumpVelocity p.DoubleJumpUsed = true log.Printf("⚡ %s verwendet Double Jump!", p.Name) } case "DOWN": - p.VY = config.FastFall + p.InputDown = true // Setze Down-Flag für Fast Fall case "LEFT_DOWN": p.InputX = -1 case "LEFT_UP": @@ -355,44 +368,67 @@ func (r *Room) Update() { continue } - // X Bewegung - // Spieler bewegt sich relativ zum Scroll - // Scroll-Geschwindigkeit + Links/Rechts Bewegung - playerMovement := p.InputX * config.PlayerSpeed - currentSpeed := r.CurrentSpeed + playerMovement - nextX := p.X + currentSpeed + // === PHYSIK MIT GEMEINSAMER ENGINE (1:1 wie Client) === - hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) - if hitX { - if typeX == "wall" { - // Wand getroffen - kann klettern! - p.OnWall = true - // X-Position NICHT ändern (bleibt vor der Wand stehen) - } else if typeX == "obstacle" { - // Godmode prüfen - if p.HasGodMode && time.Now().Before(p.GodModeEndTime) { - // Mit Godmode - Obstacle wird zerstört, Spieler überlebt - p.X = nextX - // TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle) - log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name) - } else { - // Ohne Godmode - Spieler stirbt - p.X = nextX - r.KillPlayer(p) - continue - } - } else { - // Platform blockiert - p.OnWall = false - } - } else { - p.X = nextX - p.OnWall = false + // Physik-State vorbereiten + state := physics.PlayerPhysicsState{ + X: p.X, + Y: p.Y, + VX: p.VX, + VY: p.VY, + OnGround: p.OnGround, + OnWall: p.OnWall, + } + + // Physik-Input vorbereiten + physicsInput := physics.PhysicsInput{ + InputX: p.InputX, + Jump: p.InputJump, + Down: p.InputDown, + } + + // Kollisions-Checker vorbereiten + collisionChecker := &physics.ServerCollisionChecker{ + CheckCollisionFunc: r.CheckCollision, + } + + // Gemeinsame Physik anwenden (1:1 wie Client!) + physics.ApplyPhysics(&state, physicsInput, r.CurrentSpeed, collisionChecker, physics.DefaultPlayerConstants()) + + // Ergebnis zurückschreiben + p.X = state.X + p.Y = state.Y + p.VX = state.VX + p.VY = state.VY + p.OnGround = state.OnGround + p.OnWall = state.OnWall + + // Input-Flags zurücksetzen für nächsten Tick + p.InputJump = false + p.InputDown = false + + // Double Jump Reset wenn wieder am Boden + if p.OnGround { + p.DoubleJumpUsed = false + } + + // === SERVER-SPEZIFISCHE LOGIK === + + // Obstacle-Kollision prüfen -> Spieler töten + hitObstacle, obstacleType := r.CheckCollision( + p.X+r.pDrawOffX+r.pHitboxOffX, + p.Y+r.pDrawOffY+r.pHitboxOffY, + r.pW, + r.pH, + ) + if hitObstacle && obstacleType == "obstacle" { + r.KillPlayer(p) + continue } // Grenzen - if p.X > r.GlobalScrollX+1200 { - p.X = r.GlobalScrollX + 1200 + if p.X > r.GlobalScrollX+2000 { + p.X = r.GlobalScrollX + 2000 } // Rechts Block if p.X < r.GlobalScrollX-50 { r.KillPlayer(p) @@ -403,53 +439,11 @@ func (r *Room) Update() { maxX = p.X } - // Y Bewegung - // An der Wand: Reduzierte Gravität + Klettern mit InputX - if p.OnWall { - // Wandrutschen (langsame Fallgeschwindigkeit) - p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand - if p.VY > config.WallSlideMax { - p.VY = config.WallSlideMax - } - - // Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung) - if p.InputX != 0 { - p.VY = -config.WallClimbSpeed - } + // Prüfe ob auf bewegender Plattform (für Platform-Mitbewegung) + if p.OnGround { + platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) + p.OnMovingPlatform = platform } else { - // Normal: Volle Gravität - p.VY += config.Gravity - if p.VY > config.MaxFall { - p.VY = config.MaxFall - } - } - - nextY := p.Y + p.VY - - hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) - if hitY { - if typeY == "wall" { - // An der Wand: Nicht töten, Position halten und klettern ermöglichen - p.VY = 0 - p.OnWall = true - } else if typeY == "obstacle" { - // Obstacle - immer töten - p.Y = nextY - r.KillPlayer(p) - continue - } else { - // Platform blockiert - if p.VY > 0 { - p.OnGround = true - // Prüfe ob auf bewegender Plattform - platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) - p.OnMovingPlatform = platform - } - p.VY = 0 - } - } else { - p.Y += p.VY - p.OnGround = false p.OnMovingPlatform = nil } @@ -535,10 +529,6 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { // 1. Statische Colliders (Chunks) for _, c := range r.Colliders { if game.CheckRectCollision(playerRect, c.Rect) { - log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)", - c.Type, - playerRect.OffsetX, playerRect.OffsetY, playerRect.W, playerRect.H, - c.Rect.OffsetX, c.Rect.OffsetY, c.Rect.W, c.Rect.H) return true, c.Type } } @@ -802,6 +792,7 @@ func (r *Room) Broadcast() { CollectedPowerups: r.CollectedPowerups, MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)), Sequence: r.sequence, + CurrentSpeed: r.CurrentSpeed, } for id, p := range r.Players {