package server import ( "log" "math" "math/rand" "sync" "time" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics" "github.com/nats-io/nats.go" ) type ServerPlayer struct { ID string Name string X, Y float64 VX, VY float64 OnGround bool OnWall bool // Ist an einer Wand OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht InputX float64 // -1 (Links), 0, 1 (Rechts) InputJump bool // Sprung-Input (für Physik-Engine) InputDown bool // Nach-Unten-Input (für Fast Fall) LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz Score int DistanceScore int // Score basierend auf zurückgelegter Distanz BonusScore int // Score aus Coins und anderen Boni IsAlive bool IsSpectator bool State string // Aktueller State (z.B. Emote) // Powerups HasDoubleJump bool // Doppelsprung aktiv? DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt? DoubleJumpEndTime time.Time // Wann endet Double Jump? HasGodMode bool // Godmode aktiv? GodModeEndTime time.Time // Wann endet Godmode? HasMagnet bool // Magnet aktiv? MagnetEndTime time.Time // Wann endet Magnet? } type MovingPlatform struct { ChunkID string // Welcher Chunk ObjectIdx int // Index im Chunk AssetID string // Asset-ID CurrentX float64 // Aktuelle Position X (Welt-Koordinaten) CurrentY float64 // Aktuelle Position Y StartX float64 // Start-Position X (Welt-Koordinaten) StartY float64 // Start-Position Y EndX float64 // End-Position X (Welt-Koordinaten) EndY float64 // End-Position Y Speed float64 // Geschwindigkeit Direction float64 // 1.0 = zu End, -1.0 = zu Start IsActive bool // Hat die Bewegung bereits begonnen? HitboxW float64 // Cached Hitbox HitboxH float64 DrawOffX float64 DrawOffY float64 HitboxOffX float64 HitboxOffY float64 } type Room struct { ID string NC *nats.Conn World *game.World Mutex sync.RWMutex Players map[string]*ServerPlayer ActiveChunks []game.ActiveChunk Colliders []game.Collider Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER" GlobalScrollX float64 MapEndX float64 Countdown int NextStart time.Time HostID string HostPlayerCode string // PlayerCode des Hosts (für Coop-Score Submission) TeamName string // Name des Teams (vom Host gesetzt) CollectedCoins map[string]bool // Key: "chunkID_objectIndex" CollectedPowerups map[string]bool // Key: "chunkID_objectIndex" ScoreAccum float64 // Akkumulator für Distanz-Score CurrentSpeed float64 // Aktuelle Geschwindigkeit (steigt mit der Zeit) DifficultyFactor float64 // 0.0 (Start) bis 1.0 (Maximum) – skaliert Schwierigkeit GameStartTime time.Time // Wann das Spiel gestartet wurde // Chunk-Pool für fairen Random-Spawn ChunkPool []string // Verfügbare Chunks für nächsten Spawn ChunkSpawnedCount map[string]int // Wie oft wurde jeder Chunk gespawnt MovingPlatforms []*MovingPlatform // Aktive bewegende Plattformen firstBroadcast bool // Wurde bereits geloggt? sequence uint32 // Sequenznummer für Broadcasts stopChan chan struct{} // Cache für Spieler-Hitbox aus Assets pW, pH float64 pDrawOffX float64 pDrawOffY float64 pHitboxOffX float64 pHitboxOffY float64 } // Konstruktor func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { r := &Room{ ID: id, NC: nc, World: w, Players: make(map[string]*ServerPlayer), Status: "LOBBY", stopChan: make(chan struct{}), CollectedCoins: make(map[string]bool), CollectedPowerups: make(map[string]bool), ChunkSpawnedCount: make(map[string]int), CurrentSpeed: config.RunSpeed, // Startet mit normaler Geschwindigkeit pW: 40, pH: 60, // Fallback } // Initialisiere Chunk-Pool mit allen verfügbaren Chunks r.RefillChunkPool() // Player Werte aus Manifest laden if def, ok := w.Manifest.Assets["player"]; ok { r.pW = def.Hitbox.W r.pH = def.Hitbox.H r.pDrawOffX = def.DrawOffX r.pDrawOffY = def.DrawOffY r.pHitboxOffX = def.Hitbox.OffsetX r.pHitboxOffY = def.Hitbox.OffsetY log.Printf("🎮 Player Hitbox geladen: DrawOff=(%.1f, %.1f), HitboxOff=(%.1f, %.1f), Size=(%.1f, %.1f)", r.pDrawOffX, r.pDrawOffY, r.pHitboxOffX, r.pHitboxOffY, r.pW, r.pH) } // Start-Chunk startChunk := game.ActiveChunk{ChunkID: "start", X: 0} r.ActiveChunks = append(r.ActiveChunks, startChunk) r.MapEndX = 1280 // DEBUG: Verfügbare Chunks anzeigen log.Printf("🗂️ Raum %s: ChunkLibrary hat %d Einträge", id, len(w.ChunkLibrary)) for cid := range w.ChunkLibrary { log.Printf(" - %s", cid) } // Erste Chunks generieren r.SpawnNextChunk() r.SpawnNextChunk() r.Colliders = r.World.GenerateColliders(r.ActiveChunks) // DEBUG: Collider-Typen zählen obstacleCount := 0 platformCount := 0 for _, c := range r.Colliders { if c.Type == "obstacle" { obstacleCount++ } else if c.Type == "platform" { platformCount++ } } log.Printf("🔍 Initial Colliders: %d total (%d obstacles, %d platforms)", len(r.Colliders), obstacleCount, platformCount) log.Printf("🎬 Raum %s gestartet mit %d Chunks", id, len(r.ActiveChunks)) for _, ac := range r.ActiveChunks { log.Printf(" - Chunk '%s' bei X=%.0f", ac.ChunkID, ac.X) } return r } // --- MAIN LOOP --- func (r *Room) RunLoop() { // 20 Tick pro Sekunde ticker := time.NewTicker(time.Second / 20) defer ticker.Stop() for { select { case <-r.stopChan: return case <-ticker.C: r.Mutex.RLock() status := r.Status r.Mutex.RUnlock() // Stoppe Updates wenn Spiel vorbei ist if status == "GAMEOVER" { r.Broadcast() // Ein letztes Mal broadcasten time.Sleep(5 * time.Second) // Kurz warten damit Clients den GAMEOVER State sehen return // Beende Loop } r.Update() r.Broadcast() } } } // --- PLAYER MANAGEMENT --- func (r *Room) AddPlayer(id, name string) { r.Mutex.Lock() defer r.Mutex.Unlock() if _, exists := r.Players[id]; exists { return } // Spawn-Position berechnen playerIndex := len(r.Players) spawnX := 100.0 + float64(playerIndex*150) p := &ServerPlayer{ ID: id, Name: name, X: spawnX, Y: 400, // Spawn über dem Gras (Y=540), fällt dann auf den Boden OnGround: false, Score: 0, IsAlive: true, IsSpectator: false, } // Falls das Spiel schon läuft, NICHT joinen erlauben if r.Status == "RUNNING" || r.Status == "COUNTDOWN" { // Spieler wird als Zuschauer hinzugefügt p.IsSpectator = true p.IsAlive = false log.Printf("⚠️ Spieler %s joined während des Spiels - wird Zuschauer", name) } r.Players[id] = p // Erster Spieler wird Host if r.HostID == "" { r.HostID = id // Auto-Start nur für Solo-Räume (erkennen am "solo_" Prefix) if len(r.ID) > 5 && r.ID[:5] == "solo_" { log.Printf("⏰ Solo-Mode: Auto-Start in 2 Sekunden für Raum %s", r.ID) go func() { time.Sleep(2 * time.Second) r.Mutex.Lock() if r.Status == "LOBBY" { r.Status = "COUNTDOWN" r.NextStart = time.Now().Add(3 * time.Second) log.Printf("🎮 Raum %s: Countdown gestartet!", r.ID) } r.Mutex.Unlock() }() } } } func (r *Room) ResetPlayer(p *ServerPlayer) { p.Y = 200 p.X = r.GlobalScrollX + 200 // Sicherer Spawn p.VY = 0 p.VX = 0 p.OnGround = false log.Printf("♻️ RESET Player %s", p.Name) } // --- INPUT HANDLER --- func (r *Room) HandleInput(input game.ClientInput) { r.Mutex.Lock() defer r.Mutex.Unlock() p, exists := r.Players[input.PlayerID] if !exists { return } // Sequenznummer aktualisieren if input.Sequence > p.LastInputSeq { p.LastInputSeq = input.Sequence } switch input.Type { case "STATE": // Vollständigen Input-State atomisch setzen – verhindert stuck-Inputs durch // Paketverlust oder Reihenfolge-Probleme bei Event-basierten Nachrichten. // Out-of-Order-Schutz: ältere States (Sequenz < zuletzt gesehen) ignorieren. // Hinweis: p.LastInputSeq wurde oben bereits auf max(old, input.Sequence) gesetzt, // daher muss hier < statt <= geprüft werden. if input.Sequence < p.LastInputSeq { return } // Richtung: JoyX hat Vorrang vor digitalen Tasten if input.InputJoyX != 0 { p.InputX = input.InputJoyX } else if input.InputLeft && !input.InputRight { p.InputX = -1 } else if input.InputRight && !input.InputLeft { p.InputX = 1 } else { p.InputX = 0 } // Jump/Down: einmal setzen, nie löschen (Physics-Tick macht das) if input.InputJump { p.InputJump = true // Double Jump spezial-Logik (außerhalb der Physik-Engine) if !p.OnGround && p.HasDoubleJump && !p.DoubleJumpUsed { p.VY = -config.JumpVelocity p.DoubleJumpUsed = true log.Printf("⚡ %s verwendet Double Jump!", p.Name) } } if input.InputDown { p.InputDown = true } // Legacy-Events (Rückwärtskompatibilität, werden vom neuen Client nicht mehr gesendet) case "JUMP": p.InputJump = true if !p.OnGround && p.HasDoubleJump && !p.DoubleJumpUsed { p.VY = -config.JumpVelocity p.DoubleJumpUsed = true log.Printf("⚡ %s verwendet Double Jump!", p.Name) } case "DOWN": p.InputDown = true case "LEFT_DOWN": p.InputX = -1 case "LEFT_UP": if p.InputX == -1 { p.InputX = 0 } case "RIGHT_DOWN": p.InputX = 1 case "RIGHT_UP": if p.InputX == 1 { p.InputX = 0 } case "START": if input.PlayerID == r.HostID && r.Status == "LOBBY" { r.StartCountdown() } case "START_PRESENTATION": if input.PlayerID == r.HostID && r.Status == "LOBBY" { r.Status = "PRESENTATION" r.GameStartTime = time.Now() r.CurrentSpeed = 0 } case "SET_TEAM_NAME": // Nur Host darf Team-Name setzen und nur in der Lobby if input.PlayerID == r.HostID && r.Status == "LOBBY" { r.TeamName = input.TeamName log.Printf("🏷️ Team-Name gesetzt: '%s' (von Host %s)", r.TeamName, p.Name) } } // Emote Handling (z.B. EMOTE_1, EMOTE_2) if len(input.Type) > 6 && input.Type[:6] == "EMOTE_" { p.State = input.Type // Emote nach 2 Sekunden zurücksetzen go func(player *ServerPlayer, emote string) { time.Sleep(2 * time.Second) if player.State == emote { player.State = "" } }(p, input.Type) } } func (r *Room) StartCountdown() { r.Status = "COUNTDOWN" r.NextStart = time.Now().Add(3 * time.Second) } // --- PHYSIK & UPDATE --- func (r *Room) Update() { r.Mutex.Lock() defer r.Mutex.Unlock() // 1. Status Logic if r.Status == "COUNTDOWN" { rem := time.Until(r.NextStart) r.Countdown = int(rem.Seconds()) + 1 if rem <= 0 { r.Status = "RUNNING" r.GameStartTime = time.Now() r.CurrentSpeed = config.RunSpeed } } else if r.Status == "RUNNING" { elapsed := time.Since(r.GameStartTime).Seconds() // DifficultyFactor: 0.0 am Start, 1.0 nach MaxDifficultySeconds (180s) r.DifficultyFactor = elapsed / config.MaxDifficultySeconds if r.DifficultyFactor > 1.0 { r.DifficultyFactor = 1.0 } // Geschwindigkeit: quadratische Kurve → am Anfang langsam, dann immer schneller // Bei MaxDifficultySeconds: +18 auf RunSpeed (39 total) speedIncrease := r.DifficultyFactor * r.DifficultyFactor * 18.0 r.CurrentSpeed = config.RunSpeed + speedIncrease r.GlobalScrollX += r.CurrentSpeed // Bewegende Plattformen updaten r.UpdateMovingPlatforms() } else if r.Status == "PRESENTATION" { // Keine Kamera-Bewegung, keine Schwierigkeitssteigerung, aber Physik läuft weiter r.CurrentSpeed = 0 // Bewegende Plattformen können sich auch hier bewegen, wenn gewünscht r.UpdateMovingPlatforms() } maxX := r.GlobalScrollX // 2. Spieler Physik for _, p := range r.Players { // Skip tote/Zuschauer Spieler if !p.IsAlive || p.IsSpectator { continue } // Lobby Mode if r.Status != "RUNNING" { p.VY += config.Gravity if p.Y > 540 { p.Y = 540 p.VY = 0 p.OnGround = true } if p.X < r.GlobalScrollX+50 { p.X = r.GlobalScrollX + 50 } continue } // === PHYSIK MIT GEMEINSAMER ENGINE (1:1 wie Client) === // Physik-State vorbereiten state := physics.PlayerPhysicsState{ X: p.X, Y: p.Y, VX: p.VX, VY: p.VY, OnGround: p.OnGround, OnWall: p.OnWall, } // Physik-Input vorbereiten physicsInput := physics.PhysicsInput{ InputX: p.InputX, Jump: p.InputJump, Down: p.InputDown, } // Kollisions-Checker vorbereiten collisionChecker := &physics.ServerCollisionChecker{ CheckCollisionFunc: r.CheckCollision, } // Gemeinsame Physik anwenden (1:1 wie Client!) physics.ApplyPhysics(&state, physicsInput, r.CurrentSpeed, r.DifficultyFactor, collisionChecker, physics.DefaultPlayerConstants()) // Ergebnis zurückschreiben p.X = state.X p.Y = state.Y p.VX = state.VX p.VY = state.VY p.OnGround = state.OnGround p.OnWall = state.OnWall // Input-Flags zurücksetzen für nächsten Tick p.InputJump = false p.InputDown = false // Double Jump Reset wenn wieder am Boden if p.OnGround { p.DoubleJumpUsed = false } // === SERVER-SPEZIFISCHE LOGIK === if r.Status == "PRESENTATION" { // Im Präsentationsmodus: Unverwundbar und keine Grenzen continue } // Obstacle-Kollision prüfen -> Spieler töten hitObstacle, obstacleType := r.CheckCollision( p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH, ) if hitObstacle && obstacleType == "obstacle" { r.KillPlayer(p) continue } // Grenzen if p.X > r.GlobalScrollX+2000 { p.X = r.GlobalScrollX + 2000 } // Rechts Block if p.X < r.GlobalScrollX-50 { r.KillPlayer(p) continue } // Links Tod if p.X > maxX { maxX = p.X } // Prüfe ob auf bewegender Plattform (für Platform-Mitbewegung) if p.OnGround { platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) p.OnMovingPlatform = platform } else { p.OnMovingPlatform = nil } // Spieler bewegt sich mit Plattform mit if p.OnMovingPlatform != nil && p.OnGround { // Berechne Plattform-Geschwindigkeit mp := p.OnMovingPlatform var targetX, targetY float64 if mp.Direction > 0 { targetX, targetY = mp.EndX, mp.EndY } else { targetX, targetY = mp.StartX, mp.StartY } dx := targetX - mp.CurrentX dy := targetY - mp.CurrentY dist := math.Sqrt(dx*dx + dy*dy) if dist > 0.1 { movePerTick := mp.Speed / 60.0 platformVelX := (dx / dist) * movePerTick platformVelY := (dy / dist) * movePerTick // Übertrage Plattform-Geschwindigkeit auf Spieler p.X += platformVelX p.Y += platformVelY } } if p.Y > 1000 { r.KillPlayer(p) } // Coin Kollision prüfen r.CheckCoinCollision(p) // Powerup Kollision prüfen r.CheckPowerupCollision(p) // Double Jump Timeout prüfen if p.HasDoubleJump && time.Now().After(p.DoubleJumpEndTime) { p.HasDoubleJump = false p.DoubleJumpUsed = false log.Printf("⚡ Double Jump von %s ist abgelaufen", p.Name) } // Godmode Timeout prüfen if p.HasGodMode && time.Now().After(p.GodModeEndTime) { p.HasGodMode = false log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name) } // Magnet Timeout prüfen if p.HasMagnet && time.Now().After(p.MagnetEndTime) { p.HasMagnet = false log.Printf("🧲 Magnet von %s ist abgelaufen", p.Name) } // Magnet: Coins im Umkreis automatisch einsammeln if p.HasMagnet { r.ApplyMagnetEffect(p) } } // 2b. Distanz-Score updaten if r.Status == "RUNNING" { r.UpdateDistanceScore() } // 3. Map Management r.UpdateMapLogic(maxX) // 4. Prüfen ob alle Spieler tot sind (GAMEOVER Check) if r.Status == "RUNNING" { aliveCount := 0 for _, p := range r.Players { if p.IsAlive && !p.IsSpectator { aliveCount++ } } if aliveCount == 0 && len(r.Players) > 0 { log.Printf("🏁 Alle Spieler tot - Game Over!") r.Status = "GAMEOVER" } } // 5. Host Check if _, ok := r.Players[r.HostID]; !ok && len(r.Players) > 0 { for id := range r.Players { r.HostID = id break } } } // --- COLLISION & MAP --- func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} // 1. Statische Colliders (Chunks) for _, c := range r.Colliders { if game.CheckRectCollision(playerRect, c.Rect) { return true, c.Type } } // 2. Bewegende Plattformen (dynamische Colliders) for _, mp := range r.MovingPlatforms { // Berechne Plattform-Hitbox an aktueller Position mpRect := game.Rect{ OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX, OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY, W: mp.HitboxW, H: mp.HitboxH, } if game.CheckRectCollision(playerRect, mpRect) { return true, "platform" } } return false, "" } // CheckMovingPlatformLanding prüft ob Spieler auf einer bewegenden Plattform landet func (r *Room) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform { playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} for _, mp := range r.MovingPlatforms { mpRect := game.Rect{ OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX, OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY, W: mp.HitboxW, H: mp.HitboxH, } if game.CheckRectCollision(playerRect, mpRect) { return mp } } return nil } func (r *Room) UpdateMapLogic(maxX float64) { if r.Status != "RUNNING" { return } // Neue Chunks spawnen if maxX > r.MapEndX-2000 { r.SpawnNextChunk() oldCount := len(r.Colliders) r.Colliders = r.World.GenerateColliders(r.ActiveChunks) log.Printf("🆕 Chunk gespawnt - Colliders: %d -> %d", oldCount, len(r.Colliders)) } // Alte Chunks löschen if len(r.ActiveChunks) > 0 { firstChunk := r.ActiveChunks[0] chunkDef := r.World.ChunkLibrary[firstChunk.ChunkID] chunkWidth := float64(chunkDef.Width * config.TileSize) if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 { // Lösche alle Coins dieses Chunks aus CollectedCoins r.ClearChunkCoins(firstChunk.ChunkID) // Lösche alle Powerups dieses Chunks r.ClearChunkPowerups(firstChunk.ChunkID) // Entferne bewegende Plattformen dieses Chunks r.RemoveMovingPlatforms(firstChunk.ChunkID) r.ActiveChunks = r.ActiveChunks[1:] r.Colliders = r.World.GenerateColliders(r.ActiveChunks) log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID) } } } // ClearChunkCoins löscht alle eingesammelten Coins eines Chunks func (r *Room) ClearChunkCoins(chunkID string) { prefix := chunkID + "_" coinsCleared := 0 for key := range r.CollectedCoins { if len(key) >= len(prefix) && key[:len(prefix)] == prefix { delete(r.CollectedCoins, key) coinsCleared++ } } if coinsCleared > 0 { log.Printf("💰 %d Coins von Chunk %s zurückgesetzt", coinsCleared, chunkID) } } // InitMovingPlatforms initialisiert bewegende Plattformen für einen Chunk func (r *Room) InitMovingPlatforms(chunkID string, chunkWorldX float64) { chunkDef, exists := r.World.ChunkLibrary[chunkID] if !exists { return } for objIdx, obj := range chunkDef.Objects { if obj.MovingPlatform != nil { assetDef, ok := r.World.Manifest.Assets[obj.AssetID] if !ok || assetDef.Type != "platform" { continue } mpData := obj.MovingPlatform platform := &MovingPlatform{ ChunkID: chunkID, ObjectIdx: objIdx, AssetID: obj.AssetID, StartX: chunkWorldX + mpData.StartX, StartY: mpData.StartY, EndX: chunkWorldX + mpData.EndX, EndY: mpData.EndY, Speed: mpData.Speed, Direction: 1.0, // Start bei StartX, bewege zu EndX HitboxW: assetDef.Hitbox.W, HitboxH: assetDef.Hitbox.H, DrawOffX: assetDef.DrawOffX, DrawOffY: assetDef.DrawOffY, HitboxOffX: assetDef.Hitbox.OffsetX, HitboxOffY: assetDef.Hitbox.OffsetY, } platform.CurrentX = platform.StartX platform.CurrentY = platform.StartY r.MovingPlatforms = append(r.MovingPlatforms, platform) log.Printf("🔄 Bewegende Plattform initialisiert: %s in Chunk %s", obj.AssetID, chunkID) } } } // RemoveMovingPlatforms entfernt alle Plattformen eines Chunks func (r *Room) RemoveMovingPlatforms(chunkID string) { newPlatforms := make([]*MovingPlatform, 0) removedCount := 0 for _, p := range r.MovingPlatforms { if p.ChunkID != chunkID { newPlatforms = append(newPlatforms, p) } else { removedCount++ } } r.MovingPlatforms = newPlatforms if removedCount > 0 { log.Printf("🗑️ %d bewegende Plattformen von Chunk %s entfernt", removedCount, chunkID) } } // UpdateMovingPlatforms bewegt alle aktiven Plattformen func (r *Room) UpdateMovingPlatforms() { // Sichtbarer Bereich: GlobalScrollX bis GlobalScrollX + 1400 // Aktivierung bei 3/4: GlobalScrollX + (1400 * 3/4) = GlobalScrollX + 1050 activationPoint := r.GlobalScrollX + 1050 for _, p := range r.MovingPlatforms { // Prüfe ob Plattform den Aktivierungspunkt erreicht hat if !p.IsActive { // Aktiviere Plattform, wenn sie bei 3/4 des Bildschirms ist if p.CurrentX <= activationPoint { p.IsActive = true log.Printf("▶️ Plattform aktiviert: %s (X=%.0f)", p.ChunkID, p.CurrentX) } else { // Noch nicht weit genug gescrollt, nicht bewegen continue } } // Bewegung berechnen (Speed pro Sekunde, bei 60 FPS = Speed/60) movePerTick := p.Speed / 60.0 // Bewegungsvektor von CurrentPos zu Ziel var targetX, targetY float64 if p.Direction > 0 { targetX, targetY = p.EndX, p.EndY } else { targetX, targetY = p.StartX, p.StartY } dx := targetX - p.CurrentX dy := targetY - p.CurrentY dist := math.Sqrt(dx*dx + dy*dy) if dist < movePerTick { // Ziel erreicht, umkehren p.CurrentX = targetX p.CurrentY = targetY p.Direction *= -1.0 } else { // Weiterbewegen p.CurrentX += (dx / dist) * movePerTick p.CurrentY += (dy / dist) * movePerTick } } } // RefillChunkPool füllt den Pool mit allen verfügbaren Chunks func (r *Room) RefillChunkPool() { r.ChunkPool = make([]string, 0, len(r.World.ChunkLibrary)) for chunkID := range r.World.ChunkLibrary { if chunkID != "start" { // Start-Chunk nicht in Pool r.ChunkPool = append(r.ChunkPool, chunkID) } } // Mische Pool für zufällige Reihenfolge rand.Shuffle(len(r.ChunkPool), func(i, j int) { r.ChunkPool[i], r.ChunkPool[j] = r.ChunkPool[j], r.ChunkPool[i] }) log.Printf("🔄 Chunk-Pool neu gefüllt: %d Chunks", len(r.ChunkPool)) } func (r *Room) SpawnNextChunk() { // Pool leer? Nachfüllen! if len(r.ChunkPool) == 0 { r.RefillChunkPool() } if len(r.ChunkPool) > 0 { // Nimm ersten Chunk aus Pool (bereits gemischt) randomID := r.ChunkPool[0] r.ChunkPool = r.ChunkPool[1:] // Entferne aus Pool chunkDef := r.World.ChunkLibrary[randomID] newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX} r.ActiveChunks = append(r.ActiveChunks, newChunk) r.MapEndX += float64(chunkDef.Width * config.TileSize) // Initialisiere bewegende Plattformen für diesen Chunk r.InitMovingPlatforms(randomID, newChunk.X) r.ChunkSpawnedCount[randomID]++ log.Printf("🎲 Chunk gespawnt: %s (Total: %d mal, Pool: %d übrig)", randomID, r.ChunkSpawnedCount[randomID], len(r.ChunkPool)) } else { // Fallback, falls keine Chunks da sind r.MapEndX += 1280 } } // --- NETZWERK --- func (r *Room) Broadcast() { r.Mutex.RLock() defer r.Mutex.RUnlock() // Sequenznummer erhöhen r.sequence++ state := game.GameState{ RoomID: r.ID, Players: make(map[string]game.PlayerState), Status: r.Status, TimeLeft: r.Countdown, WorldChunks: r.ActiveChunks, HostID: r.HostID, HostPlayerCode: r.HostPlayerCode, TeamName: r.TeamName, ScrollX: r.GlobalScrollX, CollectedCoins: r.CollectedCoins, CollectedPowerups: r.CollectedPowerups, MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)), Sequence: r.sequence, CurrentSpeed: r.CurrentSpeed, DifficultyFactor: r.DifficultyFactor, } now := time.Now() for id, p := range r.Players { djRemaining := 0.0 if p.HasDoubleJump { djRemaining = p.DoubleJumpEndTime.Sub(now).Seconds() if djRemaining < 0 { djRemaining = 0 } } godRemaining := 0.0 if p.HasGodMode { godRemaining = p.GodModeEndTime.Sub(now).Seconds() if godRemaining < 0 { godRemaining = 0 } } magnetRemaining := 0.0 if p.HasMagnet { magnetRemaining = p.MagnetEndTime.Sub(now).Seconds() if magnetRemaining < 0 { magnetRemaining = 0 } } state.Players[id] = game.PlayerState{ ID: id, Name: p.Name, X: p.X, Y: p.Y, VX: p.VX, VY: p.VY, State: p.State, OnGround: p.OnGround, OnWall: p.OnWall, LastInputSeq: p.LastInputSeq, Score: p.Score, IsAlive: p.IsAlive, IsSpectator: p.IsSpectator, HasDoubleJump: p.HasDoubleJump, DoubleJumpUsed: p.DoubleJumpUsed, DoubleJumpRemainingSeconds: djRemaining, HasGodMode: p.HasGodMode, GodModeRemainingSeconds: godRemaining, HasMagnet: p.HasMagnet, MagnetRemainingSeconds: magnetRemaining, } } // Bewegende Plattformen synchronisieren for _, mp := range r.MovingPlatforms { state.MovingPlatforms = append(state.MovingPlatforms, game.MovingPlatformSync{ ChunkID: mp.ChunkID, ObjectIdx: mp.ObjectIdx, AssetID: mp.AssetID, X: mp.CurrentX, Y: mp.CurrentY, }) } // DEBUG: Ersten Broadcast loggen (nur beim ersten Mal) if !r.firstBroadcast && len(r.Players) > 0 && r.Status == "LOBBY" { log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status) r.firstBroadcast = true } // Senden an raum-spezifischen Channel: "game.update." channel := "game.update." + r.ID ec, _ := nats.NewEncodedConn(r.NC, nats.JSON_ENCODER) ec.Publish(channel, state) } // RemovePlayer entfernt einen Spieler aus dem Raum func (r *Room) RemovePlayer(id string) { r.Mutex.Lock() defer r.Mutex.Unlock() delete(r.Players, id) log.Printf("➖ Player %s left room %s", id, r.ID) } // ClearChunkPowerups löscht alle eingesammelten Powerups eines Chunks func (r *Room) ClearChunkPowerups(chunkID string) { prefix := chunkID + "_" powerupsCleared := 0 for key := range r.CollectedPowerups { if len(key) >= len(prefix) && key[:len(prefix)] == prefix { delete(r.CollectedPowerups, key) powerupsCleared++ } } if powerupsCleared > 0 { log.Printf("⚡ %d Powerups von Chunk %s zurückgesetzt", powerupsCleared, chunkID) } }