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") +}