Private
Public Access
1
0

Implement core game functionalities: client prediction, coin collection, scoring, game state synchronization, and player management.

This commit is contained in:
Sebastian Unterschütz
2026-01-01 16:46:39 +01:00
parent 4b2995846e
commit 5e6b8a2304
9 changed files with 902 additions and 203 deletions

View File

@@ -55,9 +55,10 @@ type LoginPayload struct {
// Input vom Spieler während des Spiels
type ClientInput struct {
Type string `json:"type"` // "JUMP", "START"
Type string `json:"type"` // "JUMP", "START", "LEFT_DOWN", "RIGHT_DOWN", etc.
RoomID string `json:"room_id"`
PlayerID string `json:"player_id"`
Sequence uint32 `json:"sequence"` // Sequenznummer für Client Prediction
}
type JoinRequest struct {
@@ -66,21 +67,27 @@ type JoinRequest struct {
}
type PlayerState struct {
ID string `json:"id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
State string `json:"state"`
OnGround bool `json:"on_ground"`
ID string `json:"id"`
Name string `json:"name"`
X float64 `json:"x"`
Y float64 `json:"y"`
VX float64 `json:"vx"`
VY float64 `json:"vy"`
State string `json:"state"`
OnGround bool `json:"on_ground"`
LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz
Score int `json:"score"` // Punkte des Spielers
IsAlive bool `json:"is_alive"` // Lebt der Spieler noch?
IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus
}
type GameState struct {
RoomID string `json:"room_id"`
Players map[string]PlayerState `json:"players"`
Status string `json:"status"`
TimeLeft int `json:"time_left"`
WorldChunks []ActiveChunk `json:"world_chunks"`
HostID string `json:"host_id"`
ScrollX float64 `json:"scroll_x"`
RoomID string `json:"room_id"`
Players map[string]PlayerState `json:"players"`
Status string `json:"status"`
TimeLeft int `json:"time_left"`
WorldChunks []ActiveChunk `json:"world_chunks"`
HostID string `json:"host_id"`
ScrollX float64 `json:"scroll_x"`
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex)
}

View File

@@ -12,28 +12,34 @@ import (
)
type ServerPlayer struct {
ID string
Name string
X, Y float64
VX, VY float64
OnGround bool
InputX float64 // -1 (Links), 0, 1 (Rechts)
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
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{}
@@ -48,13 +54,14 @@ type Room struct {
// 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{}),
pW: 40, pH: 60, // Fallback
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
@@ -72,11 +79,22 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
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
}
@@ -108,18 +126,27 @@ func (r *Room) AddPlayer(id, name string) {
return
}
// Spawn-Position berechnen
playerIndex := len(r.Players)
spawnX := 100.0 + float64(playerIndex*150)
p := &ServerPlayer{
ID: id,
Name: name,
X: 100,
Y: 200,
OnGround: false,
ID: id,
Name: name,
X: spawnX,
Y: 200,
OnGround: false,
Score: 0,
IsAlive: true,
IsSpectator: false,
}
// Falls das Spiel schon läuft, spawnen wir weiter rechts
if r.Status == "RUNNING" {
p.X = r.GlobalScrollX + 200
p.Y = 200
// 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
@@ -127,6 +154,21 @@ func (r *Room) AddPlayer(id, name string) {
// 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()
}()
}
}
}
@@ -150,6 +192,11 @@ func (r *Room) HandleInput(input game.ClientInput) {
return
}
// Sequenznummer aktualisieren
if input.Sequence > p.LastInputSeq {
p.LastInputSeq = input.Sequence
}
switch input.Type {
case "JUMP":
if p.OnGround {
@@ -203,6 +250,11 @@ func (r *Room) Update() {
// 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
@@ -217,6 +269,13 @@ func (r *Room) Update() {
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
@@ -224,7 +283,7 @@ func (r *Room) Update() {
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.ResetPlayer(p)
r.KillPlayer(p)
continue
}
} else {
@@ -236,7 +295,7 @@ func (r *Room) Update() {
p.X = r.GlobalScrollX + 1200
} // Rechts Block
if p.X < r.GlobalScrollX-50 {
r.ResetPlayer(p)
r.KillPlayer(p)
continue
} // Links Tod
@@ -254,7 +313,7 @@ func (r *Room) Update() {
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.ResetPlayer(p)
r.KillPlayer(p)
continue
}
if p.VY > 0 {
@@ -267,8 +326,16 @@ func (r *Room) Update() {
}
if p.Y > 1000 {
r.ResetPlayer(p)
r.KillPlayer(p)
}
// Coin Kollision prüfen
r.CheckCoinCollision(p)
}
// 2b. Distanz-Score updaten
if r.Status == "RUNNING" {
r.UpdateDistanceScore()
}
// 3. Map Management
@@ -289,6 +356,11 @@ 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
}
}
@@ -346,30 +418,41 @@ func (r *Room) Broadcast() {
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,
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, OnGround: p.OnGround,
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
if len(r.Players) > 0 {
// 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 "game.update" (Client filtert nicht wirklich, aber für Demo ok)
// Besser wäre "game.update.<ROOMID>"
// Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
channel := "game.update." + r.ID
ec, _ := nats.NewEncodedConn(r.NC, nats.JSON_ENCODER)
ec.Publish("game.update", state)
ec.Publish(channel, state)
}
// RemovePlayer entfernt einen Spieler aus dem Raum

129
pkg/server/scoring.go Normal file
View File

@@ -0,0 +1,129 @@
package server
import (
"fmt"
"log"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// CheckCoinCollision prüft ob Spieler Coins einsammelt
func (r *Room) CheckCoinCollision(p *ServerPlayer) {
if !p.IsAlive || p.IsSpectator {
return
}
playerHitbox := game.Rect{
OffsetX: p.X + r.pDrawOffX + r.pHitboxOffX,
OffsetY: p.Y + r.pDrawOffY + r.pHitboxOffY,
W: r.pW,
H: r.pH,
}
// Durch alle aktiven Chunks iterieren
for _, activeChunk := range r.ActiveChunks {
chunkDef, exists := r.World.ChunkLibrary[activeChunk.ChunkID]
if !exists {
continue
}
// Durch alle Objekte im Chunk
for objIdx, obj := range chunkDef.Objects {
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
if !ok {
continue
}
// Nur Coins prüfen
if assetDef.Type != "coin" {
continue
}
// Eindeutiger Key für diesen Coin
coinKey := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
// Wurde bereits eingesammelt?
if r.CollectedCoins[coinKey] {
continue
}
// Coin-Hitbox
coinHitbox := game.Rect{
OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX,
OffsetY: obj.Y + assetDef.Hitbox.OffsetY,
W: assetDef.Hitbox.W,
H: assetDef.Hitbox.H,
}
// Kollision?
if game.CheckRectCollision(playerHitbox, coinHitbox) {
// Coin einsammeln!
r.CollectedCoins[coinKey] = true
p.Score += 200
log.Printf("💰 %s hat Coin eingesammelt! Score: %d", p.Name, p.Score)
}
}
}
}
// UpdateDistanceScore aktualisiert Distanz-basierte Punkte
func (r *Room) UpdateDistanceScore() {
if r.Status != "RUNNING" {
return
}
// Anzahl lebender Spieler zählen
aliveCount := 0
for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator {
aliveCount++
}
}
if aliveCount == 0 {
return
}
// Multiplier = Anzahl lebender Spieler
multiplier := float64(aliveCount)
// Akkumulator erhöhen: multiplier Punkte pro Sekunde
// Bei 60 FPS: multiplier / 60.0 Punkte pro Tick
r.ScoreAccum += multiplier / 60.0
// Wenn Akkumulator >= 1.0, Punkte vergeben
if r.ScoreAccum >= 1.0 {
pointsToAdd := int(r.ScoreAccum)
r.ScoreAccum -= float64(pointsToAdd)
for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator {
p.Score += pointsToAdd
}
}
}
}
// KillPlayer markiert Spieler als tot
func (r *Room) KillPlayer(p *ServerPlayer) {
if !p.IsAlive {
return
}
p.IsAlive = false
p.IsSpectator = true
log.Printf("💀 %s ist gestorben! Final Score: %d", p.Name, p.Score)
// Prüfen ob alle tot sind
aliveCount := 0
for _, pl := range r.Players {
if pl.IsAlive && !pl.IsSpectator {
aliveCount++
}
}
if aliveCount == 0 && r.Status == "RUNNING" {
log.Printf("🏁 Alle Spieler tot - Game Over!")
r.Status = "GAMEOVER"
}
}