diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/EscapeFromTeacher.iml b/.idea/EscapeFromTeacher.iml
new file mode 100644
index 0000000..5e764c4
--- /dev/null
+++ b/.idea/EscapeFromTeacher.iml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml
new file mode 100644
index 0000000..d7202f0
--- /dev/null
+++ b/.idea/go.imports.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/modules.xml b/.idea/modules.xml
new file mode 100644
index 0000000..f3c4b5a
--- /dev/null
+++ b/.idea/modules.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/assets_raw/baskeball.png b/assets_raw/baskeball.png
new file mode 100644
index 0000000..2bcc206
Binary files /dev/null and b/assets_raw/baskeball.png differ
diff --git a/assets_raw/coin.png b/assets_raw/coin.png
new file mode 100644
index 0000000..2cdb6e2
Binary files /dev/null and b/assets_raw/coin.png differ
diff --git a/assets_raw/desk.png b/assets_raw/desk.png
new file mode 100644
index 0000000..b6fecaf
Binary files /dev/null and b/assets_raw/desk.png differ
diff --git a/assets_raw/eraser.png b/assets_raw/eraser.png
new file mode 100644
index 0000000..58b1824
Binary files /dev/null and b/assets_raw/eraser.png differ
diff --git a/assets_raw/g-l.png b/assets_raw/g-l.png
new file mode 100644
index 0000000..6f6d012
Binary files /dev/null and b/assets_raw/g-l.png differ
diff --git a/assets_raw/gym-background.jpg b/assets_raw/gym-background.jpg
new file mode 100644
index 0000000..e4dd3ab
Binary files /dev/null and b/assets_raw/gym-background.jpg differ
diff --git a/assets_raw/h-l.png b/assets_raw/h-l.png
new file mode 100644
index 0000000..89ff30c
Binary files /dev/null and b/assets_raw/h-l.png differ
diff --git a/assets_raw/k-l-monitor.png b/assets_raw/k-l-monitor.png
new file mode 100644
index 0000000..e347b1f
Binary files /dev/null and b/assets_raw/k-l-monitor.png differ
diff --git a/assets_raw/k-l.png b/assets_raw/k-l.png
new file mode 100644
index 0000000..1085020
Binary files /dev/null and b/assets_raw/k-l.png differ
diff --git a/assets_raw/k-m.png b/assets_raw/k-m.png
new file mode 100644
index 0000000..3df6b53
Binary files /dev/null and b/assets_raw/k-m.png differ
diff --git a/assets_raw/m-l.png b/assets_raw/m-l.png
new file mode 100644
index 0000000..800b141
Binary files /dev/null and b/assets_raw/m-l.png differ
diff --git a/assets_raw/p-l.png b/assets_raw/p-l.png
new file mode 100644
index 0000000..e659e9e
Binary files /dev/null and b/assets_raw/p-l.png differ
diff --git a/assets_raw/pc-trash.png b/assets_raw/pc-trash.png
new file mode 100644
index 0000000..3335dde
Binary files /dev/null and b/assets_raw/pc-trash.png differ
diff --git a/assets_raw/player.png b/assets_raw/player.png
new file mode 100644
index 0000000..fc2131c
Binary files /dev/null and b/assets_raw/player.png differ
diff --git a/assets_raw/r-l.png b/assets_raw/r-l.png
new file mode 100644
index 0000000..daf3d2d
Binary files /dev/null and b/assets_raw/r-l.png differ
diff --git a/assets_raw/school-background.jpg b/assets_raw/school-background.jpg
new file mode 100644
index 0000000..b049512
Binary files /dev/null and b/assets_raw/school-background.jpg differ
diff --git a/assets_raw/school2-background.jpg b/assets_raw/school2-background.jpg
new file mode 100644
index 0000000..dc6a800
Binary files /dev/null and b/assets_raw/school2-background.jpg differ
diff --git a/assets_raw/t-s.png b/assets_raw/t-s.png
new file mode 100644
index 0000000..85b6a3d
Binary files /dev/null and b/assets_raw/t-s.png differ
diff --git a/assets_raw/w-l.png b/assets_raw/w-l.png
new file mode 100644
index 0000000..01353fa
Binary files /dev/null and b/assets_raw/w-l.png differ
diff --git a/cmd/builder/go.mod b/cmd/builder/go.mod
new file mode 100644
index 0000000..dd82ed5
--- /dev/null
+++ b/cmd/builder/go.mod
@@ -0,0 +1,5 @@
+module git.zb-server.de/ZB-Server/EscapeFromTeacher/builder
+
+go 1.25.5
+
+require golang.org/x/image v0.34.0 // indirect
diff --git a/cmd/builder/go.sum b/cmd/builder/go.sum
new file mode 100644
index 0000000..4b59bdb
--- /dev/null
+++ b/cmd/builder/go.sum
@@ -0,0 +1,2 @@
+golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
+golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
diff --git a/cmd/client/assets/assets.json b/cmd/client/assets/assets.json
new file mode 100644
index 0000000..dab46a9
--- /dev/null
+++ b/cmd/client/assets/assets.json
@@ -0,0 +1,184 @@
+{
+ "assets": {
+ "baskeball": {
+ "ID": "baskeball",
+ "Type": "obstacle",
+ "Filename": "baskeball.png",
+ "Scale": 0.1,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -3,
+ "DrawOffY": -158,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 3,
+ "OffsetY": 64,
+ "W": 99,
+ "H": 96,
+ "Type": ""
+ }
+ },
+ "coin": {
+ "ID": "coin",
+ "Type": "obstacle",
+ "Filename": "coin.png",
+ "Scale": 0.1,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -7,
+ "DrawOffY": -163,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 6,
+ "OffsetY": 63,
+ "W": 94,
+ "H": 100,
+ "Type": ""
+ }
+ },
+ "desk": {
+ "ID": "desk",
+ "Type": "obstacle",
+ "Filename": "desk.png",
+ "Scale": 0.1,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -2,
+ "DrawOffY": -155,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 4,
+ "OffsetY": 65,
+ "W": 100,
+ "H": 93,
+ "Type": ""
+ }
+ },
+ "eraser": {
+ "ID": "eraser",
+ "Type": "obstacle",
+ "Filename": "eraser.png",
+ "Scale": 0.05,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -29,
+ "DrawOffY": -61,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 30,
+ "OffsetY": 14,
+ "W": 73,
+ "H": 47,
+ "Type": ""
+ }
+ },
+ "g-l": {
+ "ID": "g-l",
+ "Type": "obstacle",
+ "Filename": "g-l.png",
+ "Scale": 0.5,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -65,
+ "DrawOffY": -381,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 64,
+ "OffsetY": 1,
+ "W": 121,
+ "H": 384,
+ "Type": ""
+ }
+ },
+ "h-l": {
+ "ID": "h-l",
+ "Type": "obstacle",
+ "Filename": "h-l.png",
+ "Scale": 0.15,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -19,
+ "DrawOffY": -357,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 21,
+ "OffsetY": 2,
+ "W": 107,
+ "H": 360,
+ "Type": ""
+ }
+ },
+ "k-l-monitor": {
+ "ID": "k-l-monitor",
+ "Type": "obstacle",
+ "Filename": "k-l-monitor.png",
+ "Scale": 0.15,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -33,
+ "DrawOffY": -332,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 7,
+ "OffsetY": 10,
+ "W": 147,
+ "H": 328,
+ "Type": ""
+ }
+ },
+ "pc-trash": {
+ "ID": "pc-trash",
+ "Type": "obstacle",
+ "Filename": "pc-trash.png",
+ "Scale": 0.15,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -3,
+ "DrawOffY": -240,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 5,
+ "OffsetY": 111,
+ "W": 154,
+ "H": 132,
+ "Type": ""
+ }
+ },
+ "platform_1767135546": {
+ "ID": "platform_1767135546",
+ "Type": "platform",
+ "Filename": "platform_1767135546.png",
+ "Scale": 1,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": 1,
+ "DrawOffY": -34,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 0,
+ "OffsetY": 2,
+ "W": 126,
+ "H": 28,
+ "Type": ""
+ }
+ },
+ "player": {
+ "ID": "player",
+ "Type": "obstacle",
+ "Filename": "player.png",
+ "Scale": 7,
+ "ProcWidth": 0,
+ "ProcHeight": 0,
+ "DrawOffX": -53,
+ "DrawOffY": -216,
+ "Color": {},
+ "Hitbox": {
+ "OffsetX": 53,
+ "OffsetY": 12,
+ "W": 108,
+ "H": 203,
+ "Type": ""
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/cmd/client/assets/baskeball.png b/cmd/client/assets/baskeball.png
new file mode 100644
index 0000000..2bcc206
Binary files /dev/null and b/cmd/client/assets/baskeball.png differ
diff --git a/cmd/client/assets/chunks/chunk_01.json b/cmd/client/assets/chunks/chunk_01.json
new file mode 100644
index 0000000..618bca6
--- /dev/null
+++ b/cmd/client/assets/chunks/chunk_01.json
@@ -0,0 +1,36 @@
+{
+ "ID": "chunk_01",
+ "Width": 50,
+ "Objects": [
+ {
+ "AssetID": "platform_1767135546",
+ "X": 419,
+ "Y": 517
+ },
+ {
+ "AssetID": "platform_1767135546",
+ "X": 549,
+ "Y": 516
+ },
+ {
+ "AssetID": "platform_1767135546",
+ "X": 682,
+ "Y": 449
+ },
+ {
+ "AssetID": "platform_1767135546",
+ "X": 808,
+ "Y": 449
+ },
+ {
+ "AssetID": "eraser",
+ "X": 1280,
+ "Y": 529
+ },
+ {
+ "AssetID": "pc-trash",
+ "X": 1960,
+ "Y": 533
+ }
+ ]
+}
\ No newline at end of file
diff --git a/cmd/client/assets/coin.png b/cmd/client/assets/coin.png
new file mode 100644
index 0000000..2cdb6e2
Binary files /dev/null and b/cmd/client/assets/coin.png differ
diff --git a/cmd/client/assets/desk.png b/cmd/client/assets/desk.png
new file mode 100644
index 0000000..b6fecaf
Binary files /dev/null and b/cmd/client/assets/desk.png differ
diff --git a/cmd/client/assets/eraser.png b/cmd/client/assets/eraser.png
new file mode 100644
index 0000000..58b1824
Binary files /dev/null and b/cmd/client/assets/eraser.png differ
diff --git a/cmd/client/assets/g-l.png b/cmd/client/assets/g-l.png
new file mode 100644
index 0000000..6f6d012
Binary files /dev/null and b/cmd/client/assets/g-l.png differ
diff --git a/cmd/client/assets/gen_plat_1767135546.png b/cmd/client/assets/gen_plat_1767135546.png
new file mode 100644
index 0000000..f70e80d
Binary files /dev/null and b/cmd/client/assets/gen_plat_1767135546.png differ
diff --git a/cmd/client/assets/gym-background.jpg b/cmd/client/assets/gym-background.jpg
new file mode 100644
index 0000000..e4dd3ab
Binary files /dev/null and b/cmd/client/assets/gym-background.jpg differ
diff --git a/cmd/client/assets/h-l.png b/cmd/client/assets/h-l.png
new file mode 100644
index 0000000..89ff30c
Binary files /dev/null and b/cmd/client/assets/h-l.png differ
diff --git a/cmd/client/assets/k-l-monitor.png b/cmd/client/assets/k-l-monitor.png
new file mode 100644
index 0000000..e347b1f
Binary files /dev/null and b/cmd/client/assets/k-l-monitor.png differ
diff --git a/cmd/client/assets/k-l.png b/cmd/client/assets/k-l.png
new file mode 100644
index 0000000..1085020
Binary files /dev/null and b/cmd/client/assets/k-l.png differ
diff --git a/cmd/client/assets/k-m.png b/cmd/client/assets/k-m.png
new file mode 100644
index 0000000..3df6b53
Binary files /dev/null and b/cmd/client/assets/k-m.png differ
diff --git a/cmd/client/assets/m-l.png b/cmd/client/assets/m-l.png
new file mode 100644
index 0000000..800b141
Binary files /dev/null and b/cmd/client/assets/m-l.png differ
diff --git a/cmd/client/assets/p-l.png b/cmd/client/assets/p-l.png
new file mode 100644
index 0000000..e659e9e
Binary files /dev/null and b/cmd/client/assets/p-l.png differ
diff --git a/cmd/client/assets/pc-trash.png b/cmd/client/assets/pc-trash.png
new file mode 100644
index 0000000..3335dde
Binary files /dev/null and b/cmd/client/assets/pc-trash.png differ
diff --git a/cmd/client/assets/platform_1767135546.png b/cmd/client/assets/platform_1767135546.png
new file mode 100644
index 0000000..f70e80d
Binary files /dev/null and b/cmd/client/assets/platform_1767135546.png differ
diff --git a/cmd/client/assets/player.png b/cmd/client/assets/player.png
new file mode 100644
index 0000000..fc2131c
Binary files /dev/null and b/cmd/client/assets/player.png differ
diff --git a/cmd/client/assets/r-l.png b/cmd/client/assets/r-l.png
new file mode 100644
index 0000000..daf3d2d
Binary files /dev/null and b/cmd/client/assets/r-l.png differ
diff --git a/cmd/client/assets/school-background.jpg b/cmd/client/assets/school-background.jpg
new file mode 100644
index 0000000..b049512
Binary files /dev/null and b/cmd/client/assets/school-background.jpg differ
diff --git a/cmd/client/assets/school2-background.jpg b/cmd/client/assets/school2-background.jpg
new file mode 100644
index 0000000..dc6a800
Binary files /dev/null and b/cmd/client/assets/school2-background.jpg differ
diff --git a/cmd/client/assets/t-s.png b/cmd/client/assets/t-s.png
new file mode 100644
index 0000000..85b6a3d
Binary files /dev/null and b/cmd/client/assets/t-s.png differ
diff --git a/cmd/client/assets/w-l.png b/cmd/client/assets/w-l.png
new file mode 100644
index 0000000..01353fa
Binary files /dev/null and b/cmd/client/assets/w-l.png differ
diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go
new file mode 100644
index 0000000..2e2cfc6
--- /dev/null
+++ b/cmd/client/game_render.go
@@ -0,0 +1,309 @@
+package main
+
+import (
+ "fmt"
+ "image/color"
+ "math"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+ "github.com/hajimehoshi/ebiten/v2/text"
+ "github.com/hajimehoshi/ebiten/v2/vector"
+ "golang.org/x/image/font/basicfont"
+)
+
+// --- INPUT & UPDATE LOGIC ---
+
+func (g *Game) UpdateGame() {
+ // --- 1. KEYBOARD INPUT ---
+ keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
+ keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
+ keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
+ keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
+
+ // --- 2. TOUCH INPUT HANDLING ---
+ g.handleTouchInput()
+
+ // --- 3. INPUTS ZUSAMMENFÜHREN & SENDEN ---
+
+ if g.connected {
+ // A) BEWEGUNG (Links/Rechts)
+ // Joystick auswerten (-1 bis 1)
+ joyDir := 0.0
+ if g.joyActive {
+ diffX := g.joyStickX - g.joyBaseX
+ if diffX < -20 {
+ joyDir = -1
+ } // Nach Links gezogen
+ if diffX > 20 {
+ joyDir = 1
+ } // Nach Rechts gezogen
+ }
+
+ // Senden: Keyboard ODER Joystick
+ if keyLeft || joyDir == -1 {
+ g.SendCommand("LEFT_DOWN")
+ } else if keyRight || joyDir == 1 {
+ g.SendCommand("RIGHT_DOWN")
+ } else {
+ // Wenn weder Links noch Rechts gedrückt ist, senden wir STOP.
+ g.SendCommand("LEFT_UP")
+ g.SendCommand("RIGHT_UP")
+ }
+
+ // B) NACH UNTEN (Fast Fall)
+ // Joystick weit nach unten gezogen?
+ isJoyDown := false
+ if g.joyActive && (g.joyStickY-g.joyBaseY) > 40 {
+ isJoyDown = true
+ }
+
+ if keyDown || isJoyDown {
+ g.SendCommand("DOWN")
+ }
+
+ // C) SPRINGEN
+ // Keyboard ODER Touch-Button
+ if keyJump || g.btnJumpActive {
+ g.SendCommand("JUMP")
+ g.btnJumpActive = false // Reset (Tap to jump)
+ }
+ }
+
+ // --- 4. KAMERA LOGIK ---
+ g.stateMutex.Lock()
+ defer g.stateMutex.Unlock()
+
+ // Wir folgen strikt dem Server-Scroll.
+ targetCam := g.gameState.ScrollX
+
+ // Negative Kamera verhindern
+ if targetCam < 0 {
+ targetCam = 0
+ }
+
+ // Kamera hart setzen
+ g.camX = targetCam
+}
+
+// Verarbeitet Touch-Eingaben für Joystick und Buttons
+func (g *Game) handleTouchInput() {
+ touches := ebiten.TouchIDs()
+
+ // Reset, wenn keine Finger mehr auf dem Display sind
+ if len(touches) == 0 {
+ g.joyActive = false
+ g.joyStickX = g.joyBaseX
+ g.joyStickY = g.joyBaseY
+ return
+ }
+
+ joyFound := false
+
+ for _, id := range touches {
+ x, y := ebiten.TouchPosition(id)
+ fx, fy := float64(x), float64(y)
+
+ // 1. RECHTE SEITE: JUMP BUTTON
+ // Alles rechts der Bildschirmmitte ist "Springen"
+ if fx > ScreenWidth/2 {
+ // Prüfen, ob dieser Touch gerade NEU dazu gekommen ist
+ for _, justID := range inpututil.JustPressedTouchIDs() {
+ if id == justID {
+ g.btnJumpActive = true
+ break
+ }
+ }
+ continue
+ }
+
+ // 2. LINKE SEITE: JOYSTICK
+ // Wenn wir noch keinen Joystick-Finger haben, prüfen wir, ob dieser Finger startet
+ if !g.joyActive {
+ // Prüfen ob Touch in der Nähe der Joystick-Basis ist (Radius 150 Toleranz)
+ dist := math.Sqrt(math.Pow(fx-g.joyBaseX, 2) + math.Pow(fy-g.joyBaseY, 2))
+ if dist < 150 {
+ g.joyActive = true
+ g.joyTouchID = id
+ }
+ }
+
+ // Wenn das der Joystick-Finger ist -> Stick bewegen
+ if g.joyActive && id == g.joyTouchID {
+ joyFound = true
+
+ // Vektor berechnen (Wie weit ziehen wir weg?)
+ dx := fx - g.joyBaseX
+ dy := fy - g.joyBaseY
+ dist := math.Sqrt(dx*dx + dy*dy)
+ maxDist := 60.0 // Maximaler Radius des Sticks
+
+ // Begrenzen auf Radius
+ if dist > maxDist {
+ scale := maxDist / dist
+ dx *= scale
+ dy *= scale
+ }
+
+ g.joyStickX = g.joyBaseX + dx
+ g.joyStickY = g.joyBaseY + dy
+ }
+ }
+
+ // Wenn der Joystick-Finger losgelassen wurde, Joystick resetten
+ if !joyFound {
+ g.joyActive = false
+ g.joyStickX = g.joyBaseX
+ g.joyStickY = g.joyBaseY
+ }
+}
+
+// --- RENDERING LOGIC ---
+
+func (g *Game) DrawGame(screen *ebiten.Image) {
+ // 1. Hintergrund & Boden
+ screen.Fill(ColSky)
+
+ floorH := float32(ScreenHeight - RefFloorY)
+ vector.DrawFilledRect(screen, 0, float32(RefFloorY), float32(ScreenWidth), floorH, ColGrass, false)
+ vector.DrawFilledRect(screen, 0, float32(RefFloorY)+20, float32(ScreenWidth), floorH-20, ColDirt, false)
+
+ // State Locken für Datenzugriff
+ g.stateMutex.Lock()
+ defer g.stateMutex.Unlock()
+
+ // 2. Chunks (Welt-Objekte)
+ for _, activeChunk := range g.gameState.WorldChunks {
+ chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
+ if !exists {
+ continue
+ }
+
+ for _, obj := range chunkDef.Objects {
+ // Asset zeichnen
+ g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y)
+ }
+ }
+
+ // 3. Spieler
+ for id, p := range g.gameState.Players {
+ g.DrawAsset(screen, "player", p.X, p.Y)
+
+ // Name Tag
+ name := p.Name
+ if name == "" {
+ name = id
+ }
+ text.Draw(screen, name, basicfont.Face7x13, int(p.X-g.camX), int(p.Y-25), ColText)
+
+ // DEBUG: Rote Hitbox
+ if def, ok := g.world.Manifest.Assets["player"]; ok {
+ hx := float32(p.X + def.DrawOffX + def.Hitbox.OffsetX - g.camX)
+ hy := float32(p.Y + def.DrawOffY + def.Hitbox.OffsetY)
+ vector.StrokeRect(screen, hx, hy, float32(def.Hitbox.W), float32(def.Hitbox.H), 2, color.RGBA{255, 0, 0, 255}, false)
+ }
+ }
+
+ // 4. UI Status
+ if g.gameState.Status == "COUNTDOWN" {
+ msg := fmt.Sprintf("GO IN: %d", g.gameState.TimeLeft)
+ text.Draw(screen, msg, basicfont.Face7x13, ScreenWidth/2-40, ScreenHeight/2, color.RGBA{255, 255, 0, 255})
+ } else if g.gameState.Status == "RUNNING" {
+ dist := fmt.Sprintf("Distance: %.0f m", g.camX/64.0)
+ text.Draw(screen, dist, basicfont.Face7x13, ScreenWidth-150, 30, ColText)
+ }
+
+ // 5. DEBUG: TODES-LINIE
+ vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
+ text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
+
+ // 6. TOUCH CONTROLS OVERLAY
+
+ // A) Joystick Base
+ baseCol := color.RGBA{255, 255, 255, 50}
+ vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, true)
+ vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{255, 255, 255, 100}, true)
+
+ // B) Joystick Knob
+ knobCol := color.RGBA{255, 255, 255, 150}
+ if g.joyActive {
+ knobCol = color.RGBA{100, 255, 100, 200}
+ }
+ vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, true)
+
+ // C) Jump Button (Rechts)
+ jumpX := float32(ScreenWidth - 150)
+ jumpY := float32(ScreenHeight - 150)
+ vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, true)
+ vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, true)
+ text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.White)
+
+ // 7. DEBUG INFO (Oben Links)
+ myPosStr := "N/A"
+ for _, p := range g.gameState.Players {
+ myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
+ break
+ }
+
+ debugMsg := fmt.Sprintf(
+ "FPS: %.2f\nState: %s\nPlayers: %d\nCamX: %.0f\nPos: %s",
+ ebiten.CurrentFPS(),
+ g.gameState.Status,
+ len(g.gameState.Players),
+ g.camX,
+ myPosStr,
+ )
+
+ vector.DrawFilledRect(screen, 10, 10, 200, 90, color.RGBA{0, 0, 0, 180}, false)
+ text.Draw(screen, debugMsg, basicfont.Face7x13, 20, 30, color.White)
+}
+
+// --- ASSET HELPER ---
+
+func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY float64) {
+ // 1. Definition laden
+ def, ok := g.world.Manifest.Assets[assetID]
+ if !ok {
+ return
+ }
+
+ // 2. Screen Position berechnen (Welt - Kamera)
+ screenX := worldX - g.camX
+ screenY := worldY
+
+ // Optimierung: Nicht zeichnen, wenn komplett außerhalb
+ if screenX < -200 || screenX > ScreenWidth+200 {
+ return
+ }
+
+ // 3. Bild holen
+ img := g.assetsImages[assetID]
+
+ if img != nil {
+ op := &ebiten.DrawImageOptions{}
+
+ // Skalieren
+ op.GeoM.Scale(def.Scale, def.Scale)
+
+ // Positionieren: ScreenPos + DrawOffset
+ op.GeoM.Translate(
+ screenX+def.DrawOffX,
+ screenY+def.DrawOffY,
+ )
+
+ // Farbe anwenden
+ op.ColorScale.ScaleWithColor(def.Color.ToRGBA())
+
+ screen.DrawImage(img, op)
+ } else {
+ // FALLBACK (Buntes Rechteck)
+ vector.DrawFilledRect(screen,
+ float32(screenX+def.Hitbox.OffsetX),
+ float32(screenY+def.Hitbox.OffsetY),
+ float32(def.Hitbox.W),
+ float32(def.Hitbox.H),
+ def.Color.ToRGBA(),
+ false,
+ )
+ }
+}
diff --git a/cmd/client/go.mod b/cmd/client/go.mod
new file mode 100644
index 0000000..7533100
--- /dev/null
+++ b/cmd/client/go.mod
@@ -0,0 +1,21 @@
+module git.zb-server.de/ZB-Server/EscapeFromTeacher/client
+
+go 1.25.5
+
+require (
+ github.com/hajimehoshi/ebiten/v2 v2.9.6
+ github.com/nats-io/nats.go v1.47.0
+)
+
+require (
+ github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
+ github.com/ebitengine/hideconsole v1.0.0 // indirect
+ github.com/ebitengine/purego v0.9.0 // indirect
+ github.com/jezek/xgb v1.1.1 // indirect
+ 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/sync v0.17.0 // indirect
+ golang.org/x/sys v0.36.0 // indirect
+)
diff --git a/cmd/client/go.sum b/cmd/client/go.sum
new file mode 100644
index 0000000..b4394f9
--- /dev/null
+++ b/cmd/client/go.sum
@@ -0,0 +1,26 @@
+github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0=
+github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
+github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
+github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
+github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
+github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/hajimehoshi/ebiten/v2 v2.9.6 h1:uP41hMkfcbfEfgiTlpzhgnTHGAAfbM/v/pNOZkelI78=
+github.com/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
+github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
+github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
+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/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
+golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
+golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
diff --git a/cmd/levelbuilder/go.mod b/cmd/levelbuilder/go.mod
new file mode 100644
index 0000000..4887316
--- /dev/null
+++ b/cmd/levelbuilder/go.mod
@@ -0,0 +1,3 @@
+module git.zb-server.de/ZB-Server/EscapeFromTeacher/levelbuilder
+
+go 1.25.5
diff --git a/cmd/levelbuilder/main.go b/cmd/levelbuilder/main.go
new file mode 100644
index 0000000..08fead3
--- /dev/null
+++ b/cmd/levelbuilder/main.go
@@ -0,0 +1,521 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "image/color"
+ "io/ioutil"
+ "log"
+ "math"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/ebitenutil"
+ "github.com/hajimehoshi/ebiten/v2/inpututil"
+ "github.com/hajimehoshi/ebiten/v2/text"
+ "github.com/hajimehoshi/ebiten/v2/vector"
+ "golang.org/x/image/font/basicfont"
+
+ "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
+)
+
+// --- CONFIG ---
+const (
+ AssetFile = "./cmd/client/assets/assets.json"
+ ChunkDir = "./cmd/client/assets/chunks"
+
+ SidebarWidth = 250
+ TopBarHeight = 40
+ CanvasHeight = 720
+ CanvasWidth = 1280
+
+ TileSize = 64
+ RefFloorY = 540
+)
+
+var (
+ ColBgSidebar = color.RGBA{40, 44, 52, 255}
+ ColBgTop = color.RGBA{35, 35, 40, 255}
+ ColBgCanvas = color.RGBA{30, 30, 30, 255}
+ ColGrid = color.RGBA{60, 60, 60, 255}
+ ColFloor = color.RGBA{0, 255, 0, 150}
+ ColText = color.RGBA{220, 220, 220, 255}
+ ColHighlight = color.RGBA{80, 120, 200, 255}
+ ColHitbox = color.RGBA{255, 0, 0, 200}
+ ColHitboxFill = color.RGBA{255, 0, 0, 50}
+ ColOrigin = color.RGBA{255, 255, 0, 255} // Gelb für Referenzpunkt
+)
+
+type LevelEditor struct {
+ assetManifest game.AssetManifest
+ assetList []string
+ assetsImages map[string]*ebiten.Image
+
+ currentChunk game.Chunk
+
+ scrollX float64
+ zoom float64
+ listScroll float64
+ statusMsg string
+
+ showGrid bool
+ enableSnap bool
+ showHitbox bool
+ showPlayerRef bool // NEU: Spieler Ghost anzeigen
+
+ activeField string
+ inputBuffer string
+
+ isDragging bool
+ dragType string
+ dragAssetID string
+ dragTargetIndex int
+ dragOffset game.Vec2
+}
+
+func NewLevelEditor() *LevelEditor {
+ le := &LevelEditor{
+ assetsImages: make(map[string]*ebiten.Image),
+ currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
+ zoom: 1.0,
+ showGrid: true,
+ enableSnap: true,
+ showHitbox: true,
+ showPlayerRef: true, // Standardmäßig an
+ }
+ le.LoadAssets()
+ le.LoadChunk("chunk_01.json")
+ return le
+}
+
+func (le *LevelEditor) LoadAssets() {
+ data, err := ioutil.ReadFile(AssetFile)
+ if err != nil {
+ fmt.Println("⚠️ Konnte Assets nicht laden:", AssetFile)
+ le.assetManifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
+ } else {
+ json.Unmarshal(data, &le.assetManifest)
+ }
+
+ baseDir := "./cmd/client/assets"
+ le.assetList = []string{}
+
+ for id, def := range le.assetManifest.Assets {
+ le.assetList = append(le.assetList, id)
+ if def.Filename != "" {
+ fullPath := filepath.Join(baseDir, def.Filename)
+ img, _, err := ebitenutil.NewImageFromFile(fullPath)
+ if err == nil {
+ le.assetsImages[id] = img
+ }
+ }
+ }
+ sort.Strings(le.assetList)
+}
+
+func (le *LevelEditor) LoadChunk(filename string) {
+ path := filepath.Join(ChunkDir, filename)
+ data, err := ioutil.ReadFile(path)
+ if err == nil {
+ json.Unmarshal(data, &le.currentChunk)
+ le.statusMsg = "Geladen: " + filename
+ } else {
+ le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename))
+ le.statusMsg = "Neu erstellt: " + le.currentChunk.ID
+ }
+}
+
+func (le *LevelEditor) SaveChunk() {
+ os.MkdirAll(ChunkDir, 0755)
+ filename := le.currentChunk.ID + ".json"
+ path := filepath.Join(ChunkDir, filename)
+ data, _ := json.MarshalIndent(le.currentChunk, "", " ")
+ ioutil.WriteFile(path, data, 0644)
+ le.statusMsg = "GESPEICHERT als " + filename
+}
+
+func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) {
+ screenX := float64(mx - SidebarWidth)
+ screenY := float64(my - TopBarHeight)
+ worldX := (screenX / le.zoom) + le.scrollX
+ worldY := screenY / le.zoom
+ return worldX, worldY
+}
+
+func (le *LevelEditor) GetSmartSnap(x, y float64, objHeight float64) (float64, float64) {
+ shouldSnap := le.enableSnap
+ if ebiten.IsKeyPressed(ebiten.KeyShift) {
+ shouldSnap = !shouldSnap
+ }
+
+ if shouldSnap {
+ sx := math.Floor(x/TileSize) * TileSize
+
+ // Y Smart Snap: Unterkante ans Raster
+ gridLine := math.Round(y/TileSize) * TileSize
+ sy := gridLine - objHeight
+
+ return sx, sy
+ }
+ return x, y
+}
+
+func (le *LevelEditor) GetAssetSize(id string) (float64, float64) {
+ def, ok := le.assetManifest.Assets[id]
+ if !ok {
+ return 64, 64
+ }
+ w := def.Hitbox.W
+ h := def.Hitbox.H
+ if w == 0 {
+ w = def.ProcWidth
+ }
+ if h == 0 {
+ h = def.ProcHeight
+ }
+ if w == 0 {
+ w = 64
+ }
+ if h == 0 {
+ h = 64
+ }
+ return w, h
+}
+
+func (le *LevelEditor) Update() error {
+ mx, my := ebiten.CursorPosition()
+
+ // Text Input
+ if le.activeField != "" {
+ if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
+ if le.activeField == "id" {
+ le.currentChunk.ID = le.inputBuffer
+ }
+ if le.activeField == "width" {
+ if v, err := strconv.Atoi(le.inputBuffer); err == nil {
+ le.currentChunk.Width = v
+ }
+ }
+ le.activeField = ""
+ } else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
+ le.activeField = ""
+ } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
+ if len(le.inputBuffer) > 0 {
+ le.inputBuffer = le.inputBuffer[:len(le.inputBuffer)-1]
+ }
+ } else {
+ le.inputBuffer += string(ebiten.InputChars())
+ }
+ return nil
+ }
+
+ // Hotkeys
+ if mx > SidebarWidth {
+ _, wy := ebiten.Wheel()
+ if wy != 0 {
+ le.zoom += wy * 0.1
+ if le.zoom < 0.2 {
+ le.zoom = 0.2
+ }
+ if le.zoom > 3.0 {
+ le.zoom = 3.0
+ }
+ }
+ }
+ if inpututil.IsKeyJustPressed(ebiten.KeyS) {
+ le.SaveChunk()
+ }
+ if inpututil.IsKeyJustPressed(ebiten.KeyG) {
+ le.showGrid = !le.showGrid
+ }
+ if inpututil.IsKeyJustPressed(ebiten.KeyH) {
+ le.showHitbox = !le.showHitbox
+ }
+ if inpututil.IsKeyJustPressed(ebiten.KeyP) {
+ le.showPlayerRef = !le.showPlayerRef
+ } // NEU: Toggle Player
+ if ebiten.IsKeyPressed(ebiten.KeyRight) {
+ le.scrollX += 10 / le.zoom
+ }
+ if ebiten.IsKeyPressed(ebiten.KeyLeft) {
+ le.scrollX -= 10 / le.zoom
+ if le.scrollX < 0 {
+ le.scrollX = 0
+ }
+ }
+
+ // UI
+ if my < TopBarHeight {
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
+ if mx >= 70 && mx < 220 {
+ le.activeField = "id"
+ le.inputBuffer = le.currentChunk.ID
+ }
+ if mx >= 300 && mx < 360 {
+ le.activeField = "width"
+ le.inputBuffer = fmt.Sprintf("%d", le.currentChunk.Width)
+ }
+ }
+ return nil
+ }
+
+ // Palette
+ if mx < SidebarWidth {
+ _, wy := ebiten.Wheel()
+ le.listScroll -= wy * 20
+ if le.listScroll < 0 {
+ le.listScroll = 0
+ }
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
+ clickY := float64(my-TopBarHeight) + le.listScroll - 40.0
+ if clickY >= 0 {
+ idx := int(clickY / 25)
+ if idx >= 0 && idx < len(le.assetList) {
+ le.isDragging = true
+ le.dragType = "new"
+ le.dragAssetID = le.assetList[idx]
+ }
+ }
+ }
+ return nil
+ }
+
+ // Canvas Logic
+ worldX, worldY := le.ScreenToWorld(mx, my)
+
+ // DELETE
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
+ for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
+ obj := le.currentChunk.Objects[i]
+ w, h := le.GetAssetSize(obj.AssetID)
+ if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
+ le.currentChunk.Objects = append(le.currentChunk.Objects[:i], le.currentChunk.Objects[i+1:]...)
+ return nil
+ }
+ }
+ }
+
+ // MOVE
+ if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging {
+ for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
+ obj := le.currentChunk.Objects[i]
+ w, h := le.GetAssetSize(obj.AssetID)
+ if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
+ le.isDragging = true
+ le.dragType = "move"
+ le.dragTargetIndex = i
+ le.dragAssetID = obj.AssetID
+ le.dragOffset = game.Vec2{X: worldX - obj.X, Y: worldY - obj.Y}
+ return nil
+ }
+ }
+ }
+
+ // DRAGGING
+ if le.isDragging {
+ rawWX, rawWY := le.ScreenToWorld(mx, my)
+ _, h := le.GetAssetSize(le.dragAssetID)
+
+ if le.dragType == "move" {
+ targetX := rawWX - le.dragOffset.X
+ targetY := rawWY - le.dragOffset.Y
+ snapX, snapY := le.GetSmartSnap(targetX, targetY+h, h)
+ if !le.enableSnap {
+ snapX = targetX
+ snapY = targetY
+ }
+
+ if le.dragTargetIndex < len(le.currentChunk.Objects) {
+ le.currentChunk.Objects[le.dragTargetIndex].X = snapX
+ le.currentChunk.Objects[le.dragTargetIndex].Y = snapY
+ }
+ }
+
+ if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
+ if le.dragType == "new" {
+ finalX, finalY := le.GetSmartSnap(rawWX, rawWY, h)
+ newObj := game.LevelObject{AssetID: le.dragAssetID, X: finalX, Y: finalY}
+ le.currentChunk.Objects = append(le.currentChunk.Objects, newObj)
+ }
+ le.isDragging = false
+ le.dragType = ""
+ }
+ }
+
+ return nil
+}
+
+func (le *LevelEditor) Draw(screen *ebiten.Image) {
+ // UI HINTERGRUND
+ vector.DrawFilledRect(screen, 0, 0, CanvasWidth, TopBarHeight, ColBgTop, false)
+ vector.DrawFilledRect(screen, 0, TopBarHeight, SidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
+
+ text.Draw(screen, "ID: "+le.currentChunk.ID, basicfont.Face7x13, 75, 25, color.White)
+
+ // ASSET LISTE
+ startY := float64(TopBarHeight+40) - le.listScroll
+ for i, id := range le.assetList {
+ y := startY + float64(i*25)
+ if y < float64(TopBarHeight) || y > CanvasHeight {
+ continue
+ }
+ col := ColText
+ if le.isDragging && le.dragType == "new" && le.dragAssetID == id {
+ col = ColHighlight
+ }
+ text.Draw(screen, id, basicfont.Face7x13, 10, int(y+15), col)
+ }
+
+ // CANVAS
+ canvasOffX := float64(SidebarWidth)
+ canvasOffY := float64(TopBarHeight)
+
+ // GRID
+ if le.showGrid {
+ startGridX := int(le.scrollX/TileSize) * TileSize
+ for x := startGridX; x < startGridX+int(CanvasWidth/le.zoom)+TileSize; x += TileSize {
+ drawX := float32((float64(x)-le.scrollX)*le.zoom + canvasOffX)
+ vector.StrokeLine(screen, drawX, float32(canvasOffY), drawX, CanvasHeight, 1, ColGrid, false)
+ }
+ for y := 0; y < int(CanvasHeight/le.zoom); y += TileSize {
+ drawY := float32(float64(y)*le.zoom + canvasOffY)
+ vector.StrokeLine(screen, float32(canvasOffX), drawY, CanvasWidth, drawY, 1, ColGrid, false)
+ }
+ }
+
+ // BODEN LINIE
+ floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY)
+ vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, float32(CanvasWidth), floorScreenY, 2, ColFloor, false)
+
+ // PLAYER REFERENCE (GHOST)
+ // PLAYER REFERENCE (GHOST)
+ if le.showPlayerRef {
+ playerDef, ok := le.assetManifest.Assets["player"]
+ if ok {
+ // 1. Richtige Y-Position berechnen, damit Füße auf dem Boden stehen
+ // Formel: Boden - HitboxHöhe - Alle Offsets
+ // Weil: (Pos + DrawOff + HitboxOff) + HitboxH = Boden
+
+ pH := playerDef.Hitbox.H
+ if pH == 0 {
+ pH = 64
+ } // Fallback
+
+ // Hier ist die korrigierte Mathe:
+ pY := float64(RefFloorY) - pH - playerDef.Hitbox.OffsetY - playerDef.DrawOffY
+
+ // Zeichne Referenz-Spieler bei Welt-X = ScrollX + 200
+ refWorldX := le.scrollX + 200
+
+ // Wir übergeben pY direkt. DrawAsset addiert dann wieder DrawOffY dazu.
+ // Dadurch gleicht es sich aus und die Hitbox landet exakt auf dem Boden.
+ le.DrawAsset(screen, "player", refWorldX, pY, canvasOffX, canvasOffY, 0.5)
+
+ // Label
+ sX := (refWorldX-le.scrollX)*le.zoom + canvasOffX
+ text.Draw(screen, "REF PLAYER", basicfont.Face7x13, int(sX), int(floorScreenY)+20, ColHighlight)
+ }
+ }
+
+ // OBJEKTE
+ for _, obj := range le.currentChunk.Objects {
+ le.DrawAsset(screen, obj.AssetID, obj.X, obj.Y, canvasOffX, canvasOffY, 1.0)
+ }
+
+ // DRAG GHOST
+ if le.isDragging && le.dragType == "new" {
+ mx, my := ebiten.CursorPosition()
+ if mx > SidebarWidth && my > TopBarHeight {
+ wRawX, wRawY := le.ScreenToWorld(mx, my)
+ _, h := le.GetAssetSize(le.dragAssetID)
+ snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h)
+ le.DrawAsset(screen, le.dragAssetID, snapX, snapY, canvasOffX, canvasOffY, 0.6)
+
+ txt := fmt.Sprintf("Y: %.0f", snapY)
+ text.Draw(screen, txt, basicfont.Face7x13, mx+10, my, ColHighlight)
+ }
+ }
+
+ // STATUS
+ text.Draw(screen, "[S]ave | [G]rid | [H]itbox | [P]layer Ref | R-Click=Del", basicfont.Face7x13, 400, 25, color.Gray{100})
+ text.Draw(screen, le.statusMsg, basicfont.Face7x13, SidebarWidth+10, CanvasHeight-10, ColHighlight)
+}
+
+func (le *LevelEditor) DrawAsset(screen *ebiten.Image, id string, wX, wY, offX, offY float64, alpha float32) {
+ sX := (wX-le.scrollX)*le.zoom + offX
+ sY := wY*le.zoom + offY
+
+ if sX < SidebarWidth-100 || sX > CanvasWidth {
+ return
+ }
+
+ def, ok := le.assetManifest.Assets[id]
+ if !ok {
+ return
+ }
+
+ // 1. SPRITE
+ if def.Filename != "" {
+ img := le.assetsImages[id]
+ if img != nil {
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Scale(def.Scale*le.zoom, def.Scale*le.zoom)
+ drawX := sX + (def.DrawOffX * le.zoom)
+ drawY := sY + (def.DrawOffY * le.zoom)
+ op.GeoM.Translate(drawX, drawY)
+ op.ColorScale.ScaleAlpha(alpha)
+ screen.DrawImage(img, op)
+ }
+ } else {
+ col := def.Color.ToRGBA()
+ col.A = uint8(float32(col.A) * alpha)
+ w := float32((def.ProcWidth) * le.zoom)
+ h := float32((def.ProcHeight) * le.zoom)
+ if w == 0 {
+ w = 64 * float32(le.zoom)
+ h = 64 * float32(le.zoom)
+ }
+ vector.DrawFilledRect(screen, float32(sX), float32(sY), w, h, col, false)
+ }
+
+ // 2. HITBOX (Rot)
+ if le.showHitbox {
+ hx := float32(sX + (def.DrawOffX * le.zoom) + (def.Hitbox.OffsetX * le.zoom))
+ hy := float32(sY + (def.DrawOffY * le.zoom) + (def.Hitbox.OffsetY * le.zoom))
+ hw := float32(def.Hitbox.W * le.zoom)
+ hh := float32(def.Hitbox.H * le.zoom)
+ if hw == 0 {
+ if def.ProcWidth > 0 {
+ hw = float32(def.ProcWidth * le.zoom)
+ hh = float32(def.ProcHeight * le.zoom)
+ } else {
+ hw = 64 * float32(le.zoom)
+ hh = 64 * float32(le.zoom)
+ }
+ }
+ vector.StrokeRect(screen, hx, hy, hw, hh, 2, ColHitbox, false)
+ vector.DrawFilledRect(screen, hx, hy, hw, hh, ColHitboxFill, false)
+ }
+
+ // 3. REFERENZPUNKT (Gelbes Kreuz) <-- DAS WOLLTEST DU!
+ // Zeigt exakt die Koordinate (X, Y) des Objekts
+ cX := float32(sX)
+ cY := float32(sY)
+ vector.StrokeLine(screen, cX-5, cY, cX+5, cY, 2, ColOrigin, false)
+ vector.StrokeLine(screen, cX, cY-5, cX, cY+5, 2, ColOrigin, false)
+}
+
+func (le *LevelEditor) Layout(w, h int) (int, int) { return CanvasWidth, CanvasHeight }
+
+func main() {
+ os.MkdirAll(ChunkDir, 0755)
+ ebiten.SetWindowSize(CanvasWidth, CanvasHeight)
+ ebiten.SetWindowTitle("Escape Level Editor - Reference Point Added")
+ if err := ebiten.RunGame(NewLevelEditor()); err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/cmd/server/go.mod b/cmd/server/go.mod
new file mode 100644
index 0000000..145195e
--- /dev/null
+++ b/cmd/server/go.mod
@@ -0,0 +1,16 @@
+module git.zb-server.de/ZB-Server/EscapeFromTeacher/server
+
+go 1.25.5
+
+require (
+ github.com/gorilla/websocket v1.5.3
+ 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
+)
diff --git a/cmd/server/go.sum b/cmd/server/go.sum
new file mode 100644
index 0000000..05226a2
--- /dev/null
+++ b/cmd/server/go.sum
@@ -0,0 +1,14 @@
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+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=
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..5f9c460
--- /dev/null
+++ b/go.work
@@ -0,0 +1,9 @@
+go 1.25.5
+
+use (
+ ./cmd/builder
+ ./cmd/client
+ ./cmd/levelbuilder
+ ./cmd/server
+ ./pkg
+)
diff --git a/go.work.sum b/go.work.sum
new file mode 100644
index 0000000..4ff4129
--- /dev/null
+++ b/go.work.sum
@@ -0,0 +1,42 @@
+github.com/ebitengine/debugui v0.2.0 h1:FPAgRRzB8QqsyRnMVlzyiVcZTg5l69BMpHlnHktdQg8=
+github.com/ebitengine/debugui v0.2.0/go.mod h1:I9KvQiFgUVO+a3GntY7k+t6QZBESqwKcoegEbYuddw4=
+github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ=
+github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI=
+github.com/gen2brain/mpeg v0.5.0 h1:YrY5F5ZQAJCF/ItDHSb0bF5fxwk5IDq/RjOnfFhWurs=
+github.com/gen2brain/mpeg v0.5.0/go.mod h1:N37OJKAg3YeMfVqscgraoU6kwusr4pvA8aJK9QWPGiQ=
+github.com/go-text/typesetting v0.3.0 h1:OWCgYpp8njoxSRpwrdd1bQOxdjOXDj9Rqart9ML4iF4=
+github.com/go-text/typesetting v0.3.0/go.mod h1:qjZLkhRgOEYMhU9eHBr3AR4sfnGJvOXNLt8yRAySFuY=
+github.com/hajimehoshi/bitmapfont/v4 v4.1.0 h1:eE3qa5Do4qhowZVIHjsrX5pYyyPN6sAFWMsO7QREm3U=
+github.com/hajimehoshi/bitmapfont/v4 v4.1.0/go.mod h1:/PD+aLjAJ0F2UoQx6hkOfXqWN7BkroDUMr5W+IT1dpE=
+github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
+github.com/jakecoffman/cp/v2 v2.3.0 h1:o27SCFFCsbX0aS5FLMYVdf4YuDoK3eNnCpkBizZDuQI=
+github.com/jakecoffman/cp/v2 v2.3.0/go.mod h1:6lPSBgxx6+//RIlSaMH3XaXtcCwPY1ZCJox1ThK5bZw=
+github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ=
+github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII=
+github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE=
+github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ=
+github.com/kisielk/errcheck v1.9.0 h1:9xt1zI9EBfcYBvdU1nVrzMzzUPUtPKs9bVSIM3TAb3M=
+github.com/kisielk/errcheck v1.9.0/go.mod h1:kQxWMMVZgIkDq7U8xtG/n2juOjbLgZtedi0D+/VL/i8=
+github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
+github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
+golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
+golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
+golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
+golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
+golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
+golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
+golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
+golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
+golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8=
diff --git a/pkg/config/config.go b/pkg/config/config.go
new file mode 100644
index 0000000..de2ae30
--- /dev/null
+++ b/pkg/config/config.go
@@ -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"
+)
diff --git a/pkg/game/world.go b/pkg/game/world.go
new file mode 100644
index 0000000..0c0ba00
--- /dev/null
+++ b/pkg/game/world.go
@@ -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
+}
diff --git a/pkg/go.mod b/pkg/go.mod
new file mode 100644
index 0000000..c0d4810
--- /dev/null
+++ b/pkg/go.mod
@@ -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
+)
diff --git a/pkg/go.sum b/pkg/go.sum
new file mode 100644
index 0000000..76dd281
--- /dev/null
+++ b/pkg/go.sum
@@ -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=
diff --git a/pkg/server/gateway.go b/pkg/server/gateway.go
new file mode 100644
index 0000000..7bbc91b
--- /dev/null
+++ b/pkg/server/gateway.go
@@ -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)
+}
diff --git a/pkg/server/room.go b/pkg/server/room.go
new file mode 100644
index 0000000..49129b8
--- /dev/null
+++ b/pkg/server/room.go
@@ -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."
+ 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)
+}
diff --git a/test_nats.go b/test_nats.go
new file mode 100644
index 0000000..9290518
--- /dev/null
+++ b/test_nats.go
@@ -0,0 +1,38 @@
+package main
+
+import (
+ "log"
+ "time"
+
+ "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
+ "github.com/nats-io/nats.go"
+)
+
+func main() {
+ log.Println("🧪 NATS Test Publisher startet...")
+
+ nc, err := nats.Connect("nats://localhost:4222")
+ if err != nil {
+ log.Fatal("❌ Verbindung fehlgeschlagen:", err)
+ }
+ defer nc.Close()
+
+ ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
+
+ log.Println("✅ Verbunden. Sende Test-Nachricht...")
+
+ req := game.JoinRequest{
+ Name: "TestPlayer",
+ RoomID: "testroom",
+ }
+
+ err = ec.Publish("game.join", req)
+ if err != nil {
+ log.Println("❌ Publish Fehler:", err)
+ } else {
+ log.Println("📤 Test-Nachricht gesendet!")
+ }
+
+ time.Sleep(2 * time.Second)
+ log.Println("✅ Test abgeschlossen")
+}