All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 8m20s
651 lines
20 KiB
Go
651 lines
20 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"log"
|
|
"math"
|
|
"time"
|
|
|
|
"github.com/hajimehoshi/ebiten/v2"
|
|
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
|
"github.com/hajimehoshi/ebiten/v2/text"
|
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
|
"golang.org/x/image/font/basicfont"
|
|
)
|
|
|
|
// --- INPUT & UPDATE LOGIC ---
|
|
|
|
func (g *Game) UpdateGame() {
|
|
// --- 1. MUTE TOGGLE ---
|
|
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
|
|
g.audio.ToggleMute()
|
|
}
|
|
|
|
// --- 2. KEYBOARD INPUT ---
|
|
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
|
|
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
|
|
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
|
|
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
|
|
|
|
// Tastatur-Nutzung erkennen (für Mobile Controls ausblenden)
|
|
if keyLeft || keyRight || keyDown || keyJump {
|
|
g.keyboardUsed = true
|
|
}
|
|
|
|
// --- 3. TOUCH INPUT HANDLING ---
|
|
g.handleTouchInput()
|
|
|
|
// --- 4. INPUT STATE ERSTELLEN ---
|
|
joyDir := 0.0
|
|
if g.joyActive {
|
|
diffX := g.joyStickX - g.joyBaseX
|
|
maxDist := 60.0 // Muss mit handleTouchInput() übereinstimmen
|
|
|
|
// Analoger Wert zwischen -1.0 und 1.0
|
|
joyDir = diffX / maxDist
|
|
|
|
// Clamp zwischen -1 und 1
|
|
if joyDir < -1.0 {
|
|
joyDir = -1.0
|
|
}
|
|
if joyDir > 1.0 {
|
|
joyDir = 1.0
|
|
}
|
|
|
|
// Deadzone für bessere Kontrolle
|
|
if joyDir > -0.15 && joyDir < 0.15 {
|
|
joyDir = 0
|
|
}
|
|
}
|
|
|
|
isJoyDown := g.joyActive && (g.joyStickY-g.joyBaseY) > 40
|
|
|
|
// Input State zusammenbauen
|
|
input := InputState{
|
|
Sequence: g.inputSequence,
|
|
Left: keyLeft || joyDir < -0.1,
|
|
Right: keyRight || joyDir > 0.1,
|
|
Jump: keyJump || g.btnJumpActive,
|
|
Down: keyDown || isJoyDown,
|
|
JoyX: joyDir, // Analoge X-Achse speichern
|
|
}
|
|
g.btnJumpActive = false
|
|
|
|
// --- 4. INPUT SENDEN (MIT CLIENT PREDICTION) ---
|
|
if g.connected {
|
|
g.predictionMutex.Lock()
|
|
// Sequenznummer erhöhen
|
|
g.inputSequence++
|
|
input.Sequence = g.inputSequence
|
|
|
|
// Lokale Prediction ausführen für sofortiges Feedback
|
|
g.ApplyInput(input)
|
|
|
|
// Input für History speichern (für Server-Reconciliation)
|
|
g.pendingInputs[input.Sequence] = input
|
|
|
|
g.predictionMutex.Unlock()
|
|
|
|
// Input an Server senden
|
|
g.SendInputWithSequence(input)
|
|
}
|
|
|
|
// --- 5. KAMERA LOGIK ---
|
|
g.stateMutex.Lock()
|
|
targetCam := g.gameState.ScrollX
|
|
g.stateMutex.Unlock()
|
|
|
|
// Negative Kamera verhindern
|
|
if targetCam < 0 {
|
|
targetCam = 0
|
|
}
|
|
|
|
// Kamera hart setzen
|
|
g.camX = targetCam
|
|
|
|
// --- 6. CORRECTION OFFSET ABKLINGEN ---
|
|
// Der visuelle Offset sorgt dafür dass Server-Korrekturen sanft und unsichtbar sind.
|
|
// Decay: 0.85 pro Frame → ~5 Frames zum Halbieren bei 60fps (≈80ms)
|
|
const correctionDecay = 0.85
|
|
g.predictionMutex.Lock()
|
|
g.correctionOffsetX *= correctionDecay
|
|
g.correctionOffsetY *= correctionDecay
|
|
if g.correctionOffsetX*g.correctionOffsetX < 0.09 {
|
|
g.correctionOffsetX = 0
|
|
}
|
|
if g.correctionOffsetY*g.correctionOffsetY < 0.09 {
|
|
g.correctionOffsetY = 0
|
|
}
|
|
g.predictionMutex.Unlock()
|
|
|
|
// --- 7. PARTIKEL UPDATEN ---
|
|
g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms
|
|
|
|
// --- 7. PARTIKEL SPAWNEN (State Changes Detection) ---
|
|
g.DetectAndSpawnParticles()
|
|
}
|
|
|
|
// Verarbeitet Touch-Eingaben für Joystick und Buttons
|
|
func (g *Game) handleTouchInput() {
|
|
touches := ebiten.TouchIDs()
|
|
|
|
// WICHTIG: Joystick-Base-Position wird jetzt beim Rendering gesetzt (DrawGame)
|
|
// um sicherzustellen dass die gleiche Canvas-Höhe verwendet wird!
|
|
// Wir verwenden hier nur die gecachte Position.
|
|
|
|
// Reset, wenn keine Finger mehr auf dem Display sind
|
|
if len(touches) == 0 {
|
|
g.joyActive = 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)
|
|
|
|
// 1. RECHTE SEITE: JUMP BUTTON
|
|
// Alles rechts der Bildschirmmitte ist "Springen"
|
|
if fx > ScreenWidth/2 {
|
|
// Prüfen, ob dieser Touch gerade NEU dazu gekommen ist
|
|
for _, justID := range inpututil.JustPressedTouchIDs() {
|
|
if id == justID {
|
|
g.btnJumpActive = true
|
|
break
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// 2. LINKE SEITE: JOYSTICK
|
|
// Wenn wir noch keinen Joystick-Finger haben, prüfen wir, ob dieser Finger startet
|
|
if !g.joyActive {
|
|
// Prüfen ob Touch in der Nähe der Joystick-Basis ist (Radius 150 Toleranz)
|
|
dist := math.Sqrt(math.Pow(fx-g.joyBaseX, 2) + math.Pow(fy-g.joyBaseY, 2))
|
|
if dist < 150 {
|
|
g.joyActive = true
|
|
g.joyTouchID = id
|
|
}
|
|
}
|
|
|
|
// Wenn das der Joystick-Finger ist -> Stick bewegen
|
|
if g.joyActive && id == g.joyTouchID {
|
|
joyFound = true
|
|
|
|
// Vektor berechnen (Wie weit ziehen wir weg?)
|
|
dx := fx - g.joyBaseX
|
|
dy := fy - g.joyBaseY
|
|
dist := math.Sqrt(dx*dx + dy*dy)
|
|
maxDist := 60.0 // Maximaler Radius des Sticks
|
|
|
|
// Begrenzen auf Radius
|
|
if dist > maxDist {
|
|
scale := maxDist / dist
|
|
dx *= scale
|
|
dy *= scale
|
|
}
|
|
|
|
g.joyStickX = g.joyBaseX + dx
|
|
g.joyStickY = g.joyBaseY + dy
|
|
}
|
|
}
|
|
|
|
// Wenn der Joystick-Finger losgelassen wurde, Joystick resetten
|
|
if !joyFound {
|
|
g.joyActive = false
|
|
g.joyStickX = g.joyBaseX
|
|
g.joyStickY = g.joyBaseY
|
|
}
|
|
}
|
|
|
|
// --- RENDERING LOGIC ---
|
|
|
|
func (g *Game) DrawGame(screen *ebiten.Image) {
|
|
// WICHTIG: GAMEOVER-Check ZUERST, bevor wir Locks holen!
|
|
g.stateMutex.Lock()
|
|
status := g.gameState.Status
|
|
g.stateMutex.Unlock()
|
|
|
|
if status == "GAMEOVER" {
|
|
// Game Over Screen - komplett separates Rendering ohne weitere Locks
|
|
g.stateMutex.Lock()
|
|
myScore := 0
|
|
for _, p := range g.gameState.Players {
|
|
if p.Name == g.playerName {
|
|
myScore = p.Score
|
|
break
|
|
}
|
|
}
|
|
g.stateMutex.Unlock()
|
|
|
|
// In WASM: HTML Game Over Screen anzeigen
|
|
if !g.scoreSubmitted {
|
|
g.submitScore() // submitScore() setzt g.scoreSubmitted intern
|
|
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
|
|
}
|
|
|
|
g.drawGameOverScreen(screen, myScore)
|
|
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
|
|
}
|
|
|
|
// State Locken für Datenzugriff
|
|
g.stateMutex.Lock()
|
|
|
|
// Prüfe ob Spieler tot ist
|
|
isDead := false
|
|
myScore := 0
|
|
for _, p := range g.gameState.Players {
|
|
if p.Name == g.playerName {
|
|
isDead = !p.IsAlive || p.IsSpectator
|
|
myScore = p.Score
|
|
break
|
|
}
|
|
}
|
|
g.stateMutex.Unlock()
|
|
|
|
// Canvas-Größe und Scale-Faktor
|
|
canvasW, canvasH := screen.Size()
|
|
viewScale := GetScaleFromHeight(canvasH)
|
|
|
|
// 1. Hintergrund (wechselt alle 5000 Punkte)
|
|
backgroundID := "background"
|
|
if myScore >= 10000 {
|
|
backgroundID = "background2"
|
|
} else if myScore >= 5000 {
|
|
backgroundID = "background1"
|
|
}
|
|
|
|
// Hintergrundbild zeichnen (skaliert auf tatsächliche Canvas-Größe)
|
|
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
|
|
op := &ebiten.DrawImageOptions{}
|
|
|
|
bgW, bgH := bgImg.Size()
|
|
|
|
// Skalierung berechnen, um Canvas komplett zu füllen
|
|
scaleX := float64(canvasW) / float64(bgW)
|
|
scaleY := float64(canvasH) / float64(bgH)
|
|
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
|
|
|
|
op.GeoM.Scale(scale, scale)
|
|
|
|
// Zentrieren auf Canvas
|
|
scaledW := float64(bgW) * scale
|
|
scaledH := float64(bgH) * scale
|
|
offsetX := (float64(canvasW) - scaledW) / 2
|
|
offsetY := (float64(canvasH) - scaledH) / 2
|
|
op.GeoM.Translate(offsetX, offsetY)
|
|
|
|
screen.DrawImage(bgImg, op)
|
|
} else {
|
|
// Fallback: Einfarbiger Himmel
|
|
screen.Fill(ColSky)
|
|
}
|
|
|
|
// Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera)
|
|
// Mit viewScale multiplizieren damit auf Mobile mehr Welt sichtbar ist
|
|
effectiveCamX := g.camX / viewScale
|
|
g.RenderGround(screen, effectiveCamX)
|
|
|
|
// State Locken für Datenzugriff
|
|
g.stateMutex.Lock()
|
|
defer g.stateMutex.Unlock()
|
|
|
|
// Screen-Höhe für Y-Transformation (canvasH bereits oben definiert)
|
|
// _, canvasH = screen.Size() // nicht nötig, bereits definiert
|
|
|
|
// 2. Chunks (Welt-Objekte)
|
|
for _, activeChunk := range g.gameState.WorldChunks {
|
|
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
|
|
if !exists {
|
|
log.Printf("⚠️ Chunk '%s' nicht in Library gefunden!", activeChunk.ChunkID)
|
|
continue
|
|
}
|
|
|
|
// Start-Chunk hat absichtlich keine Objekte
|
|
|
|
for objIdx, obj := range chunkDef.Objects {
|
|
// Skip Moving Platforms - die werden separat gerendert
|
|
if obj.MovingPlatform != nil {
|
|
continue
|
|
}
|
|
|
|
// Prüfe ob Coin/Powerup bereits eingesammelt wurde
|
|
assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID]
|
|
if hasAsset {
|
|
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
|
|
|
|
if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] {
|
|
// Coin wurde eingesammelt, nicht zeichnen
|
|
continue
|
|
}
|
|
|
|
if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] {
|
|
// Powerup wurde eingesammelt, nicht zeichnen
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Asset zeichnen (mit Welt-zu-Screen-Y-Transformation)
|
|
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, WorldToScreenYWithHeight(obj.Y, canvasH))
|
|
}
|
|
}
|
|
|
|
// 2.5 Bewegende Plattformen (von Server synchronisiert)
|
|
for _, mp := range g.gameState.MovingPlatforms {
|
|
g.DrawAsset(screen, mp.AssetID, mp.X, WorldToScreenYWithHeight(mp.Y, canvasH))
|
|
}
|
|
|
|
// 2.6 DEBUG: Basis-Boden-Collider visualisieren (GRÜN) - NUR IM DEBUG-MODUS
|
|
if g.showDebug {
|
|
vector.StrokeRect(screen, float32(-g.camX), float32(WorldToScreenYWithHeight(540, canvasH)), 10000, float32(5000), float32(2), color.RGBA{0, 255, 0, 255}, false)
|
|
}
|
|
|
|
// 3. Spieler
|
|
for id, p := range g.gameState.Players {
|
|
posX, posY := p.X, p.Y
|
|
vy := p.VY
|
|
onGround := p.OnGround
|
|
|
|
// Für lokalen Spieler: Verwende Client-Prediction + visuellen Korrektur-Offset
|
|
// correctionOffset sorgt dafür dass Server-Korrekturen sanft aussehen
|
|
if p.Name == g.playerName {
|
|
g.predictionMutex.Lock()
|
|
posX = g.predictedX + g.correctionOffsetX
|
|
posY = g.predictedY + g.correctionOffsetY
|
|
g.predictionMutex.Unlock()
|
|
}
|
|
|
|
// Wähle Sprite basierend auf Sprung-Status
|
|
sprite := "player" // Default: am Boden
|
|
|
|
// Nur Jump-Animation wenn wirklich in der Luft
|
|
// Bei 20 TPS größerer Threshold (3.0 statt 1.0)
|
|
// OnGround oder sehr kleine VY = am Boden/Plattform
|
|
isInAir := !onGround && (vy < -3.0 || vy > 3.0)
|
|
|
|
if isInAir {
|
|
if vy < -5.0 {
|
|
// Springt nach oben
|
|
sprite = "jump0"
|
|
} else {
|
|
// Fällt oder höchster Punkt
|
|
sprite = "jump1"
|
|
}
|
|
}
|
|
|
|
// Konvertiere Welt-Y zu Screen-Y für korrektes Rendering
|
|
_, canvasH := screen.Size()
|
|
screenY := WorldToScreenYWithHeight(posY, canvasH)
|
|
|
|
g.DrawAsset(screen, sprite, posX, screenY)
|
|
|
|
// Name Tag
|
|
name := p.Name
|
|
if name == "" {
|
|
name = id
|
|
}
|
|
text.Draw(screen, name, basicfont.Face7x13, int(posX-g.camX), int(screenY-25), ColText)
|
|
|
|
// HITBOX VISUALISIERUNG (NUR IM DEBUG-MODUS)
|
|
if g.showDebug {
|
|
if def, ok := g.world.Manifest.Assets["player"]; ok {
|
|
// Spieler-Hitbox (ROT) - mit Screen-Y-Transformation
|
|
hx := float32(posX + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
|
|
hy := float32(screenY + def.DrawOffY + def.Hitbox.OffsetY)
|
|
vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 3, color.RGBA{255, 0, 0, 255}, false)
|
|
|
|
// Spieler-Position als Punkt (GELB) - mit Screen-Y-Transformation
|
|
vector.DrawFilledCircle(screen, float32(posX-g.camX), float32(screenY), 5, color.RGBA{255, 255, 0, 255}, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 4. UI Status (Canvas-relativ)
|
|
canvasW, canvasH = screen.Size()
|
|
|
|
if g.gameState.Status == "COUNTDOWN" {
|
|
msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft)
|
|
text.Draw(screen, msg, basicfont.Face7x13, canvasW/2-40, canvasH/2, color.RGBA{255, 255, 0, 255})
|
|
} else if g.gameState.Status == "RUNNING" {
|
|
// Score/Distance Anzeige mit grauem Hintergrund (oben rechts)
|
|
dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
|
|
scoreStr := fmt.Sprintf("Score: %d", myScore)
|
|
|
|
// Berechne Textbreiten für dynamische Box-Größe
|
|
distLen := len(dist) * 7 // ~7px pro Zeichen
|
|
scoreLen := len(scoreStr) * 7
|
|
maxWidth := distLen
|
|
if scoreLen > maxWidth {
|
|
maxWidth = scoreLen
|
|
}
|
|
|
|
boxWidth := float32(maxWidth + 20) // 10px Padding links/rechts
|
|
boxHeight := float32(50)
|
|
boxX := float32(canvasW) - boxWidth - 10 // 10px vom rechten Rand
|
|
boxY := float32(10) // 10px vom oberen Rand
|
|
|
|
// Grauer halbtransparenter Hintergrund
|
|
vector.DrawFilledRect(screen, boxX, boxY, boxWidth, boxHeight, color.RGBA{60, 60, 60, 200}, false)
|
|
vector.StrokeRect(screen, boxX, boxY, boxWidth, boxHeight, 2, color.RGBA{100, 100, 100, 255}, false)
|
|
|
|
// Text (zentriert in Box)
|
|
textX := int(boxX) + 10
|
|
text.Draw(screen, dist, basicfont.Face7x13, textX, int(boxY)+22, color.RGBA{255, 255, 255, 255})
|
|
text.Draw(screen, scoreStr, basicfont.Face7x13, textX, int(boxY)+40, color.RGBA{255, 215, 0, 255})
|
|
|
|
// Spectator Overlay wenn tot
|
|
if isDead {
|
|
// Halbtransparenter roter Overlay (volle Canvas-Breite)
|
|
vector.DrawFilledRect(screen, 0, 0, float32(canvasW), 80, color.RGBA{150, 0, 0, 180}, false)
|
|
text.Draw(screen, "☠ DU BIST TOT - SPECTATOR MODE ☠", basicfont.Face7x13, canvasW/2-140, 30, color.White)
|
|
text.Draw(screen, fmt.Sprintf("Dein Final Score: %d", myScore), basicfont.Face7x13, canvasW/2-90, 55, color.RGBA{255, 255, 0, 255})
|
|
}
|
|
}
|
|
|
|
// 5. DEBUG: TODES-LINIE (volle Canvas-Höhe)
|
|
vector.StrokeLine(screen, 0, 0, 0, float32(canvasH), 10, color.RGBA{255, 0, 0, 128}, false)
|
|
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, canvasH/2, color.RGBA{255, 0, 0, 255})
|
|
|
|
// 6. PARTIKEL RENDERN (vor UI)
|
|
g.RenderParticles(screen)
|
|
|
|
// 7. DEBUG OVERLAY (F3 zum Umschalten)
|
|
if g.showDebug {
|
|
g.drawDebugOverlay(screen)
|
|
}
|
|
|
|
// 8. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
|
|
if !g.keyboardUsed {
|
|
canvasW, canvasH := screen.Size()
|
|
|
|
// Cache Canvas-Höhe für Touch-Input (wird in UpdateGame/handleTouchInput verwendet)
|
|
g.lastCanvasHeight = canvasH
|
|
|
|
// A) Joystick Base (unten links, über dem Boden positioniert)
|
|
// WICHTIG: Verwende die gleiche Berechnung wie in handleTouchInput()!
|
|
g.joyBaseX = 150.0
|
|
floorY := GetFloorYFromHeight(canvasH)
|
|
g.joyBaseY = floorY - 50.0 // 50px über dem Boden (sichtbar über der Erde)
|
|
|
|
// Wenn Joystick nicht aktiv, Stick = Base
|
|
if !g.joyActive {
|
|
g.joyStickX = g.joyBaseX
|
|
g.joyStickY = g.joyBaseY
|
|
}
|
|
|
|
baseCol := color.RGBA{80, 80, 80, 50}
|
|
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false)
|
|
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
|
|
|
|
// B) Joystick Knob (relativ zu Base)
|
|
knobCol := color.RGBA{100, 100, 100, 80}
|
|
if g.joyActive {
|
|
knobCol = color.RGBA{100, 255, 100, 120}
|
|
}
|
|
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
|
|
|
|
// C) Jump Button (unten rechts, über dem Boden positioniert)
|
|
jumpX := float32(canvasW) - 150
|
|
jumpY := float32(floorY) - 50 // 50px über dem Boden
|
|
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
|
|
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false)
|
|
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
|
|
}
|
|
|
|
}
|
|
|
|
// --- ASSET HELPER ---
|
|
|
|
func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY float64) {
|
|
// 1. Definition laden
|
|
def, ok := g.world.Manifest.Assets[assetID]
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
// Canvas-Größe und View-Scale
|
|
canvasW, canvasH := screen.Size()
|
|
viewScale := GetScaleFromHeight(canvasH)
|
|
|
|
// 2. Screen Position berechnen (Welt - Kamera, mit Scale)
|
|
effectiveCamX := g.camX / viewScale
|
|
screenX := (worldX - effectiveCamX) * viewScale
|
|
screenY := worldY
|
|
|
|
// Optimierung: Nicht zeichnen, wenn komplett außerhalb (Canvas-Breite verwenden)
|
|
// Großzügiger Culling-Bereich für früheres Spawning (800px statt 200px)
|
|
if screenX < -800 || screenX > float64(canvasW)+800 {
|
|
return
|
|
}
|
|
|
|
// 3. Bild holen
|
|
img := g.assetsImages[assetID]
|
|
|
|
if img != nil {
|
|
op := &ebiten.DrawImageOptions{}
|
|
|
|
// Filter für bessere Skalierung (besonders bei großen Sprites)
|
|
op.Filter = ebiten.FilterLinear
|
|
|
|
// Skalieren: Asset-Scale * View-Scale (auf Mobile kleiner)
|
|
finalScale := def.Scale * viewScale
|
|
op.GeoM.Scale(finalScale, finalScale)
|
|
|
|
// Positionieren: ScreenPos + DrawOffset (auch skaliert)
|
|
op.GeoM.Translate(
|
|
screenX+(def.DrawOffX*viewScale),
|
|
screenY+(def.DrawOffY*viewScale),
|
|
)
|
|
|
|
// Farbe anwenden (nur wenn explizit gesetzt)
|
|
// Wenn Color leer ist (R=G=B=A=0), nicht anwenden (Bild bleibt original)
|
|
if def.Color.R != 0 || def.Color.G != 0 || def.Color.B != 0 || def.Color.A != 0 {
|
|
op.ColorScale.ScaleWithColor(def.Color.ToRGBA())
|
|
}
|
|
|
|
screen.DrawImage(img, op)
|
|
} else {
|
|
// FALLBACK (Buntes Rechteck)
|
|
vector.DrawFilledRect(screen,
|
|
float32(screenX+def.Hitbox.OffsetX),
|
|
float32(screenY+def.Hitbox.OffsetY),
|
|
float32(def.Hitbox.W),
|
|
float32(def.Hitbox.H),
|
|
def.Color.ToRGBA(),
|
|
false,
|
|
)
|
|
}
|
|
}
|
|
|
|
// drawDebugOverlay zeigt Performance und Network Stats (F3 zum Umschalten)
|
|
func (g *Game) drawDebugOverlay(screen *ebiten.Image) {
|
|
// Hintergrund (halbtransparent)
|
|
vector.DrawFilledRect(screen, 10, 80, 350, 170, color.RGBA{0, 0, 0, 180}, false)
|
|
vector.StrokeRect(screen, 10, 80, 350, 170, 2, color.RGBA{255, 255, 0, 255}, false)
|
|
|
|
y := 95
|
|
lineHeight := 15
|
|
|
|
// Titel
|
|
text.Draw(screen, "=== DEBUG INFO (F3) ===", basicfont.Face7x13, 20, y, color.RGBA{255, 255, 0, 255})
|
|
y += lineHeight + 5
|
|
|
|
// FPS
|
|
fpsColor := color.RGBA{0, 255, 0, 255}
|
|
if g.currentFPS < 15 {
|
|
fpsColor = color.RGBA{255, 0, 0, 255}
|
|
} else if g.currentFPS < 30 {
|
|
fpsColor = color.RGBA{255, 165, 0, 255}
|
|
}
|
|
text.Draw(screen, fmt.Sprintf("FPS: %.1f", g.currentFPS), basicfont.Face7x13, 20, y, fpsColor)
|
|
y += lineHeight
|
|
|
|
// Server Update Latenz
|
|
updateAge := time.Since(g.lastUpdateTime).Milliseconds()
|
|
latencyColor := color.RGBA{0, 255, 0, 255}
|
|
if updateAge > 200 {
|
|
latencyColor = color.RGBA{255, 0, 0, 255}
|
|
} else if updateAge > 100 {
|
|
latencyColor = color.RGBA{255, 165, 0, 255}
|
|
}
|
|
text.Draw(screen, fmt.Sprintf("Update Age: %dms", updateAge), basicfont.Face7x13, 20, y, latencyColor)
|
|
y += lineHeight
|
|
|
|
// Network Stats
|
|
text.Draw(screen, fmt.Sprintf("Total Updates: %d", g.totalUpdates), basicfont.Face7x13, 20, y, color.White)
|
|
y += lineHeight
|
|
|
|
oooColor := color.RGBA{0, 255, 0, 255}
|
|
if g.outOfOrderCount > 10 {
|
|
oooColor = color.RGBA{255, 165, 0, 255}
|
|
}
|
|
if g.outOfOrderCount > 50 {
|
|
oooColor = color.RGBA{255, 0, 0, 255}
|
|
}
|
|
text.Draw(screen, fmt.Sprintf("Out-of-Order: %d", g.outOfOrderCount), basicfont.Face7x13, 20, y, oooColor)
|
|
y += lineHeight
|
|
|
|
// Packet Loss Rate
|
|
if g.totalUpdates > 0 {
|
|
lossRate := float64(g.outOfOrderCount) / float64(g.totalUpdates+g.outOfOrderCount) * 100
|
|
lossColor := color.RGBA{0, 255, 0, 255}
|
|
if lossRate > 10 {
|
|
lossColor = color.RGBA{255, 0, 0, 255}
|
|
} else if lossRate > 5 {
|
|
lossColor = color.RGBA{255, 165, 0, 255}
|
|
}
|
|
text.Draw(screen, fmt.Sprintf("Loss Rate: %.1f%%", lossRate), basicfont.Face7x13, 20, y, lossColor)
|
|
y += lineHeight
|
|
}
|
|
|
|
// Client Prediction Stats
|
|
text.Draw(screen, fmt.Sprintf("Pending Inputs: %d", g.pendingInputCount), basicfont.Face7x13, 20, y, color.White)
|
|
y += lineHeight
|
|
|
|
corrColor := color.RGBA{0, 255, 0, 255}
|
|
if g.correctionCount > 100 {
|
|
corrColor = color.RGBA{255, 165, 0, 255}
|
|
}
|
|
if g.correctionCount > 500 {
|
|
corrColor = color.RGBA{255, 0, 0, 255}
|
|
}
|
|
text.Draw(screen, fmt.Sprintf("Corrections: %d", g.correctionCount), basicfont.Face7x13, 20, y, corrColor)
|
|
y += lineHeight
|
|
|
|
// Current Correction Magnitude
|
|
corrMag := math.Sqrt(g.correctionX*g.correctionX + g.correctionY*g.correctionY)
|
|
if corrMag > 0.1 {
|
|
text.Draw(screen, fmt.Sprintf("Corr Mag: %.1f", corrMag), basicfont.Face7x13, 20, y, color.RGBA{255, 165, 0, 255})
|
|
} else {
|
|
text.Draw(screen, "Corr Mag: 0.0", basicfont.Face7x13, 20, y, color.RGBA{0, 255, 0, 255})
|
|
}
|
|
y += lineHeight
|
|
|
|
// Server Sequence
|
|
text.Draw(screen, fmt.Sprintf("Server Seq: %d", g.lastRecvSeq), basicfont.Face7x13, 20, y, color.White)
|
|
}
|