Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/game_render.go
Sebastian Unterschütz 0aa81a2edc
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 6m51s
fix game
2026-03-22 17:26:18 +01:00

797 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
wantsJump := keyJump || g.btnJumpActive
g.btnJumpActive = false
g.btnDownActive = false
// Jump Buffer: Sprung-Wunsch für bis zu 6 Physics-Frames (=300ms) speichern
if wantsJump {
g.jumpBufferFrames = 6
}
// --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) ---
// Wichtig: Prediction und Send müssen synchron laufen.
// Nur wenn wir senden, speichern wir den Input in pendingInputs.
if g.connected && time.Since(g.lastInputTime) >= 50*time.Millisecond {
g.lastInputTime = time.Now()
// Coyote Time: War auf dem Boden, jetzt nicht mehr → Timer setzen
g.predictionMutex.Lock()
wasOnGround := g.predictedGround
g.predictionMutex.Unlock()
// Coyote Time Countdown
if g.coyoteFrames > 0 {
g.coyoteFrames--
}
// Jump Buffer Countdown
if g.jumpBufferFrames > 0 {
g.jumpBufferFrames--
}
// Effektiven Jump bestimmen:
// - Direkter Druck, ODER
// - Jump-Buffer aktiv UND jetzt auf dem Boden (Sprung kurz vor Landung)
g.predictionMutex.Lock()
onGround := g.predictedGround
g.predictionMutex.Unlock()
if wasOnGround && !onGround {
// Gerade von Kante abgegangen → Coyote Time starten
g.coyoteFrames = 4
}
effectiveJump := wantsJump ||
(g.jumpBufferFrames > 0 && onGround) ||
(wantsJump && g.coyoteFrames > 0)
if effectiveJump {
g.jumpBufferFrames = 0 // Buffer verbraucht
}
// Input State zusammenbauen
input := InputState{
Sequence: g.inputSequence,
Left: keyLeft || joyDir < -0.1,
Right: keyRight || joyDir > 0.1,
Jump: effectiveJump,
Down: keyDown || isJoyDown,
JoyX: joyDir,
}
g.predictionMutex.Lock()
// Position vor Physics-Step merken (für Interpolation)
g.prevPredictedX = g.predictedX
g.prevPredictedY = g.predictedY
g.lastPhysicsTime = time.Now()
// 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
// Cap: nie mehr als 60 unbestätigte Inputs ansammeln (~3 Sek bei 20/sek)
if len(g.pendingInputs) > 60 {
oldest := g.inputSequence - 60
for seq := range g.pendingInputs {
if seq < oldest {
delete(g.pendingInputs, seq)
}
}
}
g.predictionMutex.Unlock()
g.SendInputWithSequence(input)
}
// --- 5. KAMERA LOGIK (mit Smoothing) ---
g.stateMutex.Lock()
targetCam := g.gameState.ScrollX
g.stateMutex.Unlock()
if targetCam < 0 {
targetCam = 0
}
// Sanftes Kamera-Folgen: 20% pro Frame Richtung Ziel (bei 60fps ≈ 95ms Halbwertszeit)
diff := targetCam - g.camX
g.camX += diff * 0.2
// --- 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)
// Prediction-Snapshot VOR dem stateMutex holen (verhindert Deadlock:
// DrawGame würde sonst stateMutex→predictionMutex halten, während
// ReconcileWithServer predictionMutex→stateMutex hält)
g.predictionMutex.Lock()
snapPrevX := g.prevPredictedX
snapPrevY := g.prevPredictedY
snapPredX := g.predictedX
snapPredY := g.predictedY
snapOffX := g.correctionOffsetX
snapOffY := g.correctionOffsetY
snapPhysTime := g.lastPhysicsTime
g.predictionMutex.Unlock()
// 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: Client-Prediction mit Interpolation zwischen Physics-Steps
// Physics läuft bei 20/sec, Draw bei 60fps → alpha interpoliert dazwischen
if p.Name == g.playerName {
// Interpolations-Alpha: wie weit sind wir zwischen letztem und nächstem Physics-Step?
alpha := float64(time.Since(snapPhysTime)) / float64(50*time.Millisecond)
if alpha > 1 {
alpha = 1
}
posX = snapPrevX + (snapPredX-snapPrevX)*alpha + snapOffX
posY = snapPrevY + (snapPredY-snapPrevY)*alpha + snapOffY
}
// 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" {
// Danger-Overlay: Ab DifficultyFactor > 0.5 rötlicher Bildschirmrand
// stateMutex ist bereits seit Zeile 388 per defer gehalten kein erneuter Lock!
df := g.gameState.DifficultyFactor
if df > 0.5 {
// Alpha von 0 (bei df=0.5) bis 60 (bei df=1.0)
dangerAlpha := uint8((df - 0.5) * 2.0 * 60)
canvasWf, canvasHf := float32(canvasW), float32(canvasH)
borderW := float32(8)
col := color.RGBA{200, 0, 0, dangerAlpha}
vector.DrawFilledRect(screen, 0, 0, canvasWf, borderW, col, false)
vector.DrawFilledRect(screen, 0, canvasHf-borderW, canvasWf, borderW, col, false)
vector.DrawFilledRect(screen, 0, 0, borderW, canvasHf, col, false)
vector.DrawFilledRect(screen, canvasWf-borderW, 0, borderW, canvasHf, col, false)
}
// 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)
}