Private
Public Access
1
0

Integrate shared physics engine for player movement and collision handling, refine 20 TPS gameplay logic, and enhance client prediction with server-reconciliation updates.
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 7m51s

This commit is contained in:
Sebastian Unterschütz
2026-01-06 21:37:32 +01:00
parent 23d42d42e7
commit 023996229a
13 changed files with 685 additions and 251 deletions

View File

@@ -88,9 +88,17 @@ func (g *Game) wsReadPump() {
// Out-of-Order-Erkennung: Ignoriere alte Updates
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
// Alte Nachricht - ignorieren
g.outOfOrderCount++
continue
}
g.lastRecvSeq = state.Sequence
g.totalUpdates++
g.lastUpdateTime = time.Now()
// Aktualisiere CurrentSpeed für Client-Prediction
g.predictionMutex.Lock()
g.currentSpeed = state.CurrentSpeed
g.predictionMutex.Unlock()
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {

View File

@@ -75,9 +75,17 @@ func (g *Game) connectToServer() {
// Out-of-Order-Erkennung: Ignoriere alte Updates
if state.Sequence > 0 && state.Sequence <= g.lastRecvSeq {
// Alte Nachricht - ignorieren
g.outOfOrderCount++
return nil
}
g.lastRecvSeq = state.Sequence
g.totalUpdates++
g.lastUpdateTime = time.Now()
// Aktualisiere CurrentSpeed für Client-Prediction
g.predictionMutex.Lock()
g.currentSpeed = state.CurrentSpeed
g.predictionMutex.Unlock()
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {

View File

@@ -5,6 +5,7 @@ import (
"image/color"
"log"
"math"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
@@ -71,45 +72,18 @@ func (g *Game) UpdateGame() {
}
g.btnJumpActive = false
// --- 4. CLIENT PREDICTION ---
// --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) ---
if g.connected {
g.predictionMutex.Lock()
// Sequenznummer erhöhen
g.inputSequence++
input.Sequence = g.inputSequence
// Input speichern für später Reconciliation
g.pendingInputs[input.Sequence] = input
// Lokale Physik sofort anwenden (Prediction)
// Lokale Prediction ausführen für sofortiges Feedback
g.ApplyInput(input)
// Sanfte Korrektur anwenden (langsamer bei 20 TPS für weniger Jitter)
const smoothingFactor = 0.15 // Reduziert für 20 TPS (war 0.4 bei 60 TPS)
if g.correctionX != 0 || g.correctionY != 0 {
g.predictedX += g.correctionX * smoothingFactor
g.predictedY += g.correctionY * smoothingFactor
g.correctionX *= (1.0 - smoothingFactor)
g.correctionY *= (1.0 - smoothingFactor)
// Korrektur beenden wenn sehr klein
if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 1.0 {
g.correctionX = 0
g.correctionY = 0
}
}
// Landing Detection für Partikel
if !g.lastGroundState && g.predictedGround {
// Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen)
// Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H
// = Y - 231 + 42 + 184 = Y - 5
feetY := g.predictedY - 231 + 42 + 184
centerX := g.predictedX - 56 + 68 + 73/2
g.SpawnLandingParticles(centerX, feetY)
}
g.lastGroundState = g.predictedGround
// Input für History speichern (für Server-Reconciliation)
g.pendingInputs[input.Sequence] = input
g.predictionMutex.Unlock()
@@ -231,7 +205,7 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
// In WASM: HTML Game Over Screen anzeigen
if !g.scoreSubmitted {
g.submitScore() // submitScore() setzt g.scoreSubmitted intern
g.submitScore() // submitScore() setzt g.scoreSubmitted intern
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
}
@@ -262,23 +236,26 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
backgroundID = "background1"
}
// Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
// Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe)
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
op := &ebiten.DrawImageOptions{}
// Skalierung berechnen, um Bildschirm zu füllen
// Tatsächliche Canvas-Größe verwenden (nicht nur ScreenWidth/Height)
canvasW, canvasH := screen.Size()
bgW, bgH := bgImg.Size()
scaleX := float64(ScreenWidth) / float64(bgW)
scaleY := float64(ScreenHeight) / float64(bgH)
// Skalierung berechnen, um Canvas komplett zu füllen
scaleX := float64(canvasW) / float64(bgW)
scaleY := float64(canvasH) / float64(bgH)
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
op.GeoM.Scale(scale, scale)
// Zentrieren
// Zentrieren auf Canvas
scaledW := float64(bgW) * scale
scaledH := float64(bgH) * scale
offsetX := (float64(ScreenWidth) - scaledW) / 2
offsetY := (float64(ScreenHeight) - scaledH) / 2
offsetX := (float64(canvasW) - scaledW) / 2
offsetY := (float64(canvasH) - scaledH) / 2
op.GeoM.Translate(offsetX, offsetY)
screen.DrawImage(bgImg, op)
@@ -336,26 +313,22 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
}
// 3. Spieler
// MyID ohne Lock holen (wir haben bereits den stateMutex)
myID := ""
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
myID = id
break
}
}
// 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - UNTER dem Gras bis tief in die Erde
vector.StrokeRect(screen, float32(-g.camX), float32(540), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false)
// 3. Spieler
for id, p := range g.gameState.Players {
// Für lokalen Spieler: Verwende vorhergesagte Position
posX, posY := p.X, p.Y
vy := p.VY
onGround := p.OnGround
if id == myID && g.connected {
// Für lokalen Spieler: Verwende Client-Prediction Position
// Die Reconciliation wird in ReconcileWithServer() (connection_*.go) gemacht
if p.Name == g.playerName {
g.predictionMutex.Lock()
posX = g.predictedX
posY = g.predictedY
vy = g.predictedVY
onGround = g.predictedGround
g.predictionMutex.Unlock()
}
// Wähle Sprite basierend auf Sprung-Status
@@ -385,82 +358,98 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
}
text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(posY-25), ColText)
// DEBUG: Rote Hitbox
// HITBOX VISUALISIERUNG (IMMER SICHTBAR)
if def, ok := g.world.Manifest.Assets["player"]; ok {
// Spieler-Hitbox (ROT)
hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
hy := float32(posY + def.DrawOffY + def.Hitbox.OffsetY)
vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 2, color.RGBA{255, 0, 0, 255}, false)
vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 3, color.RGBA{255, 0, 0, 255}, false)
// Spieler-Position als Punkt (GELB)
vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(posY), 5, color.RGBA{255, 255, 0, 255}, false)
}
}
// 4. UI Status
// 4. UI Status (Canvas-relativ)
canvasW, canvasH := screen.Size()
if g.gameState.Status == "COUNTDOWN" {
msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft)
text.Draw(screen, msg, basicfont.Face7x13, ScreenWidth/2-40, ScreenHeight/2, color.RGBA{255, 255, 0, 255})
text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255})
} else if g.gameState.Status == "RUNNING" {
// Score/Distance Anzeige mit grauem Hintergrund (oben rechts)
dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
text.Draw(screen, dist, basicfont.Face7x13, ScreenWidth-150, 30, ColText)
// Score anzeigen
scoreStr := fmt.Sprintf("Score: %d", myScore)
text.Draw(screen, scoreStr, basicfont.Face7x13, ScreenWidth-150, 50, ColText)
// Berechne Textbreiten für dynamische Box-Größe
distLen := len(dist) * 7 // ~7px pro Zeichen
scoreLen := len(scoreStr) * 7
maxWidth := distLen
if scoreLen > maxWidth {
maxWidth = scoreLen
}
boxWidth := float32(maxWidth + 20) // 10px Padding links/rechts
boxHeight := float32(50)
boxX := float32(canvasW) - boxWidth - 10 // 10px vom rechten Rand
boxY := float32(10) // 10px vom oberen Rand
// Grauer halbtransparenter Hintergrund
vector.DrawFilledRect(screen, boxX, boxY, boxWidth, boxHeight, color.RGBA{60, 60, 60, 200}, false)
vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2, color.RGBA{100, 100, 100, 255}, false)
// Text (zentriert in Box)
textX := int(boxX) + 10
text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.RGBA{255, 255, 255, 255})
text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255})
// Spectator Overlay wenn tot
if isDead {
// Halbtransparenter roter Overlay
vector.DrawFilledRect(screen, 0, 0, ScreenWidth, 80, color.RGBA{150, 0, 0, 180}, false)
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, ScreenWidth/2-140, 30, color.White)
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, ScreenWidth/2-90, 55, color.RGBA{255, 255, 0, 255})
// Halbtransparenter roter Overlay (volle Canvas-Breite)
vector.DrawFilledRect(screen, 0, 0, float32(canvasW), 80, color.RGBA{150, 0, 0, 180}, false)
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, canvasW/2-140, 30, color.White)
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, canvasW/2-90, 55, color.RGBA{255, 255, 0, 255})
}
}
// 5. DEBUG: TODES-LINIE
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
// 5. DEBUG: TODES-LINIE (volle Canvas-Höhe)
vector.StrokeLine(screen, 0, 0, 0, float32(canvasH), 10, color.RGBA{255, 0, 0, 128}, false)
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, canvasH/2, color.RGBA{255, 0, 0, 255})
// 6. PARTIKEL RENDERN (vor UI)
g.RenderParticles(screen)
// 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
if !g.keyboardUsed {
// A) Joystick Base (dunkelgrau und durchsichtig)
baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false)
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
// 7. DEBUG OVERLAY (F3 zum Umschalten)
if g.showDebug {
g.drawDebugOverlay(screen)
}
// B) Joystick Knob (dunkelgrau, außer wenn aktiv)
knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
// 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
if !g.keyboardUsed {
canvasW, canvasH := screen.Size()
// A) Joystick Base (unten links, relativ zu Canvas)
joyX := 150.0
joyY := float64(canvasH) - 150.0
baseCol := color.RGBA{80, 80, 80, 50}
vector.DrawFilledCircle(screen, float32(joyX), float32(joyY), 60, baseCol, false)
vector.StrokeCircle(screen, float32(joyX), float32(joyY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
// B) Joystick Knob (relativ zu Base, nicht zu Canvas)
knobCol := color.RGBA{100, 100, 100, 80}
if g.joyActive {
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
knobCol = color.RGBA{100, 255, 100, 120}
}
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
// C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung)
jumpX := float32(ScreenWidth - 150)
jumpY := float32(ScreenHeight - 150)
// C) Jump Button (unten rechts, relativ zu Canvas)
jumpX := float32(canvasW) - 150
jumpY := float32(canvasH) - 150
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false)
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
}
// 8. DEBUG INFO (Oben Links)
myPosStr := "N/A"
for _, p := range g.gameState.Players {
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
break
}
debugMsg := fmt.Sprintf(
"FPS: %.2f\nState: %s\nPlayers: %d\nCamX: %.0f\nPos: %s",
ebiten.CurrentFPS(),
g.gameState.Status,
len(g.gameState.Players),
g.camX,
myPosStr,
)
vector.DrawFilledRect(screen, 10, 10, 200, 90, color.RGBA{0, 0, 0, 180}, false)
text.Draw(screen, debugMsg, basicfont.Face7x13, 20, 30, color.White)
}
// --- ASSET HELPER ---
@@ -476,8 +465,10 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl
screenX := worldX - g.camX
screenY := worldY
// Optimierung: Nicht zeichnen, wenn komplett außerhalb
if screenX < -200 || screenX > ScreenWidth+200 {
// Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden)
// Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px)
canvasW, _ := screen.Size()
if screenX < -800 || screenX > float64(canvasW)+800 {
return
}
@@ -519,3 +510,90 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl
}
}
// drawDebugOverlay zeigt Performance und Network Stats (F3 zum Umschalten)
func (g *Game) drawDebugOverlay(screen *ebiten.Image) {
// Hintergrund (halbtransparent)
vector.DrawFilledRect(screen, 10, 80, 350, 170, color.RGBA{0, 0, 0, 180}, false)
vector.StrokeRect(screen, 10, 80, 350, 170, 2, color.RGBA{255, 255, 0, 255}, false)
y := 95
lineHeight := 15
// Titel
text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255})
y += lineHeight + 5
// FPS
fpsColor := color.RGBA{0, 255, 0, 255}
if g.currentFPS < 15 {
fpsColor = color.RGBA{255, 0, 0, 255}
} else if g.currentFPS < 30 {
fpsColor = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsColor)
y += lineHeight
// Server Update Latenz
updateAge := time.Since(g.lastUpdateTime).Milliseconds()
latencyColor := color.RGBA{0, 255, 0, 255}
if updateAge > 200 {
latencyColor = color.RGBA{255, 0, 0, 255}
} else if updateAge > 100 {
latencyColor = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyColor)
y += lineHeight
// Network Stats
text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White)
y += lineHeight
oooColor := color.RGBA{0, 255, 0, 255}
if g.outOfOrderCount > 10 {
oooColor = color.RGBA{255, 165, 0, 255}
}
if g.outOfOrderCount > 50 {
oooColor = color.RGBA{255, 0, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooColor)
y += lineHeight
// Packet Loss Rate
if g.totalUpdates > 0 {
lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100
lossColor := color.RGBA{0, 255, 0, 255}
if lossRate > 10 {
lossColor = color.RGBA{255, 0, 0, 255}
} else if lossRate > 5 {
lossColor = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossColor)
y += lineHeight
}
// Client Prediction Stats
text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White)
y += lineHeight
corrColor := color.RGBA{0, 255, 0, 255}
if g.correctionCount > 100 {
corrColor = color.RGBA{255, 165, 0, 255}
}
if g.correctionCount > 500 {
corrColor = color.RGBA{255, 0, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrColor)
y += lineHeight
// Current Correction Magnitude
corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY)
if corrMag > 0.1 {
text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, color.RGBA{255, 165, 0, 255})
} else {
text.Draw(screen, "Corr Mag: 0.0", basicfont.Face7x13, 20, y, color.RGBA{0, 255, 0, 255})
}
y += lineHeight
// Server Sequence
text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White)
}

