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

777 lines
23 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"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// --- KONSTANTEN ---
const (
physicsStep = 50 * time.Millisecond // Server-Physics-Rate (20 TPS)
jumpBufferFrames = 6 // Sprung-Buffer: ~300ms bei 20 TPS
coyoteTimeFrames = 4 // Coyote Time: ~200ms bei 20 TPS
inputCap = 60 // Max unbestätigte Inputs (~3s bei 20 TPS)
joystickDeadzone = 0.15 // Minimale Joystick-Auslenkung
joyDownThreshold = 0.5 // Joystick-Down-Schwellenwert (Anteil am Radius)
correctionDecay = 0.85 // Visueller Korrektur-Decay pro Frame
// Touch-Control Größen (relativ zur kleineren Screendimension)
joyRadiusScale = 0.13 // Joystick-Außenring
joyKnobRatio = 0.48 // Knob ~halb so groß wie Ring
jumpBtnScale = 0.11 // Sprung-Button
downBtnScale = 0.08 // Down-Button
// Touch-Control Positionen (X als Anteil der Canvas-Breite)
joyDefaultXRatio = 0.18
jumpBtnXRatio = 0.82
downBtnXRatio = 0.62
// Culling-Puffer: Objekte die ±800px außerhalb des Canvas liegen werden übersprungen
cullingBuffer = 800.0
)
// --- RENDER SNAPSHOT ---
// renderSnapshot hält eine Momentaufnahme aller für DrawGame benötigten Daten.
// Beide Mutexe werden kurz gehalten, um den Snapshot zu befüllen, und dann
// sofort freigegeben so gibt es keine Lock-Verschachtelung beim Zeichnen.
type renderSnapshot struct {
// Canvas
canvasW, canvasH int
viewScale float64
// Lokaler Spieler
isDead bool
myScore int
// Spielzustand (aus stateMutex)
status string
timeLeft int
difficultyFactor float64
chunks []game.ActiveChunk
movingPlatforms []game.MovingPlatformSync
collectedCoins map[string]bool
collectedPowerups map[string]bool
players map[string]game.PlayerState
// Client-Prediction (aus predictionMutex)
prevPredX, prevPredY float64
predX, predY float64
offsetX, offsetY float64
physicsTime time.Time
}
// takeRenderSnapshot liest alle benötigten Daten unter den jeweiligen Mutexen
// und gibt einen lock-freien Snapshot zurück.
func (g *Game) takeRenderSnapshot(screen *ebiten.Image) renderSnapshot {
canvasW, canvasH := screen.Size()
snap := renderSnapshot{
canvasW: canvasW,
canvasH: canvasH,
viewScale: GetScaleFromHeight(canvasH),
}
// Prediction-Daten (kurzer Lock)
g.predictionMutex.Lock()
snap.prevPredX = g.prevPredictedX
snap.prevPredY = g.prevPredictedY
snap.predX = g.predictedX
snap.predY = g.predictedY
snap.offsetX = g.correctionOffsetX
snap.offsetY = g.correctionOffsetY
snap.physicsTime = g.lastPhysicsTime
g.predictionMutex.Unlock()
// Spielzustand (kurzer Lock)
g.stateMutex.Lock()
snap.status = g.gameState.Status
snap.timeLeft = g.gameState.TimeLeft
snap.difficultyFactor = g.gameState.DifficultyFactor
snap.chunks = g.gameState.WorldChunks
snap.movingPlatforms = g.gameState.MovingPlatforms
snap.collectedCoins = g.gameState.CollectedCoins
snap.collectedPowerups = g.gameState.CollectedPowerups
snap.players = g.gameState.Players
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
snap.isDead = !p.IsAlive || p.IsSpectator
snap.myScore = p.Score
break
}
}
g.stateMutex.Unlock()
return snap
}
// --- 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)
if keyLeft || keyRight || keyDown || keyJump {
g.keyboardUsed = true
}
// --- 3. TOUCH INPUT HANDLING ---
g.handleTouchInput()
// --- 4. JOYSTICK RICHTUNG BERECHNEN ---
joyDir := 0.0
if g.joyActive {
maxDist := g.joyRadius
if maxDist == 0 {
maxDist = 60.0
}
joyDir = (g.joyStickX - g.joyBaseX) / maxDist
if joyDir < -1.0 {
joyDir = -1.0
} else if joyDir > 1.0 {
joyDir = 1.0
}
if joyDir > -joystickDeadzone && joyDir < joystickDeadzone {
joyDir = 0
}
}
isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > g.joyRadius*joyDownThreshold
wantsJump := keyJump || g.btnJumpActive
g.btnJumpActive = false
g.btnDownActive = false
// Jump Buffer: Sprung-Wunsch für bis zu jumpBufferFrames speichern
if wantsJump {
g.jumpBufferFrames = jumpBufferFrames
}
// --- 5. INPUT SENDEN (MIT CLIENT PREDICTION, 20 TPS) ---
if g.connected && time.Since(g.lastInputTime) >= physicsStep {
g.lastInputTime = time.Now()
g.predictionMutex.Lock()
wasOnGround := g.predictedGround
g.predictionMutex.Unlock()
if g.coyoteFrames > 0 {
g.coyoteFrames--
}
if g.jumpBufferFrames > 0 {
g.jumpBufferFrames--
}
g.predictionMutex.Lock()
onGround := g.predictedGround
g.predictionMutex.Unlock()
if wasOnGround && !onGround {
g.coyoteFrames = coyoteTimeFrames
}
effectiveJump := wantsJump ||
(g.jumpBufferFrames > 0 && onGround) ||
(wantsJump && g.coyoteFrames > 0)
if effectiveJump {
g.jumpBufferFrames = 0
}
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()
g.prevPredictedX = g.predictedX
g.prevPredictedY = g.predictedY
g.lastPhysicsTime = time.Now()
g.inputSequence++
input.Sequence = g.inputSequence
g.ApplyInput(input)
g.pendingInputs[input.Sequence] = input
if len(g.pendingInputs) > inputCap {
oldest := g.inputSequence - inputCap
for seq := range g.pendingInputs {
if seq < oldest {
delete(g.pendingInputs, seq)
}
}
}
g.predictionMutex.Unlock()
g.SendInputWithSequence(input)
}
// --- 6. KAMERA LOGIK (mit Smoothing) ---
g.stateMutex.Lock()
targetCam := g.gameState.ScrollX
g.stateMutex.Unlock()
if targetCam < 0 {
targetCam = 0
}
g.camX += (targetCam - g.camX) * 0.2
// --- 7. CORRECTION OFFSET ABKLINGEN ---
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()
// --- 8. PARTIKEL ---
g.UpdateParticles(1.0 / 60.0)
g.DetectAndSpawnParticles()
}
// handleTouchInput verarbeitet Touch-Eingaben für Joystick und Buttons.
func (g *Game) handleTouchInput() {
touches := ebiten.TouchIDs()
halfW := float64(g.lastCanvasWidth) * 0.55
if halfW == 0 {
halfW = float64(ScreenWidth) * 0.55
}
joyRadius := g.joyRadius
if joyRadius == 0 {
joyRadius = 60.0
}
justPressed := inpututil.JustPressedTouchIDs()
isJustPressed := func(id ebiten.TouchID) bool {
for _, j := range justPressed {
if id == j {
return true
}
}
return false
}
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
if isJustPressed(id) {
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
}
}
g.btnJumpActive = true
}
continue
}
// ── LINKE SEITE: Floating Joystick ───────────────────────────────────
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 ---
// DrawGame ist der zentrale Render-Einstiegspunkt für den Spielzustand.
// Es nimmt einen lock-freien Snapshot aller benötigten Daten und delegiert
// das Zeichnen an spezialisierte Sub-Funktionen.
func (g *Game) DrawGame(screen *ebiten.Image) {
// GAMEOVER wird separat behandelt und beendet die Funktion früh.
g.stateMutex.Lock()
status := g.gameState.Status
g.stateMutex.Unlock()
if status == "GAMEOVER" {
g.drawGameOver(screen)
return
}
snap := g.takeRenderSnapshot(screen)
g.drawBackground(screen, snap)
g.RenderGround(screen, g.camX/snap.viewScale)
g.drawWorldObjects(screen, snap)
g.drawPlayers(screen, snap)
g.drawStatusUI(screen, snap)
g.drawDeathZoneLine(screen, snap.canvasH)
g.RenderParticles(screen)
if g.showDebug {
g.drawDebugOverlay(screen)
}
if !g.keyboardUsed {
g.drawTouchControls(screen)
}
}
// drawGameOver behandelt den GAMEOVER-Bildschirm inkl. Score-Übermittlung.
func (g *Game) drawGameOver(screen *ebiten.Image) {
g.stateMutex.Lock()
myScore := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
myScore = p.Score
break
}
}
g.stateMutex.Unlock()
if !g.scoreSubmitted {
g.submitScore()
g.sendGameOverToJS(myScore)
}
g.drawGameOverScreen(screen, myScore)
}
// drawBackground zeichnet das Hintergrundbild (wechselt nach Score).
func (g *Game) drawBackground(screen *ebiten.Image, snap renderSnapshot) {
bgID := "background"
if snap.myScore >= 10000 {
bgID = "background2"
} else if snap.myScore >= 5000 {
bgID = "background1"
}
bgImg, exists := g.assetsImages[bgID]
if !exists || bgImg == nil {
screen.Fill(ColSky)
return
}
bgW, bgH := bgImg.Size()
scaleX := float64(snap.canvasW) / float64(bgW)
scaleY := float64(snap.canvasH) / float64(bgH)
scale := math.Max(scaleX, scaleY)
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(scale, scale)
op.GeoM.Translate(
(float64(snap.canvasW)-float64(bgW)*scale)/2,
(float64(snap.canvasH)-float64(bgH)*scale)/2,
)
screen.DrawImage(bgImg, op)
}
// drawWorldObjects zeichnet Chunk-Objekte und bewegende Plattformen.
func (g *Game) drawWorldObjects(screen *ebiten.Image, snap renderSnapshot) {
for _, activeChunk := range snap.chunks {
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
if !exists {
log.Printf("⚠️ Chunk '%s' nicht in Library gefunden!", activeChunk.ChunkID)
continue
}
for objIdx, obj := range chunkDef.Objects {
if obj.MovingPlatform != nil {
continue // Bewegende Plattformen separat gerendert
}
if assetDef, ok := g.world.Manifest.Assets[obj.AssetID]; ok {
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
if assetDef.Type == "coin" && snap.collectedCoins[key] {
continue
}
if assetDef.Type == "powerup" && snap.collectedPowerups[key] {
continue
}
}
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, snap.canvasH))
}
}
for _, mp := range snap.movingPlatforms {
g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, snap.canvasH))
}
// Debug: Boden-Collider visualisieren (GRÜN)
if g.showDebug {
vector.StrokeRect(screen,
float32(-g.camX), float32(WorldToScreenYWithHeight(540, snap.canvasH)),
10000, 5000, 2, color.RGBA{0, 255, 0, 255}, false)
}
}
// drawPlayers zeichnet alle Spieler mit Sprites, Nametags und optionalen Hitboxen.
func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) {
for id, p := range snap.players {
posX, posY := p.X, p.Y
if p.Name == g.playerName {
// Lokaler Spieler: Interpolation zwischen Physics-Steps (20 TPS → 60 fps)
alpha := float64(time.Since(snap.physicsTime)) / float64(physicsStep)
if alpha > 1 {
alpha = 1
}
posX = snap.prevPredX + (snap.predX-snap.prevPredX)*alpha + snap.offsetX
posY = snap.prevPredY + (snap.predY-snap.prevPredY)*alpha + snap.offsetY
}
sprite := g.selectPlayerSprite(p.OnGround, p.VY)
screenY := WorldToScreenYWithHeight(posY, snap.canvasH)
g.DrawAsset(screen, sprite, posX, screenY)
name := p.Name
if name == "" {
name = id
}
text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(screenY-25), ColText)
if g.showDebug {
g.drawPlayerHitbox(screen, posX, screenY)
}
}
}
// selectPlayerSprite gibt den Sprite-Namen basierend auf dem Bewegungszustand zurück.
func (g *Game) selectPlayerSprite(onGround bool, vy float64) string {
if onGround || (vy >= -3.0 && vy <= 3.0) {
return "player"
}
if vy < -5.0 {
return "jump0" // Springt nach oben
}
return "jump1" // Fällt oder höchster Punkt
}
// drawPlayerHitbox visualisiert die Spieler-Hitbox im Debug-Modus.
func (g *Game) drawPlayerHitbox(screen *ebiten.Image, posX, screenY float64) {
def, ok := g.world.Manifest.Assets["player"]
if !ok {
return
}
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)
vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false)
}
// drawStatusUI zeichnet das spielzustandsabhängige UI (Countdown, Score, Spectator).
func (g *Game) drawStatusUI(screen *ebiten.Image, snap renderSnapshot) {
switch snap.status {
case "COUNTDOWN":
msg := fmt.Sprintf("GO IN: %d", snap.timeLeft)
text.Draw(screen, msg, basicfont.Face7x13, snap.canvasW/2-40, snap.canvasH/2, color.RGBA{255, 255, 0, 255})
case "RUNNING":
g.drawDangerOverlay(screen, snap)
g.drawScoreBox(screen, snap)
if snap.isDead {
g.drawSpectatorOverlay(screen, snap)
}
}
}
// drawDangerOverlay zeichnet einen roten Bildschirmrand wenn DifficultyFactor > 0.5.
func (g *Game) drawDangerOverlay(screen *ebiten.Image, snap renderSnapshot) {
if snap.difficultyFactor <= 0.5 {
return
}
alpha := uint8((snap.difficultyFactor - 0.5) * 2.0 * 60)
col := color.RGBA{200, 0, 0, alpha}
w, h := float32(snap.canvasW), float32(snap.canvasH)
bw := float32(8)
vector.DrawFilledRect(screen, 0, 0, w, bw, col, false)
vector.DrawFilledRect(screen, 0, h-bw, w, bw, col, false)
vector.DrawFilledRect(screen, 0, 0, bw, h, col, false)
vector.DrawFilledRect(screen, w-bw, 0, bw, h, col, false)
}
// drawScoreBox zeichnet die Distanz- und Score-Anzeige oben rechts.
func (g *Game) drawScoreBox(screen *ebiten.Image, snap renderSnapshot) {
dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
scoreStr := fmt.Sprintf("Score: %d", snap.myScore)
maxWidth := len(dist) * 7
if sw := len(scoreStr) * 7; sw > maxWidth {
maxWidth = sw
}
boxW := float32(maxWidth + 20)
boxH := float32(50)
boxX := float32(snap.canvasW) - boxW - 10
boxY := float32(10)
vector.DrawFilledRect(screen, boxX, boxY, boxW, boxH, color.RGBA{60, 60, 60, 200}, false)
vector.StrokeRect(screen, boxX, boxY, boxW, boxH, 2, color.RGBA{100, 100, 100, 255}, false)
textX := int(boxX) + 10
text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.White)
text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255})
}
// drawSpectatorOverlay zeigt das Spectator-Banner wenn der lokale Spieler tot ist.
func (g *Game) drawSpectatorOverlay(screen *ebiten.Image, snap renderSnapshot) {
vector.DrawFilledRect(screen, 0, 0, float32(snap.canvasW), 80, color.RGBA{150, 0, 0, 180}, false)
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, snap.canvasW/2-140, 30, color.White)
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", snap.myScore), basicfont.Face7x13, snap.canvasW/2-90, 55, color.RGBA{255, 255, 0, 255})
}
// drawDeathZoneLine zeichnet die rote Todes-Linie am linken Bildschirmrand.
func (g *Game) drawDeathZoneLine(screen *ebiten.Image, canvasH int) {
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})
}
// drawTouchControls zeichnet den virtuellen Joystick und die Touch-Buttons.
// Wird nur angezeigt wenn keine Tastatur benutzt wurde.
func (g *Game) drawTouchControls(screen *ebiten.Image) {
tcW, tcH := screen.Size()
// Canvas-Maße cachen (werden in handleTouchInput benötigt)
g.lastCanvasHeight = tcH
g.lastCanvasWidth = tcW
floorY := GetFloorYFromHeight(tcH)
refDim := math.Min(float64(tcW), float64(tcH))
joyR := refDim * joyRadiusScale
knobR := joyR * joyKnobRatio
jumpR := refDim * jumpBtnScale
downR := refDim * downBtnScale
g.joyRadius = joyR
// ── A) Floating Joystick (links) ─────────────────────────────────────────
if !g.joyActive {
g.joyBaseX = float64(tcW) * joyDefaultXRatio
g.joyBaseY = floorY - joyR - 12
g.joyStickX = g.joyBaseX
g.joyStickY = g.joyBaseY
}
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)
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) * jumpBtnXRatio
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}
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 (mitte-rechts) ────────────────────────────────
downX := float64(tcW) * downBtnXRatio
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 ---
// DrawAsset zeichnet ein Asset an einer Welt-Position auf den Screen.
// Objekte außerhalb des sichtbaren Bereichs (±cullingBuffer) werden übersprungen.
func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY float64) {
def, ok := g.world.Manifest.Assets[assetID]
if !ok {
return
}
canvasW, canvasH := screen.Size()
viewScale := GetScaleFromHeight(canvasH)
screenX := (worldX - g.camX/viewScale) * viewScale
if screenX < -cullingBuffer || screenX > float64(canvasW)+cullingBuffer {
return
}
img := g.assetsImages[assetID]
if img != nil {
op := &ebiten.DrawImageOptions{}
op.Filter = ebiten.FilterLinear
finalScale := def.Scale * viewScale
op.GeoM.Scale(finalScale, finalScale)
op.GeoM.Translate(
screenX+(def.DrawOffX*viewScale),
worldY+(def.DrawOffY*viewScale),
)
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: farbiges Rechteck wenn Bild fehlt
vector.DrawFilledRect(screen,
float32(screenX+def.Hitbox.OffsetX),
float32(worldY+def.Hitbox.OffsetY),
float32(def.Hitbox.W),
float32(def.Hitbox.H),
def.Color.ToRGBA(),
false,
)
}
}
// --- DEBUG OVERLAY ---
// drawDebugOverlay zeigt Performance- und Netzwerk-Stats (F3 zum Umschalten).
func (g *Game) drawDebugOverlay(screen *ebiten.Image) {
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
lh := 15 // line height
text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255})
y += lh + 5
fpsCol := color.RGBA{0, 255, 0, 255}
if g.currentFPS < 15 {
fpsCol = color.RGBA{255, 0, 0, 255}
} else if g.currentFPS < 30 {
fpsCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsCol)
y += lh
updateAge := time.Since(g.lastUpdateTime).Milliseconds()
latencyCol := color.RGBA{0, 255, 0, 255}
if updateAge > 200 {
latencyCol = color.RGBA{255, 0, 0, 255}
} else if updateAge > 100 {
latencyCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyCol)
y += lh
text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White)
y += lh
oooCol := color.RGBA{0, 255, 0, 255}
if g.outOfOrderCount > 50 {
oooCol = color.RGBA{255, 0, 0, 255}
} else if g.outOfOrderCount > 10 {
oooCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooCol)
y += lh
if g.totalUpdates > 0 {
lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100
lossCol := color.RGBA{0, 255, 0, 255}
if lossRate > 10 {
lossCol = color.RGBA{255, 0, 0, 255}
} else if lossRate > 5 {
lossCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossCol)
y += lh
}
text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White)
y += lh
corrCol := color.RGBA{0, 255, 0, 255}
if g.correctionCount > 500 {
corrCol = color.RGBA{255, 0, 0, 255}
} else if g.correctionCount > 100 {
corrCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrCol)
y += lh
corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY)
corrMagCol := color.RGBA{0, 255, 0, 255}
if corrMag > 0.1 {
corrMagCol = color.RGBA{255, 165, 0, 255}
}
text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, corrMagCol)
y += lh
text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White)
}