Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/game_render.go
Sebastian Unterschütz 188e9c2cc2
Some checks failed
Dynamic Branch Deploy / build-and-deploy (push) Failing after 8m17s
fix game
2026-03-21 20:32:53 +01:00

706 lines
22 KiB
Go

package main
import (
"fmt"
"image/color"
"log"
"math"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/basicfont"
)
// --- INPUT & UPDATE LOGIC ---
func (g *Game) UpdateGame() {
// --- 1. MUTE TOGGLE ---
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
g.audio.ToggleMute()
}
// --- 2. KEYBOARD INPUT ---
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
// Tastatur-Nutzung erkennen (für Mobile Controls ausblenden)
if keyLeft || keyRight || keyDown || keyJump {
g.keyboardUsed = true
}
// --- 3. TOUCH INPUT HANDLING ---
g.handleTouchInput()
// --- 4. INPUT STATE ERSTELLEN ---
joyDir := 0.0
if g.joyActive {
maxDist := g.joyRadius
if maxDist == 0 {
maxDist = 60.0 // Fallback
}
diffX := g.joyStickX - g.joyBaseX
joyDir = diffX / maxDist
if joyDir < -1.0 {
joyDir = -1.0
}
if joyDir > 1.0 {
joyDir = 1.0
}
// Deadzone
if joyDir > -0.15 && joyDir < 0.15 {
joyDir = 0
}
}
// Down: Joystick nach unten ziehen ODER Down-Button
isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > g.joyRadius*0.5
// Input State zusammenbauen
input := InputState{
Sequence: g.inputSequence,
Left: keyLeft || joyDir < -0.1,
Right: keyRight || joyDir > 0.1,
Jump: keyJump || g.btnJumpActive,
Down: keyDown || isJoyDown || g.btnDownActive,
JoyX: joyDir,
}
g.btnJumpActive = false
g.btnDownActive = false
// --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) ---
if g.connected {
g.predictionMutex.Lock()
// Sequenznummer erhöhen
g.inputSequence++
input.Sequence = g.inputSequence
// Lokale Prediction ausführen für sofortiges Feedback
g.ApplyInput(input)
// Input für History speichern (für Server-Reconciliation)
g.pendingInputs[input.Sequence] = input
g.predictionMutex.Unlock()
// Input an Server senden
g.SendInputWithSequence(input)
}
// --- 5. KAMERA LOGIK ---
g.stateMutex.Lock()
targetCam := g.gameState.ScrollX
g.stateMutex.Unlock()
// Negative Kamera verhindern
if targetCam < 0 {
targetCam = 0
}
// Kamera hart setzen
g.camX = targetCam
// --- 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) ---
g.DetectAndSpawnParticles()
}
// Verarbeitet Touch-Eingaben für Joystick und Buttons
func (g *Game) handleTouchInput() {
touches := ebiten.TouchIDs()
// Linke Hälfte = Joystick-Zone (55% der Breite, damit auf schmalen Screens etwas Platz bleibt)
halfW := float64(g.lastCanvasWidth) * 0.55
if halfW == 0 {
halfW = float64(ScreenWidth) * 0.55 // Fallback
}
joyRadius := g.joyRadius
if joyRadius == 0 {
joyRadius = 60.0 // Fallback
}
// Vorab: alle gerade neu gedrückten Touch-IDs sammeln
justPressed := inpututil.JustPressedTouchIDs()
isJustPressed := func(id ebiten.TouchID) bool {
for _, j := range justPressed {
if id == j {
return true
}
}
return false
}
// Reset wenn alle Finger weg
if len(touches) == 0 {
g.joyActive = false
g.btnJumpPressed = false
g.joyStickX = g.joyBaseX
g.joyStickY = g.joyBaseY
return
}
joyFound := false
for _, id := range touches {
x, y := ebiten.TouchPosition(id)
fx, fy := float64(x), float64(y)
if fx >= halfW {
// ── RECHTE SEITE: Jump und Down ──────────────────────────────────────
g.btnJumpPressed = true // visuelles Feedback solange Finger drauf
if isJustPressed(id) {
// Down-Button: Prüfen ob Finger in der Nähe des Down-Buttons
if g.downBtnR > 0 {
dx := fx - g.downBtnX
dy := fy - g.downBtnY
if dx*dx+dy*dy < g.downBtnR*g.downBtnR*1.5 {
g.btnDownActive = true
continue
}
}
// Sonst: Sprung
g.btnJumpActive = true
}
continue
}
// ── LINKE SEITE: Floating Joystick ───────────────────────────────────
// Floating = Basis springt zu der Stelle wo der Finger aufsetzt.
// Kein fester Ausgangspunkt nötig → komfortabler auf allen Screen-Größen.
if !g.joyActive {
g.joyActive = true
g.joyTouchID = id
g.joyBaseX = fx
g.joyBaseY = fy
g.joyStickX = fx
g.joyStickY = fy
}
if g.joyActive && id == g.joyTouchID {
joyFound = true
dx := fx - g.joyBaseX
dy := fy - g.joyBaseY
dist := math.Sqrt(dx*dx + dy*dy)
if dist > joyRadius {
scale := joyRadius / dist
dx *= scale
dy *= scale
}
g.joyStickX = g.joyBaseX + dx
g.joyStickY = g.joyBaseY + dy
}
}
if !joyFound {
g.joyActive = false
g.btnJumpPressed = false
g.joyStickX = g.joyBaseX
g.joyStickY = g.joyBaseY
}
}
// --- RENDERING LOGIC ---
func (g *Game) DrawGame(screen *ebiten.Image) {
// WICHTIG: GAMEOVER-Check ZUERST, bevor wir Locks holen!
g.stateMutex.Lock()
status := g.gameState.Status
g.stateMutex.Unlock()
if status == "GAMEOVER" {
// Game Over Screen - komplett separates Rendering ohne weitere Locks
g.stateMutex.Lock()
myScore := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
myScore = p.Score
break
}
}
g.stateMutex.Unlock()
// In WASM: HTML Game Over Screen anzeigen
if !g.scoreSubmitted {
g.submitScore() // submitScore() setzt g.scoreSubmitted intern
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
}
g.drawGameOverScreen(screen, myScore)
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
}
// State Locken für Datenzugriff
g.stateMutex.Lock()
// Prüfe ob Spieler tot ist
isDead := false
myScore := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
isDead = !p.IsAlive || p.IsSpectator
myScore = p.Score
break
}
}
g.stateMutex.Unlock()
// Canvas-Größe und Scale-Faktor
canvasW, canvasH := screen.Size()
viewScale := GetScaleFromHeight(canvasH)
// 1. Hintergrund (wechselt alle 5000 Punkte)
backgroundID := "background"
if myScore >= 10000 {
backgroundID = "background2"
} else if myScore >= 5000 {
backgroundID = "background1"
}
// Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe)
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
op := &ebiten.DrawImageOptions{}
bgW, bgH := bgImg.Size()
// 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 auf Canvas
scaledW := float64(bgW) * scale
scaledH := float64(bgH) * scale
offsetX := (float64(canvasW) - scaledW) / 2
offsetY := (float64(canvasH) - scaledH) / 2
op.GeoM.Translate(offsetX, offsetY)
screen.DrawImage(bgImg, op)
} else {
// Fallback: Einfarbiger Himmel
screen.Fill(ColSky)
}
// Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera)
// Mit viewScale multiplizieren damit auf Mobile mehr Welt sichtbar ist
effectiveCamX := g.camX / viewScale
g.RenderGround(screen, effectiveCamX)
// State Locken für Datenzugriff
g.stateMutex.Lock()
defer g.stateMutex.Unlock()
// Screen-Höhe für Y-Transformation (canvasH bereits oben definiert)
// _, canvasH = screen.Size() // nicht nötig, bereits definiert
// 2. Chunks (Welt-Objekte)
for _, activeChunk := range g.gameState.WorldChunks {
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
if !exists {
log.Printf("⚠️ Chunk '%s' nicht in Library gefunden!", activeChunk.ChunkID)
continue
}
// Start-Chunk hat absichtlich keine Objekte
for objIdx, obj := range chunkDef.Objects {
// Skip Moving Platforms - die werden separat gerendert
if obj.MovingPlatform != nil {
continue
}
// Prüfe ob Coin/Powerup bereits eingesammelt wurde
assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID]
if hasAsset {
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] {
// Coin wurde eingesammelt, nicht zeichnen
continue
}
if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] {
// Powerup wurde eingesammelt, nicht zeichnen
continue
}
}
// Asset zeichnen (mit Welt-zu-Screen-Y-Transformation)
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, canvasH))
}
}
// 2.5 Bewegende Plattformen (von Server synchronisiert)
for _, mp := range g.gameState.MovingPlatforms {
g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, canvasH))
}
// 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - NUR IM DEBUG-MODUS
if g.showDebug {
vector.StrokeRect(screen, float32(-g.camX), float32(WorldToScreenYWithHeight(540, canvasH)), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false)
}
// 3. Spieler
for id, p := range g.gameState.Players {
posX, posY := p.X, p.Y
vy := p.VY
onGround := p.OnGround
// 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 + g.correctionOffsetX
posY = g.predictedY + g.correctionOffsetY
g.predictionMutex.Unlock()
}
// Wähle Sprite basierend auf Sprung-Status
sprite := "player" // Default: am Boden
// Nur Jump-Animation wenn wirklich in der Luft
// Bei 20 TPS größerer Threshold (3.0 statt 1.0)
// OnGround oder sehr kleine VY = am Boden/Plattform
isInAir := !onGround && (vy < -3.0 || vy > 3.0)
if isInAir {
if vy < -5.0 {
// Springt nach oben
sprite = "jump0"
} else {
// Fällt oder höchster Punkt
sprite = "jump1"
}
}
// Konvertiere Welt-Y zu Screen-Y für korrektes Rendering
_, canvasH := screen.Size()
screenY := WorldToScreenYWithHeight(posY, canvasH)
g.DrawAsset(screen, sprite, posX, screenY)
// Name Tag
name := p.Name
if name == "" {
name = id
}
text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(screenY-25), ColText)
// HITBOX VISUALISIERUNG (NUR IM DEBUG-MODUS)
if g.showDebug {
if def, ok := g.world.Manifest.Assets["player"]; ok {
// Spieler-Hitbox (ROT) - mit Screen-Y-Transformation
hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
hy := float32(screenY + def.DrawOffY + def.Hitbox.OffsetY)
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) - mit Screen-Y-Transformation
vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false)
}
}
}
// 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, 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)
scoreStr := fmt.Sprintf("Score: %d", myScore)
// 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 (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 (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. DEBUG OVERLAY (F3 zum Umschalten)
if g.showDebug {
g.drawDebugOverlay(screen)
}
// 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
if !g.keyboardUsed {
tcW, tcH := screen.Size()
// Canvas-Größe cachen (für handleTouchInput im nächsten Frame)
g.lastCanvasHeight = tcH
g.lastCanvasWidth = tcW
floorY := GetFloorYFromHeight(tcH)
// Proportionale Größen: basiert auf der kleineren Screen-Dimension
refDim := math.Min(float64(tcW), float64(tcH))
joyR := refDim * 0.13 // Joystick-Außenring (13% der kleineren Dimension)
knobR := joyR * 0.48 // Knob ~halber Joystick-Radius
jumpR := refDim * 0.11 // Sprung-Button
downR := refDim * 0.08 // Down-Button (kleiner)
// Werte für Input-Verarbeitung cachen
g.joyRadius = joyR
// ── A) Floating Joystick ─────────────────────────────────────────────
// Wenn inaktiv: Basis an Default-Position (unten links) zeigen
if !g.joyActive {
g.joyBaseX = float64(tcW) * 0.18
g.joyBaseY = floorY - joyR - 12
g.joyStickX = g.joyBaseX
g.joyStickY = g.joyBaseY
}
// Joystick-Ring (halb transparent, nur wenn aktiv sichtbarer)
ringAlpha := uint8(40)
if g.joyActive {
ringAlpha = 70
}
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), color.RGBA{80, 80, 80, ringAlpha}, false)
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), float32(joyR), 2, color.RGBA{120, 120, 120, 90}, false)
// Joystick-Knob
knobCol := color.RGBA{180, 180, 180, 100}
if g.joyActive {
knobCol = color.RGBA{80, 220, 80, 160}
}
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), float32(knobR), knobCol, false)
// ── B) Jump Button (rechts) ──────────────────────────────────────────
jumpX := float64(tcW)*0.82
jumpY := floorY - jumpR - 12
jumpFill := color.RGBA{220, 50, 50, 60}
jumpStroke := color.RGBA{255, 80, 80, 130}
if g.btnJumpPressed {
jumpFill = color.RGBA{255, 80, 80, 130} // heller wenn gedrückt
jumpStroke = color.RGBA{255, 160, 160, 200}
}
vector.DrawFilledCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), jumpFill, false)
vector.StrokeCircle(screen, float32(jumpX), float32(jumpY), float32(jumpR), 2.5, jumpStroke, false)
text.Draw(screen, "JUMP", basicfont.Face7x13,
int(jumpX)-14, int(jumpY)+5, color.RGBA{255, 255, 255, 180})
// ── C) Down/FastFall Button (links vom Jump) ─────────────────────────
downX := float64(tcW)*0.62
downY := floorY - downR - 12
g.downBtnX = downX
g.downBtnY = downY
g.downBtnR = downR
vector.DrawFilledCircle(screen, float32(downX), float32(downY), float32(downR), color.RGBA{50, 120, 220, 55}, false)
vector.StrokeCircle(screen, float32(downX), float32(downY), float32(downR), 2, color.RGBA{80, 160, 255, 120}, false)
text.Draw(screen, "▼", basicfont.Face7x13,
int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180})
}
}
// --- ASSET HELPER ---
func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY float64) {
// 1. Definition laden
def, ok := g.world.Manifest.Assets[assetID]
if !ok {
return
}
// Canvas-Größe und View-Scale
canvasW, canvasH := screen.Size()
viewScale := GetScaleFromHeight(canvasH)
// 2. Screen Position berechnen (Welt - Kamera, mit Scale)
effectiveCamX := g.camX / viewScale
screenX := (worldX - effectiveCamX) * viewScale
screenY := worldY
// Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden)
// Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px)
if screenX < -800 || screenX > float64(canvasW)+800 {
return
}
// 3. Bild holen
img := g.assetsImages[assetID]
if img != nil {
op := &ebiten.DrawImageOptions{}
// Filter für bessere Skalierung (besonders bei großen Sprites)
op.Filter = ebiten.FilterLinear
// Skalieren: Asset-Scale * View-Scale (auf Mobile kleiner)
finalScale := def.Scale * viewScale
op.GeoM.Scale(finalScale, finalScale)
// Positionieren: ScreenPos + DrawOffset (auch skaliert)
op.GeoM.Translate(
screenX+(def.DrawOffX*viewScale),
screenY+(def.DrawOffY*viewScale),
)
// Farbe anwenden (nur wenn explizit gesetzt)
// Wenn Color leer ist (R=G=B=A=0), nicht anwenden (Bild bleibt original)
if def.Color.R != 0 || def.Color.G != 0 || def.Color.B != 0 || def.Color.A != 0 {
op.ColorScale.ScaleWithColor(def.Color.ToRGBA())
}
screen.DrawImage(img, op)
} else {
// FALLBACK (Buntes Rechteck)
vector.DrawFilledRect(screen,
float32(screenX+def.Hitbox.OffsetX),
float32(screenY+def.Hitbox.OffsetY),
float32(def.Hitbox.W),
float32(def.Hitbox.H),
def.Color.ToRGBA(),
false,
)
}
}
// 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)
}