293 lines
8.5 KiB
Go
293 lines
8.5 KiB
Go
package main
|
||
|
||
import (
|
||
"log"
|
||
"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"
|
||
)
|
||
|
||
// 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
|
||
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 (Obstacles & Grenzen)
|
||
// Wir nutzen die vorhergesagte Position
|
||
pConst := physics.DefaultPlayerConstants()
|
||
checkX := g.predictedX + pConst.DrawOffX + pConst.HitboxOffX
|
||
checkY := g.predictedY + pConst.DrawOffY + pConst.HitboxOffY
|
||
|
||
g.stateMutex.Lock()
|
||
collisionChecker := &physics.ClientCollisionChecker{
|
||
World: g.world,
|
||
ActiveChunks: g.gameState.WorldChunks,
|
||
MovingPlatforms: g.gameState.MovingPlatforms,
|
||
}
|
||
scrollX := g.gameState.ScrollX
|
||
|
||
hasGodMode := false
|
||
for _, p := range g.gameState.Players {
|
||
if p.Name == g.playerName {
|
||
hasGodMode = p.HasGodMode
|
||
break
|
||
}
|
||
}
|
||
g.stateMutex.Unlock()
|
||
|
||
// Kollision mit Hindernis?
|
||
hit, colType := collisionChecker.CheckCollision(checkX, checkY, pConst.Width, pConst.Height)
|
||
|
||
isDead := false
|
||
deathReason := ""
|
||
|
||
if hit && colType == "obstacle" && !hasGodMode {
|
||
isDead = true
|
||
deathReason = "Hindernis berührt"
|
||
}
|
||
|
||
// 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.
|
||
}
|