Private
Public Access
1
0

fix game
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 8m20s

This commit is contained in:
Sebastian Unterschütz
2026-03-21 13:31:34 +01:00
parent 78742fc1c4
commit f48ade50bb
6 changed files with 130 additions and 83 deletions

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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()