Private
Public Access
1
0

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:
Sebastian Unterschütz
2026-01-01 15:21:50 +01:00
parent 3099ac42c0
commit 4b2995846e
65 changed files with 1943 additions and 0 deletions

24
pkg/config/config.go Normal file
View File

@@ -0,0 +1,24 @@
package config
import "time"
const (
// Server Settings
Port = ":8080"
AssetPath = "./cmd/client/assets/assets.json"
ChunkDir = "./cmd/client/assets/chunks"
// Physics
Gravity = 0.5
MaxFall = 15.0
TileSize = 64
// Gameplay
RunSpeed = 7.0
StartTime = 5 // Sekunden Countdown
TickRate = time.Millisecond * 16 // ~60 FPS
// NATS Subjects Templates
SubjectInput = "game.room.%s.input"
SubjectState = "game.room.%s.state"
)

107
pkg/game/world.go Normal file
View File

@@ -0,0 +1,107 @@
package game
import (
"encoding/json"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
)
type Collider struct {
Rect
Type string
}
type World struct {
Manifest AssetManifest
ChunkLibrary map[string]Chunk
}
func NewWorld() *World {
return &World{
Manifest: AssetManifest{Assets: make(map[string]AssetDefinition)},
ChunkLibrary: make(map[string]Chunk),
}
}
func (w *World) LoadManifest(path string) error {
data, err := ioutil.ReadFile(path)
if err != nil {
return err
}
return json.Unmarshal(data, &w.Manifest)
}
func (w *World) LoadChunkLibrary(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
count := 0
for _, f := range entries {
if filepath.Ext(f.Name()) == ".json" {
data, _ := ioutil.ReadFile(filepath.Join(dir, f.Name()))
var c Chunk
if err := json.Unmarshal(data, &c); err == nil {
if c.ID == "" {
c.ID = strings.TrimSuffix(f.Name(), ".json")
}
w.ChunkLibrary[c.ID] = c
count++
}
}
}
fmt.Printf("📦 Library: %d Chunks geladen aus %s\n", count, dir)
return nil
}
// NEU: Gibt die Liste zurück, statt sie zu speichern!
func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider {
list := []Collider{}
// 1. Boden
list = append(list, Collider{
Rect: Rect{OffsetX: -10000, OffsetY: 540, W: 100000000, H: 200},
Type: "platform",
})
// 2. Objekte
for _, ac := range activeChunks {
chunk, exists := w.ChunkLibrary[ac.ChunkID]
if !exists {
continue
}
for _, obj := range chunk.Objects {
def, ok := w.Manifest.Assets[obj.AssetID]
if !ok {
continue
}
if def.Type == "obstacle" || def.Type == "platform" {
c := Collider{
Rect: Rect{
OffsetX: ac.X + obj.X + def.Hitbox.OffsetX,
OffsetY: obj.Y + def.Hitbox.OffsetY,
W: def.Hitbox.W,
H: def.Hitbox.H,
},
Type: def.Type,
}
list = append(list, c)
}
}
}
return list
}
// CheckRectCollision prüft, ob zwei Rechtecke sich überschneiden (AABB)
func CheckRectCollision(a, b Rect) bool {
return a.OffsetX < b.OffsetX+b.W &&
a.OffsetX+a.W > b.OffsetX &&
a.OffsetY < b.OffsetY+b.H &&
a.OffsetY+a.H > b.OffsetY
}

13
pkg/go.mod Normal file
View File

@@ -0,0 +1,13 @@
module git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg
go 1.25.5
require github.com/nats-io/nats.go v1.47.0
require (
github.com/klauspost/compress v1.18.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sys v0.32.0 // indirect
)

12
pkg/go.sum Normal file
View File

@@ -0,0 +1,12 @@
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/nats-io/nats.go v1.47.0 h1:YQdADw6J/UfGUd2Oy6tn4Hq6YHxCaJrVKayxxFqYrgM=
github.com/nats-io/nats.go v1.47.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=

138
pkg/server/gateway.go Normal file
View 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
View 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)
}