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 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 // 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 } // 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-Übergang: AppState auf StateGame setzen + JS benachrichtigen if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING") && g.appState != StateGame { 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() 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() } 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) } } // 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) }