Add initial project structure for "Escape From Teacher" game: server, client, level editor, and asset framework. Includes game rendering, physics, WebSocket server, NATS integration, and asset management setup.
This commit is contained in:
138
pkg/server/gateway.go
Normal file
138
pkg/server/gateway.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
|
||||
type Gateway struct {
|
||||
NC *nats.Conn
|
||||
World *game.World
|
||||
// Lokale Referenz auf Räume, die DIESER Server verwaltet
|
||||
// In einer echten Microservice Welt wäre das separat,
|
||||
// aber hier hostet der Gateway auch Räume.
|
||||
LocalRooms map[string]*Room
|
||||
}
|
||||
|
||||
func NewGateway(nc *nats.Conn, w *game.World) *Gateway {
|
||||
return &Gateway{
|
||||
NC: nc,
|
||||
World: w,
|
||||
LocalRooms: make(map[string]*Room),
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *Gateway) HandleWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 1. HANDSHAKE (Login warten)
|
||||
// Der Client muss als allererstes JSON senden: {action: "CREATE"|"JOIN", name: "Hans"}
|
||||
var login game.LoginPayload
|
||||
_, msg, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(msg, &login); err != nil {
|
||||
log.Println("Ungültiger Login:", err)
|
||||
return
|
||||
}
|
||||
|
||||
// IDs generieren
|
||||
playerID := fmt.Sprintf("p_%d", time.Now().UnixNano())
|
||||
roomID := login.RoomID
|
||||
|
||||
// 2. RAUM LOGIK
|
||||
if login.Action == "CREATE" {
|
||||
// Raum ID generieren (4 Zeichen Random)
|
||||
roomID = GenerateRoomCode()
|
||||
|
||||
// Neuen Raum starten (auf diesem Server)
|
||||
newRoom := NewRoom(roomID, gw.NC, gw.World)
|
||||
gw.LocalRooms[roomID] = newRoom
|
||||
go newRoom.RunLoop()
|
||||
|
||||
// Spieler lokal hinzufügen (Hack für Demo, sauberer wäre via NATS Event)
|
||||
newRoom.AddPlayer(playerID, login.Name)
|
||||
|
||||
} else if login.Action == "JOIN" {
|
||||
// Wir müssen dem Raum (egal wo er läuft) sagen: Hier ist ein Neuer!
|
||||
// Da wir hier keine verteilte DB haben, tricksen wir:
|
||||
// Wir gehen davon aus, dass wir den Raum "finden" müssen.
|
||||
// Für dieses Tutorial: Wir prüfen ob er lokal ist.
|
||||
// Wenn er auf einem anderen Server wäre, bräuchten wir ein "PlayerJoin" Subject.
|
||||
|
||||
if room, ok := gw.LocalRooms[roomID]; ok {
|
||||
room.AddPlayer(playerID, login.Name)
|
||||
} else {
|
||||
// Falls Raum nicht lokal: Senden wir ein "JOIN REQUEST" über NATS?
|
||||
// Für jetzt: Wir lassen es simpel. Wenn Raum nicht auf diesem Server -> Error.
|
||||
// (Für echtes Scaling bräuchten wir Redis oder NATS Request/Reply zur Raumsuche)
|
||||
log.Println("Raum nicht gefunden (oder auf anderem Node):", roomID)
|
||||
// Optional: Error an Client senden
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Player %s (%s) joined Room %s", playerID, login.Name, roomID)
|
||||
|
||||
// 3. PROXY LOOP
|
||||
// A) NATS -> WebSocket (State Updates empfangen)
|
||||
sub, _ := gw.NC.Subscribe(fmt.Sprintf("game.room.%s.state", roomID), func(m *nats.Msg) {
|
||||
conn.WriteMessage(websocket.TextMessage, m.Data)
|
||||
})
|
||||
defer sub.Unsubscribe()
|
||||
|
||||
// B) WebSocket -> NATS (Input senden)
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
// Wir parsen kurz, um den Typ zu prüfen, oder leiten blind weiter?
|
||||
// Besser: Wir wrappen es in ClientInput struct
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(data, &raw)
|
||||
|
||||
inputType, _ := raw["type"].(string)
|
||||
|
||||
input := game.ClientInput{
|
||||
Type: inputType,
|
||||
RoomID: roomID,
|
||||
PlayerID: playerID,
|
||||
}
|
||||
|
||||
bytes, _ := json.Marshal(input)
|
||||
gw.NC.Publish(fmt.Sprintf("game.room.%s.input", roomID), bytes)
|
||||
}
|
||||
|
||||
// Cleanup beim Disconnect
|
||||
if room, ok := gw.LocalRooms[roomID]; ok {
|
||||
room.RemovePlayer(playerID)
|
||||
// Wenn leer -> Raum löschen?
|
||||
}
|
||||
}
|
||||
|
||||
func GenerateRoomCode() string {
|
||||
chars := "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, 4)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
381
pkg/server/room.go
Normal file
381
pkg/server/room.go
Normal file
@@ -0,0 +1,381 @@
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
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{}),
|
||||
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
|
||||
|
||||
// Erste Chunks generieren
|
||||
r.SpawnNextChunk()
|
||||
r.SpawnNextChunk()
|
||||
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
p := &ServerPlayer{
|
||||
ID: id,
|
||||
Name: name,
|
||||
X: 100,
|
||||
Y: 200,
|
||||
OnGround: false,
|
||||
}
|
||||
|
||||
// Falls das Spiel schon läuft, spawnen wir weiter rechts
|
||||
if r.Status == "RUNNING" {
|
||||
p.X = r.GlobalScrollX + 200
|
||||
p.Y = 200
|
||||
}
|
||||
|
||||
r.Players[id] = p
|
||||
|
||||
// Erster Spieler wird Host
|
||||
if r.HostID == "" {
|
||||
r.HostID = id
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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.ResetPlayer(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.ResetPlayer(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.ResetPlayer(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.ResetPlayer(p)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
// DEBUG: Ersten Broadcast loggen
|
||||
if len(r.Players) > 0 {
|
||||
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>"
|
||||
ec, _ := nats.NewEncodedConn(r.NC, nats.JSON_ENCODER)
|
||||
ec.Publish("game.update", 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)
|
||||
}
|
||||
Reference in New Issue
Block a user