1094 lines
34 KiB
Go
1094 lines
34 KiB
Go
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"image/color"
|
||
"log"
|
||
"math"
|
||
"math/rand"
|
||
"strings"
|
||
"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 || g.isOffline) && time.Since(g.lastInputTime) >= physicsStep {
|
||
g.lastInputTime = time.Now()
|
||
|
||
// Offline: Update Scroll & World logic locally
|
||
if g.isOffline {
|
||
g.updateOfflineLoop()
|
||
}
|
||
|
||
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:]
|
||
}
|
||
|
||
// --- Zitate & Meilensteine ---
|
||
g.updateQuotes()
|
||
}
|
||
|
||
// --- EMOTES ---
|
||
if inpututil.IsKeyJustPressed(ebiten.Key1) {
|
||
g.SendCommand("EMOTE_1")
|
||
}
|
||
if inpututil.IsKeyJustPressed(ebiten.Key2) {
|
||
g.SendCommand("EMOTE_2")
|
||
}
|
||
if inpututil.IsKeyJustPressed(ebiten.Key3) {
|
||
g.SendCommand("EMOTE_3")
|
||
}
|
||
if inpututil.IsKeyJustPressed(ebiten.Key4) {
|
||
g.SendCommand("EMOTE_4")
|
||
}
|
||
|
||
// --- 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)
|
||
|
||
// ── EMOTES ───────────────────────────────────────────────────────────
|
||
if fx >= float64(g.lastCanvasWidth)-80.0 && fy >= 40.0 && fy <= 250.0 && isJustPressed(id) {
|
||
emoteIdx := int((fy - 50.0) / 50.0)
|
||
if emoteIdx >= 0 && emoteIdx <= 3 {
|
||
g.SendCommand(fmt.Sprintf("EMOTE_%d", emoteIdx+1))
|
||
}
|
||
continue
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// In Presentation Mode normal players don't show names, only Host/PRESENTATION does (which is hidden anyway)
|
||
if snap.status != "PRESENTATION" || name == g.playerName {
|
||
text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText)
|
||
}
|
||
|
||
// Draw Emote if active
|
||
if p.State != "" && strings.HasPrefix(p.State, "EMOTE_") {
|
||
emoteStr := p.State[6:] // e.g. EMOTE_1 -> "1"
|
||
emoteMap := map[string]string{
|
||
"1": "❤️",
|
||
"2": "😂",
|
||
"3": "😡",
|
||
"4": "👍",
|
||
}
|
||
if emoji, ok := emoteMap[emoteStr]; ok {
|
||
text.Draw(screen, emoji, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale+15), int(screenY-40), color.White)
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// --- MEILENSTEIN-QUOTE ---
|
||
if time.Now().Before(g.milestoneQuoteTime) {
|
||
msg := fmt.Sprintf("🎉 %d PUNKTE! \"%s\"", g.lastMilestone, g.milestoneQuote.Text)
|
||
tw := float32(len(msg) * 7)
|
||
text.Draw(screen, msg, basicfont.Face7x13, int(float32(snap.canvasW)/2-tw/2), 60, color.RGBA{255, 255, 0, 255})
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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})
|
||
|
||
// ── D) Emote Buttons (oben rechts) ─────────────────────────────────────────
|
||
emoteY := 50.0
|
||
emoteXBase := float64(tcW) - 60.0
|
||
emoteSize := 40.0
|
||
emotes := []string{"❤️", "😂", "😡", "👍"}
|
||
|
||
for i, em := range emotes {
|
||
x := emoteXBase
|
||
y := emoteY + float64(i)*50.0
|
||
vector.DrawFilledRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), color.RGBA{0, 0, 0, 100}, false)
|
||
vector.StrokeRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), 2, color.RGBA{255, 255, 255, 100}, false)
|
||
text.Draw(screen, em, basicfont.Face7x13, int(x)+10, int(y)+25, color.White)
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
}
|
||
|
||
// updateQuotes verarbeitet die Logik für zufällige Lehrer-Sprüche und Meilensteine.
|
||
func (g *Game) updateQuotes() {
|
||
g.stateMutex.Lock()
|
||
status := g.gameState.Status
|
||
myScore := 0
|
||
for _, p := range g.gameState.Players {
|
||
if p.Name == g.playerName {
|
||
myScore = p.Score
|
||
break
|
||
}
|
||
}
|
||
g.stateMutex.Unlock()
|
||
|
||
if status != "RUNNING" {
|
||
return
|
||
}
|
||
|
||
now := time.Now()
|
||
|
||
// 1. Zufällige Lehrer-Sprüche (alle 10-25 Sekunden)
|
||
if now.After(g.teacherQuoteTime) {
|
||
g.teacherQuote = game.GetRandomQuote()
|
||
// Nächster Spruch in 15-30 Sekunden
|
||
g.teacherQuoteTime = now.Add(time.Duration(15+rand.Intn(15)) * time.Second)
|
||
}
|
||
|
||
// 2. Meilensteine (alle 1000 Punkte)
|
||
milestone := (myScore / 1000) * 1000
|
||
if milestone > 0 && milestone > g.lastMilestone {
|
||
g.lastMilestone = milestone
|
||
g.milestoneQuote = game.GetRandomQuote()
|
||
g.milestoneQuoteTime = now.Add(4 * time.Second) // 4 Sekunden anzeigen
|
||
log.Printf("🎉 Meilenstein erreicht: %d Punkte!", milestone)
|
||
}
|
||
}
|
||
|
||
// drawSpeechBubble zeichnet eine einfache Sprechblase mit Text.
|
||
func (g *Game) drawSpeechBubble(screen *ebiten.Image, x, y float32, msg string) {
|
||
// Text-Breite grob schätzen (Face7x13: ca 7px pro Zeichen)
|
||
tw := float32(len(msg) * 7)
|
||
th := float32(15)
|
||
padding := float32(8)
|
||
|
||
bx := x + 10
|
||
by := y - th - padding*2
|
||
bw := tw + padding*2
|
||
bh := th + padding*2
|
||
|
||
// Hintergrund
|
||
vector.DrawFilledRect(screen, bx, by, bw, bh, color.RGBA{255, 255, 255, 220}, false)
|
||
vector.StrokeRect(screen, bx, by, bw, bh, 2, color.Black, false)
|
||
|
||
// Kleiner Pfeil
|
||
vector.DrawFilledCircle(screen, bx, by+bh/2, 5, color.RGBA{255, 255, 255, 220}, false)
|
||
|
||
text.Draw(screen, msg, basicfont.Face7x13, int(bx+padding), int(by+padding+10), color.Black)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// --- SPRECHBLASE ---
|
||
if time.Now().Before(g.teacherQuoteTime.Add(-10 * time.Second)) && g.teacherQuote.Text != "" {
|
||
// Wir zeigen den Spruch für 5-10 Sekunden an
|
||
g.drawSpeechBubble(screen, teacherCX+15, bodyY-20, g.teacherQuote.Text)
|
||
}
|
||
}
|
||
// 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)
|
||
}
|