View File

@@ -58,9 +58,9 @@ func GenerateGroundTile(tileIdx int) GroundTile {
Stones: make([]Stone, 0),
}
// Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe)
numDirt := 15 + rng.Intn(10)
dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe
// Zufällige Dirt-Patches generieren (20-30 pro Tile, über die ganze Höhe)
numDirt := 20 + rng.Intn(10)
dirtHeight := 5000.0 // Gesamte Dirt-Höhe bis tief in die Erde
for i := 0; i < numDirt; i++ {
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
@@ -72,7 +72,17 @@ func GenerateGroundTile(tileIdx int) GroundTile {
})
}
// Keine Steine mehr auf dem Gras
// Steine IN der Erde generieren (10-20 pro Tile, tief verteilt)
numStones := 10 + rng.Intn(10)
for i := 0; i < numStones; i++ {
tile.Stones = append(tile.Stones, Stone{
X: rng.Float64() * 128,
Y: rng.Float64()*dirtHeight + 20, // Tief in der Erde verteilt
Size: 4 + rng.Float64()*8, // Verschiedene Größen
Color: color.RGBA{100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 100 + uint8(rng.Intn(50)), 255},
Shape: rng.Intn(2), // 0=rund, 1=eckig
})
}
// In Cache speichern
groundCache[tileIdx] = tile
@@ -81,20 +91,25 @@ func GenerateGroundTile(tileIdx int) GroundTile {
// RenderGround rendert den Boden mit Bewegung
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
// Tatsächliche Canvas-Größe verwenden
canvasW, _ := screen.Size()
// Boden bleibt an fester Position (RefFloorY) - wichtig für Spielphysik!
// Erweitere Boden nach unten weit über Canvas-Rand hinaus (5000 Pixel tief)
floorY := float32(RefFloorY)
floorH := float32(ScreenHeight - RefFloorY)
floorH := float32(5000) // Tief in die Erde
// 1. Basis Gras-Schicht
vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false)
// 1. Basis Gras-Schicht (volle Canvas-Breite, nur dünne Grasnarbe)
vector.DrawFilledRect(screen, 0, floorY, float32(canvasW), 20, ColGrass, false)
// 2. Dirt-Schicht (Basis)
vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false)
// 2. Dirt-Schicht (Basis, volle Canvas-Breite, tief nach unten)
vector.DrawFilledRect(screen, 0, floorY+20, float32(canvasW), floorH-20, ColDirt, false)
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
// Berechne welche Tiles sichtbar sind
// Berechne welche Tiles sichtbar sind (basierend auf Canvas-Breite)
tileWidth := 128.0
startTile := int(math.Floor(cameraX / tileWidth))
endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth))
endTile := int(math.Ceil((cameraX + float64(canvasW)) / tileWidth))
// Tiles rendern
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
@@ -106,8 +121,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
// Nur rendern wenn im sichtbaren Bereich
if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) {
// Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden)
if screenX+float32(dirt.Width) > 0 && screenX < float32(canvasW) {
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
}
}
@@ -118,8 +133,8 @@ func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(stone.Y)
// Nur rendern wenn im sichtbaren Bereich
if screenX > -20 && screenX < float32(ScreenWidth)+20 {
// Nur rendern wenn im sichtbaren Bereich (Canvas-Breite verwenden)
if screenX > -20 && screenX < float32(canvasW)+20 {
if stone.Shape == 0 {
// Runder Stein
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)

View File

@@ -88,11 +88,14 @@ type Game struct {
predictedVX float64
predictedVY float64
predictedGround bool
predictedOnWall bool
currentSpeed float64 // Aktuelle Scroll-Geschwindigkeit vom Server
inputSequence uint32 // Sequenznummer für Inputs
pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
predictionMutex sync.Mutex // Mutex für pendingInputs
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
@@ -119,6 +122,18 @@ type Game struct {
joyTouchID ebiten.TouchID
btnJumpActive bool
keyboardUsed bool // Wurde Tastatur benutzt?
// Debug Stats
showDebug bool // Debug-Overlay anzeigen (F3 zum Umschalten)
fpsCounter int // Frame-Zähler
fpsSampleTime time.Time // Letzter FPS-Sample
currentFPS float64 // Aktuelle FPS
lastUpdateTime time.Time // Letzte Server-Update Zeit
updateLatency float64 // Latenz zum letzten Update (ms)
correctionCount int // Anzahl der Korrekturen
outOfOrderCount int // Anzahl verworfener Out-of-Order Pakete
totalUpdates int // Gesamtzahl empfangener Updates
pendingInputCount int // Anzahl pending Inputs
}
func NewGame() *Game {
@@ -142,6 +157,10 @@ func NewGame() *Game {
// Audio System
audio: NewAudioSystem(),
// Debug Stats
fpsSampleTime: time.Now(),
lastUpdateTime: time.Now(),
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150,
}
@@ -161,6 +180,24 @@ func NewGame() *Game {
// --- UPDATE ---
func (g *Game) Update() error {
// FPS Tracking
g.fpsCounter++
if time.Since(g.fpsSampleTime) >= time.Second {
g.currentFPS = float64(g.fpsCounter) / time.Since(g.fpsSampleTime).Seconds()
g.fpsCounter = 0
g.fpsSampleTime = time.Now()
}
// Debug Toggle (F3)
if inpututil.IsKeyJustPressed(ebiten.KeyF3) {
g.showDebug = !g.showDebug
}
// Pending Inputs zählen für Debug
g.predictionMutex.Lock()
g.pendingInputCount = len(g.pendingInputs)
g.predictionMutex.Unlock()
// Game Over Handling
if g.appState == StateGame && g.gameState.Status == "GAMEOVER" {
// Back Button (oben links) - Touch Support
@@ -594,7 +631,10 @@ func (g *Game) DrawLobby(screen *ebiten.Image) {
text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText)
}
func (g *Game) Layout(w, h int) (int, int) { return ScreenWidth, ScreenHeight }
func (g *Game) Layout(w, h int) (int, int) {
// Nutze die GESAMTE Bildschirmfläche ohne Einschränkungen
return w, h
}
// --- HELPER ---

View File

@@ -1,11 +1,12 @@
package main
import (
"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
@@ -20,39 +21,42 @@ func (g *Game) ApplyInput(input InputState) {
moveX = input.JoyX
}
// Geschwindigkeit skaliert mit Joystick-Intensität
// Bewegung relativ zum Scroll (symmetrisch)
speed := config.RunSpeed + (moveX * config.PlayerSpeed)
g.predictedX += speed
// Gravitation
g.predictedVY += config.Gravity
if g.predictedVY > config.MaxFall {
g.predictedVY = config.MaxFall
// Physik-State vorbereiten
state := physics.PlayerPhysicsState{
X: g.predictedX,
Y: g.predictedY,
VX: g.predictedVX,
VY: g.predictedVY,
OnGround: g.predictedGround,
OnWall: g.predictedOnWall,
}
// Fast Fall
if input.Down {
g.predictedVY = config.FastFall
// Physik-Input vorbereiten
physicsInput := physics.PhysicsInput{
InputX: moveX,
Jump: input.Jump,
Down: input.Down,
}
// Sprung
if input.Jump && g.predictedGround {
g.predictedVY = -config.JumpVelocity
g.predictedGround = false
// Kollisions-Checker vorbereiten
g.stateMutex.Lock()
collisionChecker := &physics.ClientCollisionChecker{
World: g.world,
ActiveChunks: g.gameState.WorldChunks,
MovingPlatforms: g.gameState.MovingPlatforms,
}
g.stateMutex.Unlock()
// Vertikale Bewegung
g.predictedY += g.predictedVY
// Gemeinsame Physik anwenden (1:1 wie Server)
physics.ApplyPhysics(&state, physicsInput, g.currentSpeed, collisionChecker, physics.DefaultPlayerConstants())
// Einfache Boden-Kollision (hardcoded für jetzt)
if g.predictedY >= 540 {
g.predictedY = 540
g.predictedVY = 0
g.predictedGround = true
} else {
g.predictedGround = false
}
// 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
@@ -70,14 +74,15 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
}
}
// Temporäre Position für Replay
// 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
// 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 {
@@ -85,12 +90,14 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
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)
@@ -99,6 +106,7 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
replayVX = g.predictedVX
replayVY = g.predictedVY
replayGround = g.predictedGround
replayOnWall = g.predictedOnWall
// Zurücksetzen
g.predictedX = oldX
@@ -106,25 +114,44 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
g.predictedVX = oldVX
g.predictedVY = oldVY
g.predictedGround = oldGround
g.predictedOnWall = oldOnWall
}
}
}
// Berechne Differenz zwischen aktueller Prediction und Server-Replay
// Berechne Differenz zwischen Client-Prediction und Server-Replay (X und Y)
diffX := replayX - g.predictedX
diffY := replayY - g.predictedY
dist := diffX*diffX + diffY*diffY
// Nur korrigieren wenn Differenz signifikant
// Bei 20 TPS größerer Threshold wegen größerer normaler Abweichungen
const threshold = 5.0 // Erhöht für 20 TPS (war 2.0)
if diffX*diffX+diffY*diffY > threshold*threshold {
// Speichere Korrektur für sanfte Interpolation
g.correctionX = diffX
g.correctionY = diffY
// Speichere Korrektur-Magnitude für Debug
g.correctionX = diffX
g.correctionY = diffY
// Bei sehr kleinen Abweichungen (<2px): Sofort korrigieren um Drift zu vermeiden
if dist < 4.0 { // 2px threshold
g.predictedX = replayX
g.predictedY = replayY
} else if dist > 100*100 {
// Bei sehr großen Abweichungen (>100px): Sofort korrigieren (Teleport/Respawn)
g.predictedX = replayX
g.predictedY = replayY
g.correctionCount++
} else if dist > 1.0 {
// Bei normalen Abweichungen: Sanfte Interpolation
// Bei 20 TPS: Aggressivere Interpolation
interpFactor := 0.5 // 50% pro Tick
if dist > 50*50 {
interpFactor = 0.8 // 80% bei großen Abweichungen
}
g.predictedX += diffX * interpFactor
g.predictedY += diffY * interpFactor
g.correctionCount++
}
// Velocity und Ground immer sofort übernehmen
// Velocity und Ground Status vom Server übernehmen
g.predictedVX = replayVX
g.predictedVY = replayVY
g.predictedGround = replayGround
g.predictedOnWall = replayOnWall
}

Binary file not shown.

View File

@@ -1,7 +1,7 @@
@font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')}
body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;font-family:'Press Start 2P',cursive;font-size:14px}
#game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000}
canvas{position:fixed!important;top:0!important;left:0!important;width:100%!important;height:100%!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden}
canvas{position:fixed!important;top:50%!important;left:50%!important;transform:translate(-50%,-50%)!important;width:100%!important;height:100%!important;max-width:100vw!important;max-height:100vh!important;object-fit:contain!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden}
canvas.game-active{opacity:1;pointer-events:auto;z-index:2000!important;visibility:visible}
.overlay-screen{position:fixed!important;top:0;left:0;width:100%;height:100%;background:url('background.jpg') center/cover no-repeat,rgba(0,0,0,.85);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px}
.overlay-screen.hidden{display:none!important}