From f48ade50bb03006ce8212245e62ab8e1343bc2a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Sat, 21 Mar 2026 13:31:34 +0100 Subject: [PATCH] fix game --- cmd/client/game_render.go | 25 +++++++++++--- cmd/client/main.go | 72 ++++++++++++++------------------------- cmd/client/prediction.go | 47 +++++++++++++++---------- pkg/game/data.go | 10 +++++- pkg/server/gateway.go | 19 +++++------ pkg/server/room.go | 40 ++++++++++++++++++++-- 6 files changed, 130 insertions(+), 83 deletions(-) diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 7eded9d..56a8d8b 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -104,7 +104,22 @@ func (g *Game) UpdateGame() { // Kamera hart setzen g.camX = targetCam - // --- 6. PARTIKEL UPDATEN --- + // --- 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 + g.predictionMutex.Lock() + g.correctionOffsetX *= correctionDecay + g.correctionOffsetY *= correctionDecay + if g.correctionOffsetX*g.correctionOffsetX < 0.09 { + g.correctionOffsetX = 0 + } + if g.correctionOffsetY*g.correctionOffsetY < 0.09 { + g.correctionOffsetY = 0 + } + g.predictionMutex.Unlock() + + // --- 7. PARTIKEL UPDATEN --- g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms // --- 7. PARTIKEL SPAWNEN (State Changes Detection) --- @@ -335,12 +350,12 @@ func (g *Game) DrawGame(screen *ebiten.Image) { vy := p.VY onGround := p.OnGround - // Für lokalen Spieler: Verwende Client-Prediction Position - // Die Reconciliation wird in ReconcileWithServer() (connection_*.go) gemacht + // Für lokalen Spieler: Verwende Client-Prediction + visuellen Korrektur-Offset + // correctionOffset sorgt dafür dass Server-Korrekturen sanft aussehen if p.Name == g.playerName { g.predictionMutex.Lock() - posX = g.predictedX - posY = g.predictedY + posX = g.predictedX + g.correctionOffsetX + posY = g.predictedY + g.correctionOffsetY g.predictionMutex.Unlock() } diff --git a/cmd/client/main.go b/cmd/client/main.go index 4f89d44..8d84e14 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -98,9 +98,14 @@ 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) - // Smooth Correction - correctionX float64 // Verbleibende Korrektur in X - correctionY float64 // Verbleibende Korrektur in Y + // Smooth Correction (Debug-Info) + correctionX float64 // Letzte Korrektur-Magnitude X + correctionY float64 // Letzte Korrektur-Magnitude Y + + // Visueller Korrektur-Offset: Physics springt sofort, Display folgt sanft + // display_pos = predicted + correctionOffset (blendet harte Korrekturen aus) + correctionOffsetX float64 + correctionOffsetY float64 // Particle System particles []Particle @@ -866,50 +871,25 @@ func (g *Game) SendInputWithSequence(input InputState) { myID := g.getMyPlayerID() - // Inputs als einzelne Commands senden - if input.Left { - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "LEFT_DOWN", - Sequence: input.Sequence, - }) - } - if input.Right { - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "RIGHT_DOWN", - Sequence: input.Sequence, - }) - } - if input.Jump { - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "JUMP", - Sequence: input.Sequence, - }) - // Jump Sound abspielen - g.audio.PlayJump() - } - if input.Down { - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "DOWN", - Sequence: input.Sequence, - }) - } + // Kompletten Input-State in einer einzigen Nachricht senden. + // Vorteile gegenüber mehreren Event-Nachrichten: + // - Kein stuck-Input durch verlorene oder falsch sortierte Pakete + // - Server hat immer den vollständigen Zustand nach einem Paket + // - Weniger Nachrichten pro Frame + g.publishInput(game.ClientInput{ + PlayerID: myID, + Type: "STATE", + Sequence: input.Sequence, + InputLeft: input.Left, + InputRight: input.Right, + InputJump: input.Jump, + InputDown: input.Down, + InputJoyX: input.JoyX, + }) - // Wenn weder Links noch Rechts, sende STOP - if !input.Left && !input.Right { - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "LEFT_UP", - Sequence: input.Sequence, - }) - g.publishInput(game.ClientInput{ - PlayerID: myID, - Type: "RIGHT_UP", - Sequence: input.Sequence, - }) + // Jump-Sound lokal abspielen + if input.Jump { + g.audio.PlayJump() } } diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go index f5e4816..608db91 100644 --- a/cmd/client/prediction.go +++ b/cmd/client/prediction.go @@ -128,37 +128,48 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { g.correctionX = diffX g.correctionY = diffY - // === NEUE KORREKTUR-LOGIK MIT TOLERANZ === + // === 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 ( - // Toleranzen für Client-Abweichungen - acceptTolerance = 400.0 // 20px - Client-Wert wird akzeptiert, keine Korrektur - warnTolerance = 2500.0 // 50px - Warnung, nur noch Monitoring - criticalTolerance = 10000.0 // 100px - kritisch (Teleport/Respawn/Desync) + acceptTolerance = 400.0 // ~20px: keine Korrektur + criticalTolerance = 2500.0 // ~50px: sanfte Korrektur ) if dist < acceptTolerance { - // Bei kleinen Abweichungen (<20px): Client-Position akzeptieren, KEINE Korrektur - // Server nimmt Client-Wert an - // (Client-seitige Prediction bleibt unverändert) - } else if dist < warnTolerance { - // Bei mittleren Abweichungen (20-50px): Nur Monitoring, KEINE Korrektur - // Der Unterschied wird toleriert - Client-Position wird akzeptiert - // Server überwacht weiterhin, greift aber nicht ein + // Kleine Abweichung (<20px): Client-Position beibehalten, kein Eingriff nötig + } else if dist < criticalTolerance { - // Bei großen Abweichungen (50-100px): Sanfte Korrektur - interpFactor := 0.3 // Nur 30% Korrektur - g.predictedX += diffX * interpFactor - g.predictedY += diffY * interpFactor + // 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 { - // Bei kritischen Abweichungen (>100px): Sofort korrigieren (Teleport/Respawn/Desync) + // 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 Status vom Server übernehmen + // 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 diff --git a/pkg/game/data.go b/pkg/game/data.go index 9ace549..37e1c66 100644 --- a/pkg/game/data.go +++ b/pkg/game/data.go @@ -66,11 +66,19 @@ type LoginPayload struct { // Input vom Spieler während des Spiels type ClientInput struct { - Type string `json:"type"` // "JUMP", "START", "LEFT_DOWN", "RIGHT_DOWN", "SET_TEAM_NAME", etc. + Type string `json:"type"` // "STATE", "START", "SET_TEAM_NAME", etc. RoomID string `json:"room_id"` PlayerID string `json:"player_id"` Sequence uint32 `json:"sequence"` // Sequenznummer für Client Prediction TeamName string `json:"team_name,omitempty"` // Für SET_TEAM_NAME Input + + // Vollständiger Input-State (für TYPE="STATE") + // Sendet den kompletten Zustand in einer Nachricht, verhindert stuck-Inputs durch Paketverlust + InputLeft bool `json:"input_left,omitempty"` + InputRight bool `json:"input_right,omitempty"` + InputJump bool `json:"input_jump,omitempty"` + InputDown bool `json:"input_down,omitempty"` + InputJoyX float64 `json:"input_joy_x,omitempty"` } type JoinRequest struct { diff --git a/pkg/server/gateway.go b/pkg/server/gateway.go index 0641a37..b30cc8e 100644 --- a/pkg/server/gateway.go +++ b/pkg/server/gateway.go @@ -146,17 +146,16 @@ func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) { continue // Ignoriere böswilligen Input } - // 🔒 SECURITY: Setze IMMER die korrekten IDs (überschreibe Client-Werte) - input := game.ClientInput{ - Type: inputType, - RoomID: roomID, // Server setzt den Raum (nicht Client!) - PlayerID: playerID, // Server setzt die Player-ID (nicht Client!) - } + // 🔒 SECURITY: Alle Input-Felder übernehmen, aber IDs immer vom Server setzen + // Remarshal des raw-Objekts in ClientInput um alle Felder (inkl. STATE-Felder) zu übernehmen + inputBytes, _ := json.Marshal(raw) + var input game.ClientInput + json.Unmarshal(inputBytes, &input) - // Sequence-Nummer vom Client übernehmen (für Client Prediction) - if seq, ok := raw["sequence"].(float64); ok { - input.Sequence = uint32(seq) - } + // Security-kritische Felder vom Server überschreiben (nie Client-Werten vertrauen) + input.Type = inputType + input.RoomID = roomID // Server setzt den Raum + input.PlayerID = playerID // Server setzt die Player-ID bytes, _ := json.Marshal(input) gw.NC.Publish(fmt.Sprintf("game.room.%s.input", roomID), bytes) diff --git a/pkg/server/room.go b/pkg/server/room.go index 4bbce4f..b2b530f 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -276,16 +276,49 @@ func (r *Room) HandleInput(input game.ClientInput) { } switch input.Type { + case "STATE": + // Vollständigen Input-State atomisch setzen – verhindert stuck-Inputs durch + // Paketverlust oder Reihenfolge-Probleme bei Event-basierten Nachrichten. + // Out-of-Order-Schutz: nur neuere States übernehmen + if input.Sequence <= p.LastInputSeq { + return + } + + // Richtung: JoyX hat Vorrang vor digitalen Tasten + if input.InputJoyX != 0 { + p.InputX = input.InputJoyX + } else if input.InputLeft && !input.InputRight { + p.InputX = -1 + } else if input.InputRight && !input.InputLeft { + p.InputX = 1 + } else { + p.InputX = 0 + } + + // Jump/Down: einmal setzen, nie löschen (Physics-Tick macht das) + if input.InputJump { + p.InputJump = true + // 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) + } + } + if input.InputDown { + p.InputDown = true + } + + // Legacy-Events (Rückwärtskompatibilität, werden vom neuen Client nicht mehr gesendet) case "JUMP": - p.InputJump = true // Setze Jump-Flag für Physik-Engine - // Double Jump spezial-Logik (außerhalb der Physik-Engine) + p.InputJump = true 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.InputDown = true // Setze Down-Flag für Fast Fall + p.InputDown = true case "LEFT_DOWN": p.InputX = -1 case "LEFT_UP": @@ -298,6 +331,7 @@ func (r *Room) HandleInput(input game.ClientInput) { if p.InputX == 1 { p.InputX = 0 } + case "START": if input.PlayerID == r.HostID && r.Status == "LOBBY" { r.StartCountdown()