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) // Solo: Lokale Prüfung der Runde (Tod/Score) if g.gameMode == "solo" { g.checkSoloRound() } // 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) }