Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/game_render.go
Sebastian Unterschütz 6b07004f01
Some checks failed
Dynamic Branch Deploy / build-and-deploy (push) Has been cancelled
fix view port rendering to include
2026-03-24 09:12:51 +01:00

955 lines
29 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"
"math/rand"
"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
// Powerup Timer HUD
myHasDoubleJump bool
myDoubleJumpRemaining float64
myDoubleJumpUsed bool
myHasGodMode bool
myGodModeRemaining float64
myHasMagnet bool
myMagnetRemaining float64
// 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
snap.myHasDoubleJump = p.HasDoubleJump
snap.myDoubleJumpRemaining = p.DoubleJumpRemainingSeconds
snap.myDoubleJumpUsed = p.DoubleJumpUsed
snap.myHasGodMode = p.HasGodMode
snap.myGodModeRemaining = p.GodModeRemainingSeconds
snap.myHasMagnet = p.HasMagnet
snap.myMagnetRemaining = p.MagnetRemainingSeconds
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)
// Trail: store predicted position every physics step
g.trail = append(g.trail, trailPoint{X: g.predictedX, Y: g.predictedY})
if len(g.trail) > 8 {
g.trail = g.trail[1:]
}
}
// --- 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)
// Screen Shake: draw to offscreen buffer when active
target := screen
if g.shakeFrames > 0 {
w, h := screen.Size()
if g.shakeBuffer == nil {
g.shakeBuffer = ebiten.NewImage(w, h)
} else if bw, bh := g.shakeBuffer.Size(); bw != w || bh != h {
g.shakeBuffer = ebiten.NewImage(w, h)
}
g.shakeBuffer.Clear()
target = g.shakeBuffer
g.shakeFrames--
}
g.drawBackground(target, snap)
g.RenderGround(target, g.camX, snap.viewScale)
g.drawTeacher(target, snap)
g.drawWorldObjects(target, snap)
g.drawPlayers(target, snap)
g.drawStatusUI(target, snap)
g.drawDeathZoneLine(target, snap.canvasH)
g.RenderParticles(target)
if g.showDebug {
g.drawDebugOverlay(target)
}
if !g.keyboardUsed {
g.drawTouchControls(target)
}
// Blit shakeBuffer to screen with random offset
if target != screen {
ox := (rand.Float64()*2 - 1) * g.shakeIntensity
oy := (rand.Float64()*2 - 1) * g.shakeIntensity
if g.shakeFrames == 0 {
g.shakeIntensity = 0
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(ox, oy)
screen.DrawImage(g.shakeBuffer, op)
}
}
// 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*snap.viewScale), 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)
if p.Name == g.playerName && len(g.trail) > 1 {
for i, tp := range g.trail {
ratio := float32(i+1) / float32(len(g.trail))
alpha := uint8(ratio * 80)
r := float32(8 * ratio)
tx := float32((tp.X - g.camX) * snap.viewScale)
ty := float32(WorldToScreenYWithHeight(tp.Y, snap.canvasH))
vector.DrawFilledCircle(screen, tx, ty, r, color.RGBA{200, 220, 255, alpha}, false)
}
}
g.DrawAsset(screen, sprite, posX, screenY)
name := p.Name
if name == "" {
name = id
}
text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText)
if g.showDebug {
g.drawPlayerHitbox(screen, posX, screenY, snap.viewScale)
}
}
}
// 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, viewScale float64) {
def, ok := g.world.Manifest.Assets["player"]
if !ok {
return
}
hx := float32((posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX) * viewScale)
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)*viewScale), 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.drawPowerupHUD(screen, snap)
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})
}
// drawPowerupHUD zeichnet Timer-Balken für aktive Powerups (oben links).
func (g *Game) drawPowerupHUD(screen *ebiten.Image, snap renderSnapshot) {
type bar struct {
label string
remaining float64
maxTime float64
col color.RGBA
active bool
used bool
}
bars := []bar{
{"JUMP x2", snap.myDoubleJumpRemaining, 15.0, color.RGBA{100, 200, 255, 255}, snap.myHasDoubleJump, snap.myDoubleJumpUsed},
{"GODMODE", snap.myGodModeRemaining, 10.0, color.RGBA{255, 215, 0, 255}, snap.myHasGodMode, false},
{"MAGNET", snap.myMagnetRemaining, 8.0, color.RGBA{255, 80, 220, 255}, snap.myHasMagnet, false},
}
x := float32(10)
y := float32(60)
barW := float32(110)
barH := float32(13)
for _, b := range bars {
if !b.active {
continue
}
ratio := float32(b.remaining / b.maxTime)
if ratio > 1 {
ratio = 1
}
if ratio < 0 {
ratio = 0
}
// Background
vector.DrawFilledRect(screen, x, y, barW, barH, color.RGBA{30, 30, 30, 200}, false)
// Fill — blinks red when < 30%
fillCol := b.col
if b.used {
fillCol = color.RGBA{fillCol.R / 3, fillCol.G / 3, fillCol.B / 3, fillCol.A}
}
if ratio < 0.3 && (time.Now().UnixMilli()/250)%2 == 0 {
fillCol = color.RGBA{255, 60, 60, 255}
}
vector.DrawFilledRect(screen, x, y, barW*ratio, barH, fillCol, false)
vector.StrokeRect(screen, x, y, barW, barH, 1, color.RGBA{140, 140, 140, 180}, false)
label := fmt.Sprintf("%s %.0fs", b.label, b.remaining)
text.Draw(screen, label, basicfont.Face7x13, int(x)+2, int(y)+10, color.White)
y += barH + 5
}
}
// 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})
}
// TriggerShake aktiviert den Screen-Shake-Effekt.
func (g *Game) TriggerShake(frames int, intensity float64) {
if frames > g.shakeFrames {
g.shakeFrames = frames
}
if intensity > g.shakeIntensity {
g.shakeIntensity = intensity
}
}
// drawTeacher zeichnet den Lehrer-Charakter am linken Bildschirmrand.
func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) {
if snap.status != "RUNNING" && snap.status != "COUNTDOWN" {
return
}
danger := snap.difficultyFactor
groundY := float32(GetFloorYFromHeight(snap.canvasH))
// Teacher slides in from the left as danger increases
// At danger=0: fully offscreen (-70). At danger=1: at X=5.
teacherCX := float32(-70 + danger*75)
bodyW := float32(28)
bodyH := float32(55 + danger*15)
headR := float32(14)
bodyX := teacherCX - bodyW/2
bodyY := groundY - bodyH
alpha := uint8(40 + danger*215)
// Shadow on left edge (red vignette)
shadowW := int(20 + danger*40)
for i := 0; i < shadowW; i++ {
a := uint8(float64(70) * float64(shadowW-i) / float64(shadowW) * danger)
vector.DrawFilledRect(screen, float32(i), 0, 1, float32(snap.canvasH), color.RGBA{200, 0, 0, a}, false)
}
// Only draw body when partially visible
if teacherCX > -40 {
// Body (dark suit)
vector.DrawFilledRect(screen, bodyX, bodyY, bodyW, bodyH, color.RGBA{100, 10, 10, alpha}, false)
// Tie
vector.DrawFilledRect(screen, teacherCX-3, bodyY+4, 6, bodyH-8, color.RGBA{180, 0, 0, alpha}, false)
// Head
vector.DrawFilledCircle(screen, teacherCX, bodyY-headR, headR, color.RGBA{210, 160, 110, alpha}, false)
// Angry eyes
eyeA := uint8(200)
vector.DrawFilledCircle(screen, teacherCX-5, bodyY-headR-1, 3, color.RGBA{255, 0, 0, eyeA}, false)
vector.DrawFilledCircle(screen, teacherCX+5, bodyY-headR-1, 3, color.RGBA{255, 0, 0, eyeA}, false)
// Legs
vector.DrawFilledRect(screen, bodyX+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false)
vector.DrawFilledRect(screen, bodyX+bodyW/2+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false)
}
// Warning text — blinks when close
if danger > 0.55 {
if (time.Now().UnixMilli()/300)%2 == 0 {
warnX := int(teacherCX) - 20
if warnX < 2 {
warnX = 2
}
text.Draw(screen, "LEHRER!", basicfont.Face7x13, warnX, int(bodyY)-20, color.RGBA{255, 50, 50, 255})
}
}
}
// --- 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
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)
}