From de87b760052b9bf9b88d02d5e43ed1d78b0f57ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Wed, 22 Apr 2026 12:37:52 +0200 Subject: [PATCH] add offline mode for solo play with local game state simulation --- cmd/client/game_render.go | 7 +- cmd/client/gameover_native.go | 7 +- cmd/client/main.go | 11 +-- cmd/client/offline_logic.go | 128 ++++++++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 cmd/client/offline_logic.go diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index a2c6817..e4e714d 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -185,9 +185,14 @@ func (g *Game) UpdateGame() { } // --- 5. INPUT SENDEN (MIT CLIENT PREDICTION, 20 TPS) --- - if g.connected && time.Since(g.lastInputTime) >= physicsStep { + 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() diff --git a/cmd/client/gameover_native.go b/cmd/client/gameover_native.go index e10aa10..b141583 100644 --- a/cmd/client/gameover_native.go +++ b/cmd/client/gameover_native.go @@ -27,8 +27,10 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) { screen.Fill(color.RGBA{20, 20, 30, 255}) // Leaderboard immer beim ersten Mal anfordern (ohne Lock hier!) - if !g.scoreSubmitted && g.gameMode == "solo" { + if !g.scoreSubmitted && g.gameMode == "solo" && !g.isOffline { g.submitScore() // submitScore() ruft requestLeaderboard() auf + } else if !g.scoreSubmitted && g.gameMode == "solo" && g.isOffline { + // Offline-Solo: Keine automatische Submission } else { // Für Coop: Nur Leaderboard anfordern, nicht submitten g.leaderboardMutex.Lock() @@ -169,6 +171,9 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) { } else if g.gameMode == "solo" && g.scoreSubmitted { // Solo: Zeige Bestätigungsmeldung text.Draw(screen, "✓ Runde verifiziert & Score eingereicht!", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-100, color.RGBA{0, 255, 0, 255}) + } else if g.gameMode == "solo" && g.isOffline { + // Offline Solo + text.Draw(screen, "Offline-Modus: Score lokal gespeichert.", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-100, color.RGBA{200, 200, 0, 255}) } else if g.gameMode == "coop" && !g.isHost { // Coop Non-Host: Warten auf Host text.Draw(screen, "Warte auf Host...", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.Gray{180}) diff --git a/cmd/client/main.go b/cmd/client/main.go index 5a83aa2..c1664cb 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -73,6 +73,7 @@ type Game struct { roomID string activeField string // "name" oder "room" oder "teamname" gameMode string // "solo" oder "coop" + isOffline bool // Läuft das Spiel lokal ohne Server? isHost bool teamName string // Team-Name für Coop beim Game Over @@ -351,15 +352,13 @@ func (g *Game) updateMenu() { btnY := ScreenHeight/2 - 20 if isHit(soloX, btnY, btnW, btnH) { - // SOLO MODE + // SOLO MODE (Offline by default) if g.playerName == "" { g.playerName = "Player" } g.gameMode = "solo" - g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano()) g.isHost = true - g.appState = StateGame - go g.connectAndStart() + g.startOfflineGame() } else if isHit(coopX, btnY, btnW, btnH) { // CO-OP MODE if g.playerName == "" { @@ -926,6 +925,10 @@ func (g *Game) SendCommand(cmdType string) { 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 } diff --git a/cmd/client/offline_logic.go b/cmd/client/offline_logic.go new file mode 100644 index 0000000..cd8fc9f --- /dev/null +++ b/cmd/client/offline_logic.go @@ -0,0 +1,128 @@ +package main + +import ( + "log" + "math/rand" + "time" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// startOfflineGame initialisiert eine lokale Spielrunde ohne Server +func (g *Game) startOfflineGame() { + g.resetForNewGame() + g.isOffline = true + g.connected = false // Explizit offline + g.appState = StateGame + + // Initialen GameState lokal erstellen + g.stateMutex.Lock() + g.gameState = game.GameState{ + Status: "RUNNING", + RoomID: "offline_solo", + Players: make(map[string]game.PlayerState), + WorldChunks: []game.ActiveChunk{{ChunkID: "start", X: 0}}, + CurrentSpeed: config.RunSpeed, + DifficultyFactor: 0, + } + + // Lokalen Spieler hinzufügen + g.gameState.Players[g.playerName] = game.PlayerState{ + ID: g.playerName, + Name: g.playerName, + X: 100, + Y: 200, + IsAlive: true, + } + g.stateMutex.Unlock() + + // Initialer Chunk-Library Check + if len(g.world.ChunkLibrary) == 0 { + log.Println("⚠️ Warnung: Keine Chunks in Library geladen!") + } + + g.roundStartTime = time.Now() + g.predictedX = 100 + g.predictedY = 200 + g.currentSpeed = config.RunSpeed + + g.audio.PlayMusic() + g.notifyGameStarted() + log.Println("🕹️ Offline-Modus gestartet") +} + +// updateOfflineLoop simuliert die Server-Logik lokal +func (g *Game) updateOfflineLoop() { + if !g.isOffline || g.gameState.Status != "RUNNING" { + return + } + + g.stateMutex.Lock() + defer g.stateMutex.Unlock() + + elapsed := time.Since(g.roundStartTime).Seconds() + + // 1. Schwierigkeit & Speed (analog zu pkg/server/room.go) + g.gameState.DifficultyFactor = elapsed / config.MaxDifficultySeconds + if g.gameState.DifficultyFactor > 1.0 { + g.gameState.DifficultyFactor = 1.0 + } + + speedIncrease := g.gameState.DifficultyFactor * g.gameState.DifficultyFactor * 18.0 + g.gameState.CurrentSpeed = config.RunSpeed + speedIncrease + g.currentSpeed = g.gameState.CurrentSpeed + + // 2. Scrolling + g.gameState.ScrollX += g.currentSpeed + + // 3. Chunks nachladen + // Wenn das Ende der Map nah am rechten Rand ist, neuen Chunk spawnen + mapEnd := 0.0 + for _, c := range g.gameState.WorldChunks { + chunkDef := g.world.ChunkLibrary[c.ChunkID] + end := c.X + float64(chunkDef.Width*config.TileSize) + if end > mapEnd { + mapEnd = end + } + } + + if mapEnd < g.gameState.ScrollX+2500 { + g.spawnOfflineChunk(mapEnd) + } + + // 4. Entferne alte Chunks (links aus dem Bild) + if len(g.gameState.WorldChunks) > 5 { + // Behalte immer mindestens die letzten paar Chunks + if g.gameState.WorldChunks[0].X < g.gameState.ScrollX-2000 { + g.gameState.WorldChunks = g.gameState.WorldChunks[1:] + } + } + + // 5. Score Update (Distanz) + p, ok := g.gameState.Players[g.playerName] + if ok && p.IsAlive { + // Grobe Score-Simulation + p.Score = int(g.gameState.ScrollX / 10) + g.gameState.Players[g.playerName] = p + } +} + +func (g *Game) spawnOfflineChunk(atX float64) { + // Zufälligen Chunk wählen + var pool []string + for id := range g.world.ChunkLibrary { + if id != "start" { + pool = append(pool) + pool = append(pool, id) + } + } + + if len(pool) > 0 { + randomID := pool[rand.Intn(len(pool))] + g.gameState.WorldChunks = append(g.gameState.WorldChunks, game.ActiveChunk{ + ChunkID: randomID, + X: atX, + }) + } +}