diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index da36029..d5a3106 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -61,23 +61,72 @@ func (g *Game) UpdateGame() { // Down: Joystick nach unten ziehen ODER Down-Button isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > g.joyRadius*0.5 - // Input State zusammenbauen - input := InputState{ - Sequence: g.inputSequence, - Left: keyLeft || joyDir < -0.1, - Right: keyRight || joyDir > 0.1, - Jump: keyJump || g.btnJumpActive, - Down: keyDown || isJoyDown || g.btnDownActive, - JoyX: joyDir, - } + wantsJump := keyJump || g.btnJumpActive g.btnJumpActive = false g.btnDownActive = false + // Jump Buffer: Sprung-Wunsch für bis zu 6 Physics-Frames (=300ms) speichern + if wantsJump { + g.jumpBufferFrames = 6 + } + // --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) --- - // Prediction läuft bei jedem Frame (60 TPS) für flüssige Darstellung. - // Netz-Send wird auf ~20/sek gedrosselt um Server-Last zu begrenzen. - if g.connected { + // 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 { + 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 + } + + effectiveJump := wantsJump || + (g.jumpBufferFrames > 0 && onGround) || + (wantsJump && g.coyoteFrames > 0) + + if effectiveJump { + g.jumpBufferFrames = 0 // Buffer verbraucht + } + + // Input State zusammenbauen + input := InputState{ + Sequence: g.inputSequence, + Left: keyLeft || joyDir < -0.1, + Right: keyRight || joyDir > 0.1, + Jump: effectiveJump, + Down: keyDown || isJoyDown, + JoyX: joyDir, + } + + 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 @@ -88,9 +137,9 @@ func (g *Game) UpdateGame() { // Input für History speichern (für Server-Reconciliation) g.pendingInputs[input.Sequence] = input - // Cap: nie mehr als 120 unbestätigte Inputs ansammeln - if len(g.pendingInputs) > 120 { - oldest := g.inputSequence - 120 + // Cap: nie mehr als 60 unbestätigte Inputs ansammeln (~3 Sek bei 20/sek) + if len(g.pendingInputs) > 60 { + oldest := g.inputSequence - 60 for seq := range g.pendingInputs { if seq < oldest { delete(g.pendingInputs, seq) @@ -100,25 +149,21 @@ func (g *Game) UpdateGame() { g.predictionMutex.Unlock() - // Netz-Throttle: max 20 Inputs/Sek senden - if time.Since(g.lastInputTime) >= 50*time.Millisecond { - g.lastInputTime = time.Now() - g.SendInputWithSequence(input) - } + g.SendInputWithSequence(input) } - // --- 5. KAMERA LOGIK --- + // --- 5. KAMERA LOGIK (mit Smoothing) --- g.stateMutex.Lock() targetCam := g.gameState.ScrollX g.stateMutex.Unlock() - // Negative Kamera verhindern if targetCam < 0 { targetCam = 0 } - // Kamera hart setzen - g.camX = targetCam + // 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. @@ -386,12 +431,17 @@ func (g *Game) DrawGame(screen *ebiten.Image) { vy := p.VY onGround := p.OnGround - // Für lokalen Spieler: Verwende Client-Prediction + visuellen Korrektur-Offset - // correctionOffset sorgt dafür dass Server-Korrekturen sanft aussehen + // 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 { g.predictionMutex.Lock() - posX = g.predictedX + g.correctionOffsetX - posY = g.predictedY + g.correctionOffsetY + // Interpolations-Alpha: wie weit sind wir zwischen letztem und nächstem Physics-Step? + alpha := float64(time.Since(g.lastPhysicsTime)) / float64(50*time.Millisecond) + if alpha > 1 { + alpha = 1 + } + posX = g.prevPredictedX + (g.predictedX-g.prevPredictedX)*alpha + g.correctionOffsetX + posY = g.prevPredictedY + (g.predictedY-g.prevPredictedY)*alpha + g.correctionOffsetY g.predictionMutex.Unlock() } diff --git a/cmd/client/main.go b/cmd/client/main.go index 527b5a0..fa5f62b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -100,6 +100,17 @@ type Game struct { lastRecvSeq uint32 // Letzte empfangene Server-Sequenznummer (für Out-of-Order-Erkennung) lastInputTime time.Time // Letzter Input-Send (für 20 TPS Throttling) + // Interpolation (60fps Draw ↔ 20hz Physics) + prevPredictedX float64 // Position vor letztem Physics-Step (für Interpolation) + prevPredictedY float64 + lastPhysicsTime time.Time // Zeitpunkt des letzten Physics-Steps + + // Jump Buffer: Sprung kurz vor Landung speichern → löst beim Aufkommen aus + jumpBufferFrames int // Countdown in Physics-Frames (bei 0: kein Buffer) + + // Coyote Time: Sprung kurz nach Abgang von Kante erlauben + coyoteFrames int // Countdown in Physics-Frames + // Smooth Correction (Debug-Info) correctionX float64 // Letzte Korrektur-Magnitude X correctionY float64 // Letzte Korrektur-Magnitude Y @@ -176,6 +187,9 @@ func NewGame() *Game { fpsSampleTime: time.Now(), lastUpdateTime: time.Now(), + // Interpolation + lastPhysicsTime: time.Now(), + joyBaseX: 150, joyBaseY: ScreenHeight - 150, joyStickX: 150, joyStickY: ScreenHeight - 150, }