Private
Public Access
1
0
Files
EscapeFromTeacher/pkg/server/room.go

465 lines
10 KiB
Go
Raw 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/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)
}