465 lines
10 KiB
Go
465 lines
10 KiB
Go
package server
|
||
|
||
import (
|
||
"log"
|
||
"math/rand"
|
||
"sync"
|
||
"time"
|
||
|
||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
|
||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||
"github.com/nats-io/nats.go"
|
||
)
|
||
|
||
type ServerPlayer struct {
|
||
ID string
|
||
Name string
|
||
X, Y float64
|
||
VX, VY float64
|
||
OnGround bool
|
||
InputX float64 // -1 (Links), 0, 1 (Rechts)
|
||
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
|
||
Score int
|
||
IsAlive bool
|
||
IsSpectator bool
|
||
}
|
||
|
||
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
|
||
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
|
||
ScoreAccum float64 // Akkumulator für Distanz-Score
|
||
|
||
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),
|
||
pW: 40, pH: 60, // Fallback
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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)
|
||
|
||
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() {
|
||
// 60 Tick pro Sekunde
|
||
ticker := time.NewTicker(time.Second / 60)
|
||
defer ticker.Stop()
|
||
|
||
for {
|
||
select {
|
||
case <-r.stopChan:
|
||
return
|
||
case <-ticker.C:
|
||
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: 200,
|
||
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 "JUMP":
|
||
if p.OnGround {
|
||
p.VY = -14.0
|
||
p.OnGround = false
|
||
}
|
||
case "DOWN":
|
||
p.VY = 15.0
|
||
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()
|
||
}
|
||
}
|
||
}
|
||
|
||
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"
|
||
}
|
||
} else if r.Status == "RUNNING" {
|
||
r.GlobalScrollX += config.RunSpeed
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Prüfe aktuelle Position auf Obstacles (falls reingesprungen)
|
||
currentHit, currentType := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||
if currentHit && currentType == "obstacle" {
|
||
r.KillPlayer(p)
|
||
continue
|
||
}
|
||
|
||
// X Bewegung
|
||
currentSpeed := config.RunSpeed + (p.InputX * 4.0)
|
||
nextX := p.X + currentSpeed
|
||
|
||
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||
if hitX {
|
||
if typeX == "obstacle" {
|
||
r.KillPlayer(p)
|
||
continue
|
||
}
|
||
} else {
|
||
p.X = nextX
|
||
}
|
||
|
||
// Grenzen
|
||
if p.X > r.GlobalScrollX+1200 {
|
||
p.X = r.GlobalScrollX + 1200
|
||
} // Rechts Block
|
||
if p.X < r.GlobalScrollX-50 {
|
||
r.KillPlayer(p)
|
||
continue
|
||
} // Links Tod
|
||
|
||
if p.X > maxX {
|
||
maxX = p.X
|
||
}
|
||
|
||
// Y Bewegung
|
||
p.VY += config.Gravity
|
||
if p.VY > config.MaxFall {
|
||
p.VY = config.MaxFall
|
||
}
|
||
nextY := p.Y + p.VY
|
||
|
||
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
|
||
if hitY {
|
||
if typeY == "obstacle" {
|
||
r.KillPlayer(p)
|
||
continue
|
||
}
|
||
if p.VY > 0 {
|
||
p.OnGround = true
|
||
}
|
||
p.VY = 0
|
||
} else {
|
||
p.Y += p.VY
|
||
p.OnGround = false
|
||
}
|
||
|
||
if p.Y > 1000 {
|
||
r.KillPlayer(p)
|
||
}
|
||
|
||
// Coin Kollision prüfen
|
||
r.CheckCoinCollision(p)
|
||
}
|
||
|
||
// 2b. Distanz-Score updaten
|
||
if r.Status == "RUNNING" {
|
||
r.UpdateDistanceScore()
|
||
}
|
||
|
||
// 3. Map Management
|
||
r.UpdateMapLogic(maxX)
|
||
|
||
// 4. 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}
|
||
for _, c := range r.Colliders {
|
||
if game.CheckRectCollision(playerRect, c.Rect) {
|
||
if c.Type == "obstacle" {
|
||
log.Printf("🔴 OBSTACLE HIT! Player: (%.0f, %.0f, %.0f, %.0f), Obstacle: (%.0f, %.0f, %.0f, %.0f)",
|
||
playerRect.OffsetX, playerRect.OffsetY, playerRect.W, playerRect.H,
|
||
c.Rect.OffsetX, c.Rect.OffsetY, c.Rect.W, c.Rect.H)
|
||
}
|
||
return true, c.Type
|
||
}
|
||
}
|
||
return false, ""
|
||
}
|
||
|
||
func (r *Room) UpdateMapLogic(maxX float64) {
|
||
if r.Status != "RUNNING" {
|
||
return
|
||
}
|
||
|
||
// Neue Chunks spawnen
|
||
if maxX > r.MapEndX-2000 {
|
||
r.SpawnNextChunk()
|
||
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
|
||
}
|
||
|
||
// 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 {
|
||
r.ActiveChunks = r.ActiveChunks[1:]
|
||
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (r *Room) SpawnNextChunk() {
|
||
keys := make([]string, 0, len(r.World.ChunkLibrary))
|
||
for k := range r.World.ChunkLibrary {
|
||
keys = append(keys, k)
|
||
}
|
||
|
||
if len(keys) > 0 {
|
||
// Zufälligen Chunk wählen
|
||
randomID := keys[rand.Intn(len(keys))]
|
||
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)
|
||
} else {
|
||
// Fallback, falls keine Chunks da sind
|
||
r.MapEndX += 1280
|
||
}
|
||
}
|
||
|
||
// --- NETZWERK ---
|
||
|
||
func (r *Room) Broadcast() {
|
||
r.Mutex.RLock()
|
||
defer r.Mutex.RUnlock()
|
||
|
||
state := game.GameState{
|
||
RoomID: r.ID,
|
||
Players: make(map[string]game.PlayerState),
|
||
Status: r.Status,
|
||
TimeLeft: r.Countdown,
|
||
WorldChunks: r.ActiveChunks,
|
||
HostID: r.HostID,
|
||
ScrollX: r.GlobalScrollX,
|
||
CollectedCoins: r.CollectedCoins,
|
||
}
|
||
|
||
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,
|
||
LastInputSeq: p.LastInputSeq,
|
||
Score: p.Score,
|
||
IsAlive: p.IsAlive,
|
||
IsSpectator: p.IsSpectator,
|
||
}
|
||
}
|
||
|
||
// DEBUG: Ersten Broadcast loggen (nur beim ersten Mal)
|
||
if 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)
|
||
}
|
||
|
||
// 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)
|
||
}
|