All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 8m20s
896 lines
24 KiB
Go
896 lines
24 KiB
Go
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
|
||
|
||
// Powerups
|
||
HasDoubleJump bool // Doppelsprung aktiv?
|
||
DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt?
|
||
HasGodMode bool // Godmode aktiv?
|
||
GodModeEndTime time.Time // Wann endet Godmode?
|
||
}
|
||
|
||
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)
|
||
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: nur neuere States übernehmen
|
||
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 "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)
|
||
}
|
||
}
|
||
}
|
||
|
||
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" {
|
||
// Geschwindigkeit erhöhen: +0.5 pro 10 Sekunden (max +5.0 nach 100 Sekunden)
|
||
elapsed := time.Since(r.GameStartTime).Seconds()
|
||
speedIncrease := (elapsed / 10.0) * 0.5
|
||
if speedIncrease > 5.0 {
|
||
speedIncrease = 5.0
|
||
}
|
||
r.CurrentSpeed = config.RunSpeed + speedIncrease
|
||
|
||
r.GlobalScrollX += r.CurrentSpeed
|
||
// Bewegende Plattformen updaten
|
||
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, 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 ===
|
||
|
||
// 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)
|
||
|
||
// Godmode Timeout prüfen
|
||
if p.HasGodMode && time.Now().After(p.GodModeEndTime) {
|
||
p.HasGodMode = false
|
||
log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name)
|
||
}
|
||
}
|
||
|
||
// 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,
|
||
}
|
||
|
||
for id, p := range r.Players {
|
||
state.Players[id] = game.PlayerState{
|
||
ID: id,
|
||
Name: p.Name,
|
||
X: p.X,
|
||
Y: p.Y,
|
||
VX: p.VX,
|
||
VY: p.VY,
|
||
OnGround: p.OnGround,
|
||
OnWall: p.OnWall,
|
||
LastInputSeq: p.LastInputSeq,
|
||
Score: p.Score,
|
||
IsAlive: p.IsAlive,
|
||
IsSpectator: p.IsSpectator,
|
||
HasDoubleJump: p.HasDoubleJump,
|
||
HasGodMode: p.HasGodMode,
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|