Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/prediction.go
Sebastian Unterschütz f1dff8d64c
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m49s
add offline moving platform logic: implement dynamic platform detection and movement handling in offline mode
2026-04-22 23:52:32 +02:00

319 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"log"
"math"
"time"
"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"
)
// CheckMovingPlatformLanding prüft ob der Spieler auf einer bewegenden Plattform steht
func (g *Game) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
for _, mp := range g.offlineMovingPlatforms {
mpRect := game.Rect{
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
W: mp.HitboxW,
H: mp.HitboxH,
}
// Etwas großzügigerer Check nach oben
if game.CheckRectCollision(playerRect, mpRect) {
return mp
}
}
return nil
}
// ApplyInput wendet einen Input auf den vorhergesagten Zustand an
// Nutzt die gemeinsame Physik-Engine aus pkg/physics
func (g *Game) ApplyInput(input InputState) {
g.stateMutex.Lock()
status := g.gameState.Status
g.stateMutex.Unlock()
if status == "COUNTDOWN" {
return
}
// --- OFFLINE: Mit Plattform mitbewegen ---
if g.isOffline && g.predictedGround {
pConst := physics.DefaultPlayerConstants()
mp := g.CheckMovingPlatformLanding(
g.predictedX+pConst.DrawOffX+pConst.HitboxOffX,
g.predictedY+pConst.DrawOffY+pConst.HitboxOffY,
pConst.Width,
pConst.Height,
)
if mp != nil {
// Berechne Plattform-Geschwindigkeit
dx := mp.EndX - mp.StartX
dy := mp.EndY - mp.StartY
dist := math.Sqrt(dx*dx + dy*dy)
if dist > 0.1 {
vx := (dx / dist) * (mp.Speed / 20.0) * mp.Direction
vy := (dy / dist) * (mp.Speed / 20.0) * mp.Direction
g.predictedX += vx
g.predictedY += vy
}
}
}
// Horizontale Bewegung mit analogem Joystick
moveX := 0.0
if input.Left {
moveX = -1.0
} else if input.Right {
moveX = 1.0
}
// Wenn Joystick benutzt wird, überschreibe moveX mit analogem Wert
if input.JoyX != 0 {
moveX = input.JoyX
}
// Physik-State vorbereiten
state := physics.PlayerPhysicsState{
X: g.predictedX,
Y: g.predictedY,
VX: g.predictedVX,
VY: g.predictedVY,
OnGround: g.predictedGround,
OnWall: g.predictedOnWall,
}
// Double Jump: vor der Physik anwenden (1:1 wie Server)
if input.Jump && !g.predictedGround && g.predictedHasDoubleJump && !g.predictedDoubleJumpUsed {
g.predictedVY = -config.JumpVelocity
g.predictedDoubleJumpUsed = true
}
// Double Jump Reset wenn wieder am Boden
if g.predictedGround {
g.predictedDoubleJumpUsed = false
}
// Physik-Input vorbereiten
physicsInput := physics.PhysicsInput{
InputX: moveX,
Jump: input.Jump,
Down: input.Down,
}
// Kollisions-Checker vorbereiten
g.stateMutex.Lock()
collisionChecker := &physics.ClientCollisionChecker{
World: g.world,
ActiveChunks: g.gameState.WorldChunks,
MovingPlatforms: g.gameState.MovingPlatforms,
}
difficultyFactor := g.gameState.DifficultyFactor
g.stateMutex.Unlock()
// Gemeinsame Physik anwenden (1:1 wie Server, inkl. Schwierigkeits-Skalierung)
physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, difficultyFactor, collisionChecker, physics.DefaultPlayerConstants())
// 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
func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
g.predictionMutex.Lock()
defer g.predictionMutex.Unlock()
// Server-bestätigte Sequenz
g.lastServerSeq = serverState.LastInputSeq
// Entferne alle bestätigten Inputs
for seq := range g.pendingInputs {
if seq <= g.lastServerSeq {
delete(g.pendingInputs, seq)
}
}
// 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 mit VOLLER Physik
if len(g.pendingInputs) > 0 {
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
if input, ok := g.pendingInputs[seq]; ok {
// Temporär auf Replay-Position setzen
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)
replayX = g.predictedX
replayY = g.predictedY
replayVX = g.predictedVX
replayVY = g.predictedVY
replayGround = g.predictedGround
replayOnWall = g.predictedOnWall
// Zurücksetzen
g.predictedX = oldX
g.predictedY = oldY
g.predictedVX = oldVX
g.predictedVY = oldVY
g.predictedGround = oldGround
g.predictedOnWall = oldOnWall
}
}
}
// Berechne Differenz zwischen Client-Prediction und Server-Replay (X und Y)
diffX := replayX - g.predictedX
diffY := replayY - g.predictedY
dist := diffX*diffX + diffY*diffY
// Speichere Korrektur-Magnitude für Debug
g.correctionX = diffX
g.correctionY = diffY
// === SMOOTH CORRECTION MIT VISUELLEM OFFSET ===
//
// Strategie: Physics springt sofort zur richtigen Position (korrekte Physik),
// aber der angezeigte Wert (predictedX/Y + correctionOffsetX/Y) springt NICHT
// er driftet sanft über ~80ms zur Physics-Position hin.
//
// Dadurch sehen Spieler keinen "Pushback/Teleport" mehr, selbst wenn der Server
// eine größere Korrektur schickt.
const (
acceptTolerance = 400.0 // ~20px: keine Korrektur
criticalTolerance = 2500.0 // ~50px: sanfte Korrektur
)
if dist < acceptTolerance {
// Kleine Abweichung (<20px): Client-Position beibehalten, kein Eingriff nötig
} else if dist < criticalTolerance {
// Mittlere Abweichung (20-50px): sanfte Physics-Korrektur (25%)
// Visueller Offset kompensiert die Bewegung → kein sichtbarer Sprung
factor := 0.25
corrX := diffX * factor
corrY := diffY * factor
g.correctionOffsetX -= corrX // Offset gegenläufig zur Physics-Korrektur
g.correctionOffsetY -= corrY
g.predictedX += corrX
g.predictedY += corrY
g.correctionCount++
} else {
// Große Abweichung (>50px): Physics springt komplett zur Replay-Position.
// Visueller Offset hält das Bild an der alten Stelle Spieler sieht keinen Teleport.
g.correctionOffsetX += g.predictedX - replayX // = -(replayX - predictedX) = -diffX
g.correctionOffsetY += g.predictedY - replayY
g.predictedX = replayX
g.predictedY = replayY
g.correctionCount++
}
// Velocity und Ground-State immer vom Replay übernehmen.
// Replay-Werte kommen aus korrekter Server-State + Pending-Input-Replay
// und sind physikalisch genauer als reine Client-Prediction über Zeit.
g.predictedVX = replayVX
g.predictedVY = replayVY
g.predictedGround = replayGround
g.predictedOnWall = replayOnWall
// Powerup-State vom Server übernehmen (autoritativ)
g.predictedHasDoubleJump = serverState.HasDoubleJump
g.predictedDoubleJumpUsed = serverState.DoubleJumpUsed
}
// checkSoloRound führt lokale Prüfungen für den Solo-Modus durch.
// Dies ermöglicht sofortiges Feedback bei Tod und lokale Score-Validierung.
func (g *Game) checkSoloRound() {
if g.gameMode != "solo" || g.gameState.Status != "RUNNING" {
return
}
// 1. Lokale Todes-Erkennung (Nur noch Grenzen im Solo-Modus)
g.stateMutex.Lock()
scrollX := g.gameState.ScrollX
g.stateMutex.Unlock()
isDead := false
deathReason := ""
// Aus dem linken Bildschirmrand gefallen?
if g.predictedX < scrollX-50 {
isDead = true
deathReason = "Vom Lehrer erwischt"
}
// Wenn lokal Tod festgestellt wurde, den GameState lokal auf GAMEOVER setzen
// (Wird vom Server-Update später bestätigt, aber sorgt für 0ms Latenz im UI)
if isDead {
g.stateMutex.Lock()
if g.gameState.Status == "RUNNING" {
log.Printf("💀 Lokale Todes-Erkennung: %s! Beende Runde.", deathReason)
g.gameState.Status = "GAMEOVER"
// Eigenen Spieler lokal als tot markieren
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
p.IsAlive = false
g.gameState.Players[id] = p
break
}
}
g.audio.StopMusic()
}
g.stateMutex.Unlock()
}
// 2. Lokale Score-Prüfung (Optional: Vergleiche mit Server)
// In einem echten Anti-Cheat-Szenario könnte man hier die Distanz selbst tracken
}
// verifyRoundResult prüft am Ende der Runde die Konsistenz der Daten.
func (g *Game) verifyRoundResult() {
g.stateMutex.Lock()
defer g.stateMutex.Unlock()
if g.gameMode != "solo" {
return
}
myScore := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
myScore = p.Score
break
}
}
duration := time.Since(g.roundStartTime).Seconds()
log.Printf("🧐 Runde beendet. Überprüfe Ergebnis: %d Punkte (Dauer: %.1fs)", myScore, duration)
// Hier könnten weitere Prüfungen folgen (z.B. war die Zeit plausibel?)
// Für dieses Projekt zeigen wir die erfolgreiche Überprüfung im Log an.
}