Private
Public Access
1
0
Files
2026-04-22 20:28:16 +02:00

983 lines
27 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.<ROOMID>"
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)
}
}