From 9742ccb03813fc2ed288aa1f7b3e547814e1fb18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Wed, 22 Apr 2026 18:35:58 +0200 Subject: [PATCH] enhance offline mode: add moving platforms, collision detection, and power-ups --- cmd/client/main.go | 28 +++++ cmd/client/offline_logic.go | 239 ++++++++++++++++++++++++++++++++++-- 2 files changed, 254 insertions(+), 13 deletions(-) diff --git a/cmd/client/main.go b/cmd/client/main.go index 9b41e7b..62e132b 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -55,6 +55,27 @@ type InputState struct { 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 @@ -74,6 +95,10 @@ type Game struct { 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 @@ -876,6 +901,9 @@ func (g *Game) resetForNewGame() { // 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 diff --git a/cmd/client/offline_logic.go b/cmd/client/offline_logic.go index d03ec1d..a67b3dc 100644 --- a/cmd/client/offline_logic.go +++ b/cmd/client/offline_logic.go @@ -1,7 +1,9 @@ package main import ( + "fmt" "log" + "math" "math/rand" "time" @@ -19,12 +21,14 @@ func (g *Game) startOfflineGame() { // 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, + Status: "RUNNING", + RoomID: "offline_solo", + Players: make(map[string]game.PlayerState), + WorldChunks: []game.ActiveChunk{{ChunkID: "start", X: 0}}, + CurrentSpeed: config.RunSpeed, + DifficultyFactor: 0, + CollectedCoins: make(map[string]bool), + CollectedPowerups: make(map[string]bool), } // Lokalen Spieler hinzufügen @@ -37,6 +41,8 @@ func (g *Game) startOfflineGame() { } g.stateMutex.Unlock() + g.offlineMovingPlatforms = nil + // Initialer Chunk-Library Check if len(g.world.ChunkLibrary) == 0 { log.Println("⚠️ Warnung: Keine Chunks in Library geladen!") @@ -63,7 +69,7 @@ func (g *Game) updateOfflineLoop() { elapsed := time.Since(g.roundStartTime).Seconds() - // 1. Schwierigkeit & Speed (analog zu pkg/server/room.go) + // 1. Schwierigkeit & Speed g.gameState.DifficultyFactor = elapsed / config.MaxDifficultySeconds if g.gameState.DifficultyFactor > 1.0 { g.gameState.DifficultyFactor = 1.0 @@ -77,7 +83,6 @@ func (g *Game) updateOfflineLoop() { 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] @@ -91,25 +96,73 @@ func (g *Game) updateOfflineLoop() { g.spawnOfflineChunk(mapEnd) } - // 4. Entferne alte Chunks (links aus dem Bild) + // 4. Entferne alte Chunks if len(g.gameState.WorldChunks) > 5 { - // Behalte immer mindestens die letzten paar Chunks if g.gameState.WorldChunks[0].X < g.gameState.ScrollX-2000 { + // Bereinige auch Moving Platforms des alten Chunks + oldChunkID := g.gameState.WorldChunks[0].ChunkID + newPlats := g.offlineMovingPlatforms[:0] + for _, p := range g.offlineMovingPlatforms { + if p.ChunkID != oldChunkID { + newPlats = append(newPlats, p) + } + } + g.offlineMovingPlatforms = newPlats g.gameState.WorldChunks = g.gameState.WorldChunks[1:] } } - // 5. Score Update (Distanz) + // 5. Update Moving Platforms + g.updateOfflineMovingPlatforms() + + // 6. Player State Update (Score, Powerups, Collisions) p, ok := g.gameState.Players[g.playerName] if ok && p.IsAlive { - // Grobe Score-Simulation + // Basis-Score aus Distanz p.Score = int(g.gameState.ScrollX / 10) + + // Synchronisiere Prediction-State zurück in GameState (für Rendering) + p.X = g.predictedX + p.Y = g.predictedY + p.VX = g.predictedVX + p.VY = g.predictedVY + p.OnGround = g.predictedGround + p.OnWall = g.predictedOnWall + + // Lokale Kollisionsprüfung für Coins/Powerups + g.checkOfflineCollisions(&p) + + // Powerup-Timer herunterschalten + now := time.Now() + if p.HasGodMode && now.After(g.godModeEndTime) { + p.HasGodMode = false + } + if p.HasMagnet && now.After(g.magnetEndTime) { + p.HasMagnet = false + } + if g.predictedHasDoubleJump && now.After(g.doubleJumpEndTime) { + g.predictedHasDoubleJump = false + p.HasDoubleJump = false + } + g.gameState.Players[g.playerName] = p } + + // Synchronisiere Plattform-Positionen für Renderer + syncPlats := make([]game.MovingPlatformSync, len(g.offlineMovingPlatforms)) + for i, p := range g.offlineMovingPlatforms { + syncPlats[i] = game.MovingPlatformSync{ + ChunkID: p.ChunkID, + ObjectIdx: p.ObjectIdx, + AssetID: p.AssetID, + X: p.CurrentX, + Y: p.CurrentY, + } + } + g.gameState.MovingPlatforms = syncPlats } func (g *Game) spawnOfflineChunk(atX float64) { - // Zufälligen Chunk wählen var pool []string for id := range g.world.ChunkLibrary { if id != "start" { @@ -123,5 +176,165 @@ func (g *Game) spawnOfflineChunk(atX float64) { ChunkID: randomID, X: atX, }) + + // Extrahiere Moving Platforms aus dem neuen Chunk + chunkDef := g.world.ChunkLibrary[randomID] + for i, obj := range chunkDef.Objects { + asset, ok := g.world.Manifest.Assets[obj.AssetID] + if ok && asset.Type == "moving_platform" && obj.MovingPlatform != nil { + mp := obj.MovingPlatform + p := &MovingPlatform{ + ChunkID: randomID, + ObjectIdx: i, + AssetID: obj.AssetID, + StartX: atX + mp.StartX, + StartY: mp.StartY, + EndX: atX + mp.EndX, + EndY: mp.EndY, + Speed: mp.Speed, + Direction: 1.0, + IsActive: true, + CurrentX: atX + mp.StartX, + CurrentY: mp.StartY, + HitboxW: asset.Hitbox.W, + HitboxH: asset.Hitbox.H, + DrawOffX: asset.DrawOffX, + DrawOffY: asset.DrawOffY, + HitboxOffX: asset.Hitbox.OffsetX, + HitboxOffY: asset.Hitbox.OffsetY, + } + g.offlineMovingPlatforms = append(g.offlineMovingPlatforms, p) + } + } + } +} + +func (g *Game) updateOfflineMovingPlatforms() { + for _, p := range g.offlineMovingPlatforms { + if !p.IsActive { + continue + } + + dx := p.EndX - p.StartX + dy := p.EndY - p.StartY + dist := math.Sqrt(dx*dx + dy*dy) + if dist < 1 { + continue + } + + vx := (dx / dist) * (p.Speed / 20.0) * p.Direction + vy := (dy / dist) * (p.Speed / 20.0) * p.Direction + + p.CurrentX += vx + p.CurrentY += vy + + // Ziel erreicht? Umkehren. + if p.Direction > 0 { + dToEnd := math.Sqrt(math.Pow(p.CurrentX-p.EndX, 2) + math.Pow(p.CurrentY-p.EndY, 2)) + if dToEnd < (p.Speed / 20.0) { + p.Direction = -1.0 + } + } else { + dToStart := math.Sqrt(math.Pow(p.CurrentX-p.StartX, 2) + math.Pow(p.CurrentY-p.StartY, 2)) + if dToStart < (p.Speed / 20.0) { + p.Direction = 1.0 + } + } + } +} + +func (g *Game) checkOfflineCollisions(p *game.PlayerState) { + // Hitbox des Spielers (Welt-Koordinaten) + pW, pH := 40.0, 60.0 // Default + pOffX, pOffY := 0.0, 0.0 + pDrawX, pDrawY := 0.0, 0.0 + if def, ok := g.world.Manifest.Assets["player"]; ok { + pW = def.Hitbox.W + pH = def.Hitbox.H + pOffX = def.Hitbox.OffsetX + pOffY = def.Hitbox.OffsetY + pDrawX = def.DrawOffX + pDrawY = def.DrawOffY + } + + pRect := game.Rect{ + OffsetX: p.X + pDrawX + pOffX, + OffsetY: p.Y + pDrawY + pOffY, + W: pW, + H: pH, + } + + for _, ac := range g.gameState.WorldChunks { + chunkDef := g.world.ChunkLibrary[ac.ChunkID] + for i, obj := range chunkDef.Objects { + asset, ok := g.world.Manifest.Assets[obj.AssetID] + if !ok { continue } + + objID := fmt.Sprintf("%s_%d", ac.ChunkID, i) + + // 1. COINS + if asset.Type == "coin" { + if g.gameState.CollectedCoins[objID] { continue } + + coinX := ac.X + obj.X + asset.DrawOffX + asset.Hitbox.OffsetX + coinY := obj.Y + asset.DrawOffY + asset.Hitbox.OffsetY + + // Magnet-Effekt? + if p.HasMagnet { + playerCenterX := pRect.OffsetX + pRect.W/2 + playerCenterY := pRect.OffsetY + pRect.H/2 + coinCenterX := coinX + asset.Hitbox.W/2 + coinCenterY := coinY + asset.Hitbox.H/2 + + dist := math.Sqrt(math.Pow(playerCenterX - coinCenterX, 2) + math.Pow(playerCenterY - coinCenterY, 2)) + if dist < 300 { + // Münze wird eingesammelt wenn im Magnet-Radius + g.gameState.CollectedCoins[objID] = true + p.Score += 200 // Bonus direkt auf Score + g.audio.PlayCoin() + continue + } + } + + coinRect := game.Rect{OffsetX: coinX, OffsetY: coinY, W: asset.Hitbox.W, H: asset.Hitbox.H} + if game.CheckRectCollision(pRect, coinRect) { + g.gameState.CollectedCoins[objID] = true + p.Score += 200 + g.audio.PlayCoin() + } + } + + // 2. POWERUPS + if asset.Type == "powerup" { + if g.gameState.CollectedPowerups[objID] { continue } + + puRect := game.Rect{ + OffsetX: ac.X + obj.X + asset.DrawOffX + asset.Hitbox.OffsetX, + OffsetY: obj.Y + asset.DrawOffY + asset.Hitbox.OffsetY, + W: asset.Hitbox.W, + H: asset.Hitbox.H, + } + + if game.CheckRectCollision(pRect, puRect) { + g.gameState.CollectedPowerups[objID] = true + g.audio.PlayPowerUp() + + switch obj.AssetID { + case "jumpboost": + p.HasDoubleJump = true + p.DoubleJumpUsed = false + g.predictedHasDoubleJump = true + g.predictedDoubleJumpUsed = false + g.doubleJumpEndTime = time.Now().Add(15 * time.Second) + case "godmode": + p.HasGodMode = true + g.godModeEndTime = time.Now().Add(10 * time.Second) + case "magnet": + p.HasMagnet = true + g.magnetEndTime = time.Now().Add(8 * time.Second) + } + } + } + } } }