Private
Public Access
1
0
Files
EscapeFromTeacher/cmd/client/main.go
Sebastian Unterschütz e71fd6f0ee
Some checks failed
Dynamic Branch Deploy / build-and-deploy (push) Failing after 1m33s
add presentation mode: implement presentation logic, QR code support, animated quotes, assets display, and emotes
2026-04-22 20:00:48 +02:00

1170 lines
33 KiB
Go

package main
import (
"fmt"
"image/color"
_ "image/jpeg" // JPEG-Decoder
_ "image/png" // PNG-Decoder
"log"
mrand "math/rand"
"sort"
"strings"
"sync"
"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"
)
// --- KONFIGURATION ---
const (
ScreenWidth = 1280
ScreenHeight = 720
StateMenu = 0
StateLobby = 1
StateGame = 2
StateLeaderboard = 3
StatePresentation = 4
RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich)
)
var (
ColText = color.White
ColBtnNormal = color.RGBA{40, 44, 52, 255}
ColSky = color.RGBA{135, 206, 235, 255}
ColGrass = color.RGBA{34, 139, 34, 255}
ColDirt = color.RGBA{101, 67, 33, 255}
)
// trailPoint speichert eine Position für den Player-Trail
type trailPoint struct {
X, Y float64
}
// InputState speichert einen einzelnen Input für Replay
type InputState struct {
Sequence uint32
Left bool
Right bool
Jump bool
Down bool
JoyX float64 // Analoger Joystick-Wert (-1.0 bis 1.0)
}
type MovingPlatform struct {
ChunkID string
ObjectIdx int
AssetID string
CurrentX float64
CurrentY float64
StartX float64
StartY float64
EndX float64
EndY float64
Speed float64
Direction float64
IsActive bool
HitboxW float64
HitboxH float64
DrawOffX float64
DrawOffY float64
HitboxOffX float64
HitboxOffY float64
}
// --- GAME STRUCT ---
type Game struct {
appState int
wsConn *wsConn // WebSocket für WASM
connGeneration int // Erhöht bei jedem Disconnect; macht alte WS-Handler ungültig
isConnecting bool // Guard gegen mehrfaches connectAndStart()
gameState game.GameState
stateMutex sync.Mutex
connected bool
world *game.World
assetsImages map[string]*ebiten.Image
// Spieler Info
playerName string
playerCode string // Eindeutiger UUID für Leaderboard
roomID string
activeField string // "name" oder "room" oder "teamname"
gameMode string // "solo" oder "coop"
isOffline bool // Läuft das Spiel lokal ohne Server?
offlineMovingPlatforms []*MovingPlatform // Lokale bewegende Plattformen für Offline-Modus
godModeEndTime time.Time
magnetEndTime time.Time
doubleJumpEndTime time.Time
isHost bool
teamName string // Team-Name für Coop beim Game Over
// Leaderboard
leaderboard []game.LeaderboardEntry
scoreSubmitted bool
showLeaderboard bool
leaderboardMutex sync.Mutex
// Lobby State (für Change Detection)
lastPlayerCount int
lastStatus string
// Client Prediction
predictedX float64 // Vorhergesagte Position
predictedY float64
predictedVX float64
predictedVY float64
predictedGround bool
predictedOnWall bool
predictedHasDoubleJump bool // Lokale Kopie des Double-Jump-Powerups
predictedDoubleJumpUsed bool // Wurde zweiter Sprung schon verbraucht?
currentSpeed float64 // Aktuelle Scroll-Geschwindigkeit vom Server
inputSequence uint32 // Sequenznummer für Inputs
pendingInputs map[uint32]InputState // Noch nicht bestätigte Inputs
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
predictionMutex sync.Mutex // Mutex für pendingInputs
lastRecvSeq uint32 // Letzte empfangene Server-Sequenznummer (für Out-of-Order-Erkennung)
lastInputTime time.Time // Letzter Input-Send (für 20 TPS Throttling)
// Interpolation (60fps Draw ↔ 20hz Physics)
prevPredictedX float64 // Position vor letztem Physics-Step (für Interpolation)
prevPredictedY float64
lastPhysicsTime time.Time // Zeitpunkt des letzten Physics-Steps
// Jump Buffer: Sprung kurz vor Landung speichern → löst beim Aufkommen aus
jumpBufferFrames int // Countdown in Physics-Frames (bei 0: kein Buffer)
// Coyote Time: Sprung kurz nach Abgang von Kante erlauben
coyoteFrames int // Countdown in Physics-Frames
// Smooth Correction (Debug-Info)
correctionX float64 // Letzte Korrektur-Magnitude X
correctionY float64 // Letzte Korrektur-Magnitude Y
// Visueller Korrektur-Offset: Physics springt sofort, Display folgt sanft
// display_pos = predicted + correctionOffset (blendet harte Korrekturen aus)
correctionOffsetX float64
correctionOffsetY float64
// Screen Shake
shakeFrames int
shakeIntensity float64
shakeBuffer *ebiten.Image
// Particle System
particles []Particle
particlesMutex sync.Mutex
lastGroundState bool // Für Landing-Detection
lastCollectedCoins map[string]bool // Für Coin-Partikel
lastCollectedPowerups map[string]bool // Für Powerup-Partikel
lastPlayerStates map[string]game.PlayerState // Für Death-Partikel
trail []trailPoint // Player Trail
// Highscore
localHighscore int
roundStartTime time.Time // Startzeit der aktuellen Runde (für Solo)
// Audio System
audio *AudioSystem
// Zitate / Sprüche
teacherQuote game.Quote
teacherQuoteTime time.Time
milestoneQuote game.Quote
milestoneQuoteTime time.Time
deathQuote game.Quote
lastMilestone int
// Presentation Mode
presQuote game.Quote
presQuoteTime time.Time
presAssets []presAssetInstance
lastPresUpdate time.Time
presQRCode *ebiten.Image
// Kamera
camX float64
// Touch State
joyBaseX, joyBaseY float64
joyStickX, joyStickY float64
joyActive bool
joyTouchID ebiten.TouchID
joyRadius float64 // Joystick-Radius (skaliert mit Screen)
btnJumpActive bool
btnJumpPressed bool // Jump wird gerade gehalten (visuelles Feedback)
btnDownActive bool // Down/FastFall-Button gedrückt
keyboardUsed bool // Wurde Tastatur benutzt?
lastCanvasHeight int // Cache der Canvas-Höhe für Touch-Input
lastCanvasWidth int // Cache der Canvas-Breite für Touch-Input
downBtnX, downBtnY float64 // Zentrum des Down-Buttons
downBtnR float64 // Radius des Down-Buttons
// Debug Stats
showDebug bool // Debug-Overlay anzeigen (F3 zum Umschalten)
fpsCounter int // Frame-Zähler
fpsSampleTime time.Time // Letzter FPS-Sample
currentFPS float64 // Aktuelle FPS
lastUpdateTime time.Time // Letzte Server-Update Zeit
updateLatency float64 // Latenz zum letzten Update (ms)
correctionCount int // Anzahl der Korrekturen
outOfOrderCount int // Anzahl verworfener Out-of-Order Pakete
totalUpdates int // Gesamtzahl empfangener Updates
pendingInputCount int // Anzahl pending Inputs
}
func NewGame() *Game {
g := &Game{
appState: StateMenu,
world: game.NewWorld(),
assetsImages: make(map[string]*ebiten.Image),
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
playerName: "Student",
activeField: "",
gameMode: "",
pendingInputs: make(map[uint32]InputState),
leaderboard: make([]game.LeaderboardEntry, 0),
// Particle tracking
lastCollectedCoins: make(map[string]bool),
lastCollectedPowerups: make(map[string]bool),
lastPlayerStates: make(map[string]game.PlayerState),
// Audio System
audio: NewAudioSystem(),
// Debug Stats
fpsSampleTime: time.Now(),
lastUpdateTime: time.Now(),
// Interpolation
lastPhysicsTime: time.Now(),
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150,
}
g.loadAssets()
g.loadOrCreatePlayerCode()
g.localHighscore = g.loadHighscore()
// Gespeicherten Namen laden
savedName := g.loadPlayerName()
if savedName != "" {
g.playerName = savedName
}
return g
}
// loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert
// --- UPDATE ---
func (g *Game) Update() error {
// FPS Tracking
g.fpsCounter++
if time.Since(g.fpsSampleTime) >= time.Second {
g.currentFPS = float64(g.fpsCounter) / time.Since(g.fpsSampleTime).Seconds()
g.fpsCounter = 0
g.fpsSampleTime = time.Now()
}
// Debug Toggle (F3)
if inpututil.IsKeyJustPressed(ebiten.KeyF3) {
g.showDebug = !g.showDebug
}
// Presentation Toggle (F1)
if inpututil.IsKeyJustPressed(ebiten.KeyF1) {
if g.appState == StatePresentation {
g.appState = StateMenu
g.disconnectFromServer()
} else {
g.appState = StatePresentation
g.presAssets = nil // Reset assets
g.presQuoteTime = time.Now() // Force immediate first quote
// Setup Server Connection for Presentation Mode
g.gameMode = "coop" // Use coop logic on server
g.isHost = true
g.roomID = "PRES" + generateRoomCode()
g.playerName = "PRESENTATION"
// Start connection process in background
go g.connectAndStart()
// Generate QR Code URL
joinURL := "https://escape-from-school.de/?room=" + g.roomID
g.presQRCode = generateQRCode(joinURL)
}
}
// Pending Inputs zählen für Debug
g.predictionMutex.Lock()
g.pendingInputCount = len(g.pendingInputs)
g.predictionMutex.Unlock()
// Aktuellen Status einmalig (thread-safe) lesen
g.stateMutex.Lock()
currentStatus := g.gameState.Status
g.stateMutex.Unlock()
// Game Over Handling
if g.appState == StateGame && currentStatus == "GAMEOVER" {
backBtnW, backBtnH := 120, 40
if isHit(20, 20, backBtnW, backBtnH) {
g.returnToMenu()
log.Println("🔙 Zurück zum Menü (Back Button)")
return nil
}
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
g.returnToMenu()
log.Println("🔙 Zurück zum Menü (ESC)")
return nil
}
if g.isHost {
g.handleGameOverInput()
}
}
// COUNTDOWN/RUNNING/PRESENTATION-Übergang: AppState auf StateGame setzen + JS benachrichtigen
if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING" || currentStatus == "PRESENTATION") && g.appState != StateGame && g.appState != StatePresentation {
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus)
g.appState = StateGame
g.notifyGameStarted()
}
if currentStatus == "RUNNING" && g.lastStatus != "RUNNING" {
g.audio.PlayMusic()
g.roundStartTime = time.Now()
}
if currentStatus == "GAMEOVER" && g.lastStatus == "RUNNING" {
g.audio.StopMusic()
g.deathQuote = game.GetRandomQuote()
if g.gameMode == "solo" {
g.verifyRoundResult()
}
}
g.lastStatus = currentStatus
switch g.appState {
case StateMenu:
g.updateMenu()
case StateLobby:
g.updateLobby()
case StateGame:
g.UpdateGame()
case StateLeaderboard:
g.updateLeaderboard()
case StatePresentation:
g.updatePresentation()
}
return nil
}
func (g *Game) updateMenu() {
g.handleMenuInput()
// Volume Sliders (unten links)
volumeX := 20
volumeY := ScreenHeight - 100
sliderWidth := 200
sliderHeight := 10
// Music Volume Slider
musicSliderY := volumeY + 10
if isSliderHit(volumeX, musicSliderY, sliderWidth, sliderHeight) {
newVolume := getSliderValue(volumeX, sliderWidth)
g.audio.SetMusicVolume(newVolume)
return
}
// SFX Volume Slider
sfxSliderY := volumeY + 50
if isSliderHit(volumeX, sfxSliderY, sliderWidth, sliderHeight) {
newVolume := getSliderValue(volumeX, sliderWidth)
g.audio.SetSFXVolume(newVolume)
// Test-Sound abspielen
g.audio.PlayCoin()
return
}
// Leaderboard Button
lbBtnW, lbBtnH := 200, 50
lbBtnX := ScreenWidth - lbBtnW - 20
lbBtnY := 20
if isHit(lbBtnX, lbBtnY, lbBtnW, lbBtnH) {
g.appState = StateLeaderboard
if !g.connected {
go g.connectForLeaderboard()
}
return
}
// Name-Feld
fieldW, fieldH := 250, 40
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 150
if isHit(nameX, nameY, fieldW, fieldH) {
g.activeField = "name"
return
}
// Mode-Buttons
btnW, btnH := 200, 60
soloX := ScreenWidth/2 - btnW - 20
coopX := ScreenWidth/2 + 20
btnY := ScreenHeight/2 - 20
if isHit(soloX, btnY, btnW, btnH) {
// SOLO MODE (Offline by default)
if g.playerName == "" {
g.playerName = "Player"
}
g.gameMode = "solo"
g.isHost = true
g.startOfflineGame()
} else if isHit(coopX, btnY, btnW, btnH) {
// CO-OP MODE
if g.playerName == "" {
g.playerName = "Player"
}
g.gameMode = "coop"
g.roomID = generateRoomCode()
g.isHost = true
g.appState = StateLobby
go g.connectAndStart()
}
// Join Button (unten)
joinW, joinH := 300, 50
joinX := ScreenWidth/2 - joinW/2
joinY := ScreenHeight/2 + 100
if isHit(joinX, joinY, joinW, joinH) {
g.activeField = "room"
}
// Join Code Feld
if g.activeField == "room" {
roomFieldW := 200
roomFieldX := ScreenWidth/2 - roomFieldW/2
roomFieldY := ScreenHeight/2 + 160
if isHit(roomFieldX, roomFieldY, roomFieldW, 40) {
// Stay in room field
} else if isHit(roomFieldX+roomFieldW+20, roomFieldY, 100, 40) {
// JOIN button next to code field
if g.roomID != "" && g.playerName != "" {
g.gameMode = "coop"
g.isHost = false
g.appState = StateLobby
go g.connectAndStart()
}
}
}
}
func (g *Game) updateLobby() {
// Start Button (nur für Host)
if g.isHost {
btnW, btnH := 200, 60
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight - 150
if isHit(btnX, btnY, btnW, btnH) {
// START GAME
g.sendStartRequest()
}
}
// Zurück Button
if isHit(50, 50, 100, 40) {
g.disconnectFromServer()
g.appState = StateMenu
g.connected = false
g.stateMutex.Lock()
g.gameState = game.GameState{Players: make(map[string]game.PlayerState)}
g.stateMutex.Unlock()
}
// Lobby State Change Detection (für HTML-Updates)
g.stateMutex.Lock()
currentPlayerCount := len(g.gameState.Players)
g.stateMutex.Unlock()
if currentPlayerCount != g.lastPlayerCount {
g.lastPlayerCount = currentPlayerCount
g.sendLobbyUpdateToJS()
}
}
// --- DRAW ---
func (g *Game) Draw(screen *ebiten.Image) {
// In WASM: Nur das Spiel zeichnen, kein Menü/Lobby (HTML übernimmt das)
// In Native: Alles zeichnen
g.draw(screen)
}
// draw ist die plattform-übergreifende Zeichenfunktion
func (g *Game) draw(screen *ebiten.Image) {
switch g.appState {
case StateMenu:
g.drawMenu(screen)
case StateLobby:
g.drawLobby(screen)
case StateGame:
g.DrawGame(screen)
case StateLeaderboard:
g.drawLeaderboard(screen)
case StatePresentation:
g.drawPresentation(screen)
}
}
// drawMenu, drawLobby, drawLeaderboard sind in draw_wasm.go und draw_native.go definiert
func (g *Game) DrawMenu(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Titel
title := "ESCAPE FROM TEACHER"
text.Draw(screen, title, basicfont.Face7x13, ScreenWidth/2-80, 100, ColText)
if g.localHighscore > 0 {
hsText := fmt.Sprintf("Persönlicher Highscore: %d", g.localHighscore)
text.Draw(screen, hsText, basicfont.Face7x13, ScreenWidth/2-70, 120, color.RGBA{255, 215, 0, 255})
}
// Name-Feld
fieldW := 250
nameX := ScreenWidth/2 - fieldW/2
nameY := ScreenHeight/2 - 150
col := color.RGBA{50, 50, 60, 255}
if g.activeField == "name" {
col = color.RGBA{70, 70, 80, 255}
}
vector.DrawFilledRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, col, false)
vector.StrokeRect(screen, float32(nameX), float32(nameY), float32(fieldW), 40, 1, color.White, false)
display := g.playerName
if g.activeField == "name" && (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
text.Draw(screen, "Name: "+display, basicfont.Face7x13, nameX+10, nameY+25, ColText)
// Mode Selection
text.Draw(screen, "Select Game Mode:", basicfont.Face7x13, ScreenWidth/2-60, ScreenHeight/2-60, ColText)
// SOLO Button
btnW, btnH := 200, 60
soloX := ScreenWidth/2 - btnW - 20
btnY := ScreenHeight/2 - 20
vector.DrawFilledRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(soloX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "SOLO", basicfont.Face7x13, soloX+80, btnY+35, ColText)
// CO-OP Button
coopX := ScreenWidth/2 + 20
vector.DrawFilledRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "CO-OP (Host)", basicfont.Face7x13, coopX+45, btnY+35, ColText)
// Join Section
joinY := ScreenHeight/2 + 100
text.Draw(screen, "Or join a room:", basicfont.Face7x13, ScreenWidth/2-60, joinY, color.Gray{200})
if g.activeField == "room" {
roomFieldW := 200
roomFieldX := ScreenWidth/2 - roomFieldW/2
roomFieldY := ScreenHeight/2 + 160
col := color.RGBA{70, 70, 80, 255}
vector.DrawFilledRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, col, false)
vector.StrokeRect(screen, float32(roomFieldX), float32(roomFieldY), float32(roomFieldW), 40, 1, color.White, false)
display := g.roomID
if (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
text.Draw(screen, display, basicfont.Face7x13, roomFieldX+10, roomFieldY+25, ColText)
// Join Button
joinBtnX := roomFieldX + roomFieldW + 20
vector.DrawFilledRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, color.RGBA{0, 150, 0, 255}, false)
vector.StrokeRect(screen, float32(joinBtnX), float32(roomFieldY), 100, 40, 2, color.White, false)
text.Draw(screen, "JOIN", basicfont.Face7x13, joinBtnX+30, roomFieldY+25, ColText)
} else {
joinBtnW := 300
joinBtnX := ScreenWidth/2 - joinBtnW/2
joinBtnY := ScreenHeight/2 + 120
vector.DrawFilledRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, ColBtnNormal, false)
vector.StrokeRect(screen, float32(joinBtnX), float32(joinBtnY), float32(joinBtnW), 50, 2, color.White, false)
text.Draw(screen, "Join with Code", basicfont.Face7x13, joinBtnX+90, joinBtnY+30, ColText)
}
// Leaderboard Button
lbBtnW := 200
lbBtnX := ScreenWidth - lbBtnW - 20
lbBtnY := 20
vector.DrawFilledRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, ColBtnNormal, false)
vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false)
text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255})
// Volume Controls (unten links)
volumeX := 20
volumeY := ScreenHeight - 100
// Music Volume
text.Draw(screen, "Music Volume:", basicfont.Face7x13, volumeX, volumeY, ColText)
g.drawVolumeSlider(screen, volumeX, volumeY+10, 200, g.audio.GetMusicVolume())
// SFX Volume
text.Draw(screen, "SFX Volume:", basicfont.Face7x13, volumeX, volumeY+40, ColText)
g.drawVolumeSlider(screen, volumeX, volumeY+50, 200, g.audio.GetSFXVolume())
text.Draw(screen, "WASD / Arrows - SPACE to Jump - M to Mute", basicfont.Face7x13, ScreenWidth/2-130, ScreenHeight-15, color.Gray{150})
}
func (g *Game) DrawLobby(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Titel
text.Draw(screen, "LOBBY", basicfont.Face7x13, ScreenWidth/2-20, 80, ColText)
// Room Code (groß anzeigen)
text.Draw(screen, "Room Code:", basicfont.Face7x13, ScreenWidth/2-40, 150, color.Gray{200})
codeBoxW, codeBoxH := 300, 60
codeBoxX := ScreenWidth/2 - codeBoxW/2
codeBoxY := 170
vector.DrawFilledRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), color.RGBA{50, 50, 60, 255}, false)
vector.StrokeRect(screen, float32(codeBoxX), float32(codeBoxY), float32(codeBoxW), float32(codeBoxH), 3, color.RGBA{100, 200, 100, 255}, false)
text.Draw(screen, g.roomID, basicfont.Face7x13, codeBoxX+100, codeBoxY+35, color.RGBA{100, 255, 100, 255})
// Spieler-Liste
g.stateMutex.Lock()
playerCount := len(g.gameState.Players)
// Spieler in sortierte Liste konvertieren (damit sie nicht flackern)
type PlayerEntry struct {
ID string
Name string
IsHost bool
}
players := make([]PlayerEntry, 0, playerCount)
hostID := g.gameState.HostID
for id, p := range g.gameState.Players {
name := p.Name
if name == "" {
name = id
}
isHost := (id == hostID)
players = append(players, PlayerEntry{
ID: id,
Name: name,
IsHost: isHost,
})
}
g.stateMutex.Unlock()
// Sortieren: Host zuerst, dann alphabetisch nach Name
sort.SliceStable(players, func(i, j int) bool {
if players[i].IsHost {
return true
}
if players[j].IsHost {
return false
}
return players[i].Name < players[j].Name
})
text.Draw(screen, fmt.Sprintf("Players (%d/16):", playerCount), basicfont.Face7x13, 100, 280, ColText)
y := 310
for _, p := range players {
name := p.Name
// Host markieren
if p.IsHost {
name += " [HOST]"
}
text.Draw(screen, "• "+name, basicfont.Face7x13, 120, y, ColText)
y += 25
if y > ScreenHeight-200 {
break
}
}
// Status
statusY := ScreenHeight - 180
statusText := "Waiting for host to start..."
statusCol := color.RGBA{200, 200, 0, 255}
if g.gameState.Status == "COUNTDOWN" {
statusText = fmt.Sprintf("Starting in %d...", g.gameState.TimeLeft)
statusCol = color.RGBA{255, 150, 0, 255}
}
text.Draw(screen, statusText, basicfont.Face7x13, ScreenWidth/2-80, statusY, statusCol)
// Start Button (nur für Host)
if g.isHost && g.gameState.Status == "LOBBY" {
btnW, btnH := 200, 60
btnX := ScreenWidth/2 - btnW/2
btnY := ScreenHeight - 150
vector.DrawFilledRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), color.RGBA{0, 180, 0, 255}, false)
vector.StrokeRect(screen, float32(btnX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
text.Draw(screen, "START GAME", basicfont.Face7x13, btnX+60, btnY+35, ColText)
}
// Zurück Button
vector.DrawFilledRect(screen, 50, 50, 100, 40, color.RGBA{150, 0, 0, 255}, false)
vector.StrokeRect(screen, 50, 50, 100, 40, 2, color.White, false)
text.Draw(screen, "< Back", basicfont.Face7x13, 65, 75, ColText)
}
func (g *Game) Layout(w, h int) (int, int) {
// Nutze die echte Window-Größe (Mobile: ~360px Höhe, Desktop: 720px+ Höhe)
// Das erlaubt dynamische Anpassung an verschiedene Bildschirmgrößen
return w, h
}
// --- HELPER ---
// GetFloorY gibt die Y-Position des Bodens basierend auf der aktuellen Bildschirmhöhe zurück
// WICHTIG: Kann nicht direkt aufgerufen werden, braucht Screen-Höhe als Parameter!
func GetFloorYFromHeight(screenHeight int) float64 {
h := screenHeight
if h == 0 {
// Fallback wenn keine Höhe verfügbar
h = ScreenHeight // 720
log.Printf("⚠️ GetFloorY: Screen height is 0, using fallback: %d", h)
}
// Ziel: Gameplay füllt den Bildschirm optimal aus
// Erde-Tiefe: ~100px (kompakt, damit mehr Gameplay-Raum bleibt)
dirtDepth := 100.0
// Berechne Boden-Position: möglichst weit unten
floorY := float64(h) - dirtDepth
// Minimum-Check: Bei sehr kleinen Bildschirmen (< 300px) mindestens 70% Höhe
minFloorY := float64(h) * 0.7
if floorY < minFloorY {
floorY = minFloorY
}
return floorY
}
// GetScale gibt den Scale-Faktor zurück um die Spielwelt an den Bildschirm anzupassen.
// Die Scale ist proportional zur Bildschirmhöhe relativ zur Referenz-Auflösung (720p),
// damit auf jedem Gerät dieselbe Menge Spielwelt sichtbar ist wie auf dem PC.
func GetScale() float64 {
_, h := ebiten.WindowSize()
if h == 0 {
h = ScreenHeight
}
return GetScaleFromHeight(h)
}
// GetScaleFromHeight - Scale proportional zur Bildschirmhöhe.
// scale = screenHeight / 720 (Referenz), maximal 1.0.
func GetScaleFromHeight(screenHeight int) float64 {
h := screenHeight
if h == 0 {
h = ScreenHeight
}
scale := float64(h) / float64(ScreenHeight)
if scale > 1.0 {
scale = 1.0
}
return scale
}
// WorldToScreenYWithHeight konvertiert Welt-Y zu Bildschirm-Y.
// Skaliert die Y-Achse genauso wie die X-Achse, damit das Seitenverhältnis
// der Spielwelt auf allen Geräten gleich bleibt.
func WorldToScreenYWithHeight(worldY float64, screenHeight int) float64 {
viewScale := GetScaleFromHeight(screenHeight)
// Boden (Y=540) soll am unteren Bildschirmrand erscheinen (canvasH - 100).
floorScreenY := float64(screenHeight) - 100.0
return (worldY-float64(RefFloorY))*viewScale + floorScreenY
}
func isHit(x, y, w, h int) bool {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
mx, my := ebiten.CursorPosition()
if mx >= x && mx <= x+w && my >= y && my <= y+h {
return true
}
}
for _, id := range inpututil.JustPressedTouchIDs() {
tx, ty := ebiten.TouchPosition(id)
if tx >= x && tx <= x+w && ty >= y && ty <= y+h {
return true
}
}
return false
}
func (g *Game) handleMenuInput() {
if g.activeField == "" {
return
}
// Text Eingabe
var target *string
if g.activeField == "name" {
target = &g.playerName
} else if g.activeField == "room" {
target = &g.roomID
}
if target == nil {
return
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
// Namen speichern wenn geändert
if g.activeField == "name" && g.playerName != "" {
g.savePlayerName(g.playerName)
}
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
if len(*target) > 0 {
*target = (*target)[:len(*target)-1]
}
} else {
// Für Room Code: Nur Großbuchstaben und Zahlen
chars := string(ebiten.InputChars())
if g.activeField == "room" {
chars = strings.ToUpper(chars)
}
*target += chars
}
}
func (g *Game) handleGameOverInput() {
// Team-Name Feld
fieldW := 300
fieldX := ScreenWidth/2 - fieldW/2
fieldY := ScreenHeight - 140
// Click auf Team-Name Feld?
if isHit(fieldX, fieldY, fieldW, 40) {
g.activeField = "teamname"
return
}
// Submit Button
submitBtnW := 200
submitBtnX := ScreenWidth/2 - submitBtnW/2
submitBtnY := ScreenHeight - 85
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
if g.teamName != "" {
g.submitScore() // submitScore behandelt jetzt beide Modi
}
return
}
// Tastatur-Eingabe für Team-Name
if g.activeField == "teamname" {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
if g.teamName != "" {
g.submitScore() // submitScore behandelt jetzt beide Modi
}
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
if len(g.teamName) > 0 {
g.teamName = g.teamName[:len(g.teamName)-1]
}
} else {
chars := string(ebiten.InputChars())
if len(g.teamName) < 30 { // Max 30 Zeichen
g.teamName += chars
}
}
}
}
// returnToMenu trennt die Verbindung und setzt den App-State zurück auf das Hauptmenü.
func (g *Game) returnToMenu() {
g.disconnectFromServer()
g.appState = StateMenu
g.connected = false
g.scoreSubmitted = false
g.teamName = ""
g.activeField = ""
g.stateMutex.Lock()
g.gameState = game.GameState{Players: make(map[string]game.PlayerState)}
g.stateMutex.Unlock()
}
func generateRoomCode() string {
mrand.Seed(time.Now().UnixNano())
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
code := make([]byte, 6)
for i := range code {
code[i] = chars[mrand.Intn(len(chars))]
}
return string(code)
}
// resetForNewGame setzt den gesamten Spiel-State zurück ohne die Seite neu zu laden.
// Muss vor jeder neuen Verbindung aufgerufen werden.
func (g *Game) resetForNewGame() {
// Alte Verbindung sauber trennen
g.disconnectFromServer()
// Prediction-State zurücksetzen
g.predictionMutex.Lock()
g.pendingInputs = make(map[uint32]InputState)
g.inputSequence = 0
g.lastServerSeq = 0
g.predictedX = 100
g.predictedY = 200
g.predictedVX = 0
g.predictedVY = 0
g.predictedGround = false
g.predictedOnWall = false
g.currentSpeed = 0
g.correctionOffsetX = 0
g.correctionOffsetY = 0
g.predictionMutex.Unlock()
// KRITISCH: lastRecvSeq zurücksetzen!
// Ohne diesen Reset ignoriert die Out-of-Order-Prüfung alle Nachrichten
// des neuen Spiels (neue Sequenzen < alter lastRecvSeq).
g.lastRecvSeq = 0
// Spieler-State zurücksetzen
g.isOffline = false
g.godModeEndTime = time.Time{}
g.magnetEndTime = time.Time{}
g.doubleJumpEndTime = time.Time{}
g.scoreSubmitted = false
g.lastStatus = ""
g.correctionCount = 0
g.outOfOrderCount = 0
g.totalUpdates = 0
// GameState leeren
g.stateMutex.Lock()
g.gameState = game.GameState{Players: make(map[string]game.PlayerState)}
g.stateMutex.Unlock()
// Leaderboard leeren
g.leaderboardMutex.Lock()
g.leaderboard = make([]game.LeaderboardEntry, 0)
g.leaderboardMutex.Unlock()
// Partikel leeren
g.particlesMutex.Lock()
g.particles = nil
g.particlesMutex.Unlock()
g.lastCollectedCoins = make(map[string]bool)
g.lastCollectedPowerups = make(map[string]bool)
g.lastPlayerStates = make(map[string]game.PlayerState)
}
func (g *Game) connectAndStart() {
// Guard: verhindert mehrfaches gleichzeitiges Verbinden
if g.isConnecting {
log.Println("⚠️ connectAndStart bereits aktiv, ignoriere doppelten Aufruf")
return
}
g.isConnecting = true
defer func() { g.isConnecting = false }()
g.resetForNewGame()
// Verbindung über plattformspezifische Implementierung
g.connectToServer()
}
func (g *Game) SendCommand(cmdType string) {
if !g.connected {
return
}
myID := g.getMyPlayerID()
g.publishInput(game.ClientInput{PlayerID: myID, Type: cmdType})
}
func (g *Game) SendInputWithSequence(input InputState) {
if !g.connected {
// Im Offline-Modus den Jump-Sound trotzdem lokal abspielen
if input.Jump && g.isOffline {
g.audio.PlayJump()
}
return
}
myID := g.getMyPlayerID()
// Kompletten Input-State in einer einzigen Nachricht senden.
// Vorteile gegenüber mehreren Event-Nachrichten:
// - Kein stuck-Input durch verlorene oder falsch sortierte Pakete
// - Server hat immer den vollständigen Zustand nach einem Paket
// - Weniger Nachrichten pro Frame
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "STATE",
Sequence: input.Sequence,
InputLeft: input.Left,
InputRight: input.Right,
InputJump: input.Jump,
InputDown: input.Down,
InputJoyX: input.JoyX,
})
// Jump-Sound lokal abspielen
if input.Jump {
g.audio.PlayJump()
}
}
func (g *Game) getMyPlayerID() string {
g.stateMutex.Lock()
defer g.stateMutex.Unlock()
for id, p := range g.gameState.Players {
if p.Name == g.playerName {
return id
}
}
return g.playerName
}
// submitScore, requestLeaderboard, connectForLeaderboard
// sind in connection_native.go und connection_wasm.go definiert
func (g *Game) updateLeaderboard() {
// Back Button (oben links) - Touch Support
backBtnW, backBtnH := 120, 40
backBtnX, backBtnY := 20, 20
if isHit(backBtnX, backBtnY, backBtnW, backBtnH) {
g.appState = StateMenu
return
}
// ESC = zurück zum Menü
if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
g.appState = StateMenu
return
}
}
func (g *Game) DrawLeaderboard(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Titel
text.Draw(screen, "=== TOP 10 LEADERBOARD ===", basicfont.Face7x13, ScreenWidth/2-100, 80, color.RGBA{255, 215, 0, 255})
// Leaderboard abrufen wenn leer (prüfen ohne Lock, dann ggf. nachladen)
g.leaderboardMutex.Lock()
empty := len(g.leaderboard) == 0
g.leaderboardMutex.Unlock()
if empty && g.connected {
g.requestLeaderboard()
}
g.leaderboardMutex.Lock()
y := 150
if len(g.leaderboard) == 0 {
text.Draw(screen, "Noch keine Einträge...", basicfont.Face7x13, ScreenWidth/2-80, y, color.Gray{150})
} else {
for i, entry := range g.leaderboard {
if i >= 10 {
break
}
// Eigenen Eintrag markieren
var col color.Color = color.White
marker := ""
if entry.PlayerCode == g.playerCode {
col = color.RGBA{0, 255, 0, 255}
marker = " ← DU"
}
// Medaillen
medal := ""
if i == 0 {
medal = "🥇 "
} else if i == 1 {
medal = "🥈 "
} else if i == 2 {
medal = "🥉 "
}
leaderMsg := fmt.Sprintf("%d. %s%s: %d pts%s", i+1, medal, entry.PlayerName, entry.Score, marker)
text.Draw(screen, leaderMsg, basicfont.Face7x13, ScreenWidth/2-150, y, col)
y += 30
}
}
g.leaderboardMutex.Unlock()
// Back Button (oben links)
backBtnW, backBtnH := 120, 40
backBtnX, backBtnY := 20, 20
vector.DrawFilledRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), color.RGBA{150, 0, 0, 255}, false)
vector.StrokeRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), 2, color.White, false)
text.Draw(screen, "< ZURÜCK", basicfont.Face7x13, backBtnX+20, backBtnY+25, color.White)
// Zurück-Button Anleitung
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
}
// main() ist jetzt in main_wasm.go und main_native.go definiert
// drawVolumeSlider zeichnet einen Volume-Slider
func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) {
// Hintergrund
vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false)
vector.StrokeRect(screen, float32(x), float32(y), float32(width), 10, 1, color.White, false)
// Füllstand
fillWidth := int(float64(width) * volume)
vector.DrawFilledRect(screen, float32(x), float32(y), float32(fillWidth), 10, color.RGBA{0, 200, 100, 255}, false)
// Prozent-Anzeige
pct := fmt.Sprintf("%.0f%%", volume*100)
text.Draw(screen, pct, basicfont.Face7x13, x+width+10, y+10, ColText)
}
// isSliderHit prüft, ob auf einen Slider geklickt wurde
func isSliderHit(x, y, width, height int) bool {
// Erweitere den Klickbereich vertikal für bessere Touch-Support
return isHit(x, y-10, width, height+20)
}
// getSliderValue berechnet den Slider-Wert basierend auf Mausposition
func getSliderValue(sliderX, sliderWidth int) float64 {
mx, _ := ebiten.CursorPosition()
// Bei Touch: Ersten Touch nutzen
touches := ebiten.TouchIDs()
if len(touches) > 0 {
mx, _ = ebiten.TouchPosition(touches[0])
}
// Berechne relative Position im Slider
relX := float64(mx - sliderX)
if relX < 0 {
relX = 0
}
if relX > float64(sliderWidth) {
relX = float64(sliderWidth)
}
return relX / float64(sliderWidth)
}