diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..d59a84d --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,17 @@ + + + + + redis + true + jdbc.RedisDriver + jdbc:redis://localhost:6379/0 + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml new file mode 100644 index 0000000..aa93db1 --- /dev/null +++ b/.idea/data_source_mapping.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/queries/Query.sql b/.idea/queries/Query.sql new file mode 100644 index 0000000..e69de29 diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..cfb730d --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/assets_raw/godmode.png b/assets_raw/godmode.png new file mode 100644 index 0000000..40bff46 Binary files /dev/null and b/assets_raw/godmode.png differ diff --git a/assets_raw/jump0.png b/assets_raw/jump0.png new file mode 100644 index 0000000..b8835bb Binary files /dev/null and b/assets_raw/jump0.png differ diff --git a/assets_raw/jump1.png b/assets_raw/jump1.png new file mode 100644 index 0000000..b8c774b Binary files /dev/null and b/assets_raw/jump1.png differ diff --git a/assets_raw/jumpboost.png b/assets_raw/jumpboost.png new file mode 100644 index 0000000..7cd32bd Binary files /dev/null and b/assets_raw/jumpboost.png differ diff --git a/assets_raw/player.png b/assets_raw/player.png deleted file mode 100644 index fc2131c..0000000 Binary files a/assets_raw/player.png and /dev/null differ diff --git a/assets_raw/playernew.png b/assets_raw/playernew.png new file mode 100644 index 0000000..182d000 Binary files /dev/null and b/assets_raw/playernew.png differ diff --git a/bin/builder b/bin/builder new file mode 100755 index 0000000..f361705 Binary files /dev/null and b/bin/builder differ diff --git a/bin/client b/bin/client new file mode 100755 index 0000000..a2d092f Binary files /dev/null and b/bin/client differ diff --git a/bin/levelbuilder b/bin/levelbuilder new file mode 100755 index 0000000..e60bcc8 Binary files /dev/null and b/bin/levelbuilder differ diff --git a/bin/server b/bin/server new file mode 100755 index 0000000..27e0ef7 Binary files /dev/null and b/bin/server differ diff --git a/cmd/builder/main.go b/cmd/builder/main.go index b5e8434..2904c14 100644 --- a/cmd/builder/main.go +++ b/cmd/builder/main.go @@ -56,7 +56,7 @@ var ( ColPlayerRef = color.RGBA{0, 255, 255, 100} ) -var AssetTypes = []string{"obstacle", "platform", "powerup", "enemy", "deco", "coin"} +var AssetTypes = []string{"obstacle", "platform", "wall", "powerup", "enemy", "deco", "coin"} // --- HILFSFUNKTIONEN --- @@ -92,6 +92,58 @@ func generateBrickTexture(w, h int) *ebiten.Image { return img } +func generateWallTexture(w, h int) *ebiten.Image { + img := ebiten.NewImage(w, h) + // Dunklerer Hintergrund für Wände + img.Fill(color.RGBA{60, 60, 70, 255}) + + stoneColor := color.RGBA{100, 100, 110, 255} + stoneDark := color.RGBA{80, 80, 90, 255} + stoneLight := color.RGBA{120, 120, 130, 255} + + // Mehr Reihen und Spalten für Wände + rows := h / 16 + if rows < 2 { + rows = 2 + } + cols := w / 16 + if cols < 2 { + cols = 2 + } + + brickH := float32(h) / float32(rows) + brickW := float32(w) / float32(cols) + padding := float32(1) + + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + // Versatz für ungeraden Reihen (Mauerwerk-Muster) + xOffset := float32(0) + if row%2 != 0 { + xOffset = brickW / 2 + } + x := float32(col)*brickW + xOffset + y := float32(row) * brickH + + drawStone := func(bx, by float32) { + // Hauptstein + vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, brickH-padding*2, stoneColor, false) + // Schatten unten + vector.DrawFilledRect(img, bx+padding, by+brickH-padding-2, brickW-padding*2, 2, stoneDark, false) + // Highlight oben + vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, 2, stoneLight, false) + } + + drawStone(x, y) + // Wrap-around für versetzten Offset + if x+brickW > float32(w) { + drawStone(x-float32(w), y) + } + } + } + return img +} + func saveImageToDisk(img *ebiten.Image, filename string) error { stdImg := img.SubImage(img.Bounds()) assetDir := filepath.Dir(OutFile) @@ -282,6 +334,33 @@ func (e *Editor) CreatePlatform() { e.selectedID = id } +func (e *Editor) CreateWall() { + w, h := 64, 128 + texImg := generateWallTexture(w, h) + timestamp := time.Now().Unix() + filename := fmt.Sprintf("gen_wall_%d.png", timestamp) + id := fmt.Sprintf("wall_%d", timestamp) + + if err := saveImageToDisk(texImg, filename); err != nil { + log.Printf("Fehler beim Speichern: %v", err) + return + } + + e.assetsImages[id] = texImg + e.manifest.Assets[id] = game.AssetDefinition{ + ID: id, + Type: "wall", // Neuer Type für kletterbare Wände + Filename: filename, + Scale: 1.0, + Color: game.HexColor{R: 255, G: 255, B: 255, A: 255}, + DrawOffX: float64(-w) / 2, + DrawOffY: float64(-h), + Hitbox: game.Rect{W: float64(w), H: float64(h), OffsetX: float64(-w) / 2, OffsetY: float64(-h)}, + } + e.RebuildList() + e.selectedID = id +} + func (e *Editor) Update() error { if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" { e.Save() @@ -335,9 +414,13 @@ func (e *Editor) Update() error { currentY += float64(LineHeight) } - if my > CanvasHeight-40 { + // Button-Bereich unten + if my > CanvasHeight-75 && my <= CanvasHeight-40 { e.CreatePlatform() } + if my > CanvasHeight-40 { + e.CreateWall() + } } return nil } @@ -500,10 +583,15 @@ func (e *Editor) Draw(screen *ebiten.Image) { // --- 1. LISTE LINKS --- vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false) - // Button Neu - btnRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10) - vector.DrawFilledRect(screen, float32(btnRect.Min.X), float32(btnRect.Min.Y), float32(btnRect.Dx()), float32(btnRect.Dy()), ColHighlight, false) - text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-18, color.RGBA{255, 255, 255, 255}) + // Button Platform + btnPlatRect := image.Rect(10, CanvasHeight-70, WidthList-10, CanvasHeight-45) + vector.DrawFilledRect(screen, float32(btnPlatRect.Min.X), float32(btnPlatRect.Min.Y), float32(btnPlatRect.Dx()), float32(btnPlatRect.Dy()), ColHighlight, false) + text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-53, color.RGBA{255, 255, 255, 255}) + + // Button Wall + btnWallRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10) + vector.DrawFilledRect(screen, float32(btnWallRect.Min.X), float32(btnWallRect.Min.Y), float32(btnWallRect.Dx()), float32(btnWallRect.Dy()), color.RGBA{100, 100, 120, 255}, false) + text.Draw(screen, "+ NEW WALL", basicfont.Face7x13, 35, CanvasHeight-18, color.RGBA{255, 255, 255, 255}) // SCROLL BEREICH startY := 40.0 - e.listScroll @@ -511,7 +599,7 @@ func (e *Editor) Draw(screen *ebiten.Image) { // Helper Funktion zum Zeichnen von Listeneinträgen mit Bild drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) { - if currentY > -float64(LineHeight) && currentY < CanvasHeight-50 { + if currentY > -float64(LineHeight) && currentY < CanvasHeight-80 { // Bild Vorschau (Thumbnail) if img != nil { // Skalierung berechnen (max 28px hoch/breit) diff --git a/cmd/client/assets/assets.json b/cmd/client/assets/assets.json index e5a99ff..1854039 100644 --- a/cmd/client/assets/assets.json +++ b/cmd/client/assets/assets.json @@ -90,6 +90,24 @@ "Type": "" } }, + "godmode": { + "ID": "godmode", + "Type": "powerup", + "Filename": "godmode.png", + "Scale": 0.08, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 3, + "DrawOffY": -90, + "Color": {}, + "Hitbox": { + "OffsetX": -1, + "OffsetY": 3, + "W": 59, + "H": 85, + "Type": "" + } + }, "h-l": { "ID": "h-l", "Type": "obstacle", @@ -108,6 +126,78 @@ "Type": "" } }, + "jump0": { + "ID": "jump0", + "Type": "obstacle", + "Filename": "jump0.png", + "Scale": 0.17, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": -8, + "DrawOffY": -193, + "Color": {}, + "Hitbox": { + "OffsetX": 22, + "OffsetY": 6, + "W": 72, + "H": 183, + "Type": "" + } + }, + "jump1": { + "ID": "jump1", + "Type": "obstacle", + "Filename": "jump1.png", + "Scale": 0.16, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": -1, + "DrawOffY": -167, + "Color": {}, + "Hitbox": { + "OffsetX": 18, + "OffsetY": 11, + "W": 72, + "H": 149, + "Type": "" + } + }, + "jumpboost": { + "ID": "jumpboost", + "Type": "powerup", + "Filename": "jumpboost.png", + "Scale": 0.09, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 1, + "DrawOffY": -81, + "Color": {}, + "Hitbox": { + "OffsetX": 3, + "OffsetY": 2, + "W": 97, + "H": 76, + "Type": "" + } + }, + "k-l": { + "ID": "k-l", + "Type": "obstacle", + "Filename": "k-l.png", + "Scale": 0.12, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": -43, + "DrawOffY": -228, + "Color": {}, + "Hitbox": { + "OffsetX": 43, + "OffsetY": 5, + "W": 78, + "H": 222, + "Type": "" + } + }, "k-l-monitor": { "ID": "k-l-monitor", "Type": "obstacle", @@ -126,6 +216,24 @@ "Type": "" } }, + "k-m": { + "ID": "k-m", + "Type": "obstacle", + "Filename": "k-m.png", + "Scale": 1, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": -528, + "DrawOffY": -2280, + "Color": {}, + "Hitbox": { + "OffsetX": -528, + "OffsetY": -2280, + "W": 1056, + "H": 2280, + "Type": "" + } + }, "pc-trash": { "ID": "pc-trash", "Type": "obstacle", @@ -165,18 +273,90 @@ "player": { "ID": "player", "Type": "obstacle", - "Filename": "player.png", - "Scale": 7, + "Filename": "playernew.png", + "Scale": 0.08, "ProcWidth": 0, "ProcHeight": 0, - "DrawOffX": -53, - "DrawOffY": -216, + "DrawOffX": -56, + "DrawOffY": -231, "Color": {}, "Hitbox": { - "OffsetX": 53, + "OffsetX": 68, + "OffsetY": 42, + "W": 73, + "H": 184, + "Type": "" + } + }, + "background": { + "ID": "background", + "Type": "background", + "Filename": "background.jpg", + "Scale": 1, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 0, + "DrawOffY": 0, + "Color": {}, + "Hitbox": { + "OffsetX": 0, + "OffsetY": 0, + "W": 0, + "H": 0, + "Type": "" + } + }, + "background1": { + "ID": "background1", + "Type": "background", + "Filename": "background1.jpg", + "Scale": 1, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 0, + "DrawOffY": 0, + "Color": {}, + "Hitbox": { + "OffsetX": 0, + "OffsetY": 0, + "W": 0, + "H": 0, + "Type": "" + } + }, + "background2": { + "ID": "background2", + "Type": "background", + "Filename": "background2.jpg", + "Scale": 1, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 0, + "DrawOffY": 0, + "Color": {}, + "Hitbox": { + "OffsetX": 0, + "OffsetY": 0, + "W": 0, + "H": 0, + "Type": "" + } + }, + "wall_1767369789": { + "ID": "wall_1767369789", + "Type": "obstacle", + "Filename": "gen_wall_1767369789.png", + "Scale": 1, + "ProcWidth": 0, + "ProcHeight": 0, + "DrawOffX": 1, + "DrawOffY": -127, + "Color": {}, + "Hitbox": { + "OffsetX": 4, "OffsetY": 12, - "W": 108, - "H": 203, + "W": 55, + "H": 113, "Type": "" } } diff --git a/assets_raw/school2-background.jpg b/cmd/client/assets/background.jpg similarity index 100% rename from assets_raw/school2-background.jpg rename to cmd/client/assets/background.jpg diff --git a/assets_raw/school-background.jpg b/cmd/client/assets/background1.jpg similarity index 100% rename from assets_raw/school-background.jpg rename to cmd/client/assets/background1.jpg diff --git a/assets_raw/gym-background.jpg b/cmd/client/assets/background2.jpg similarity index 100% rename from assets_raw/gym-background.jpg rename to cmd/client/assets/background2.jpg diff --git a/cmd/client/assets/chunks/chunk_01.json b/cmd/client/assets/chunks/chunk_01.json index 618bca6..8ece2e0 100644 --- a/cmd/client/assets/chunks/chunk_01.json +++ b/cmd/client/assets/chunks/chunk_01.json @@ -31,6 +31,11 @@ "AssetID": "pc-trash", "X": 1960, "Y": 533 + }, + { + "AssetID": "coin", + "X": 1024, + "Y": 412 } ] } \ No newline at end of file diff --git a/cmd/client/assets/chunks/chunk_02.json b/cmd/client/assets/chunks/chunk_02.json new file mode 100644 index 0000000..6784d6d --- /dev/null +++ b/cmd/client/assets/chunks/chunk_02.json @@ -0,0 +1,61 @@ +{ + "ID": "chunk_02", + "Width": 50, + "Objects": [ + { + "AssetID": "coin", + "X": 512, + "Y": 476 + }, + { + "AssetID": "coin", + "X": 704, + "Y": 476 + }, + { + "AssetID": "coin", + "X": 1024, + "Y": 476 + }, + { + "AssetID": "platform_1767135546", + "X": 1152, + "Y": 484 + }, + { + "AssetID": "platform_1767135546", + "X": 1344, + "Y": 420 + }, + { + "AssetID": "platform_1767135546", + "X": 1472, + "Y": 420 + }, + { + "AssetID": "platform_1767135546", + "X": 1600, + "Y": 420 + }, + { + "AssetID": "platform_1767135546", + "X": 1792, + "Y": 292 + }, + { + "AssetID": "platform_1767135546", + "X": 1920, + "Y": 292 + }, + { + "AssetID": "platform_1767135546", + "X": 2048, + "Y": 292 + }, + { + "AssetID": "coin", + "X": 2112, + "Y": 220 + } + ] +} \ No newline at end of file diff --git a/cmd/client/assets/chunks/chunk_03.json b/cmd/client/assets/chunks/chunk_03.json new file mode 100644 index 0000000..b08b7e7 --- /dev/null +++ b/cmd/client/assets/chunks/chunk_03.json @@ -0,0 +1,65 @@ +{ + "ID": "chunk_03", + "Width": 50, + "Objects": [ + { + "AssetID": "platform_1767135546", + "X": 514, + "Y": 519, + "moving_platform": { + "start_x": 514, + "start_y": 522, + "end_x": 800, + "end_y": 239, + "speed": 100 + } + }, + { + "AssetID": "coin", + "X": 834, + "Y": 204 + }, + { + "AssetID": "wall_1767369789", + "X": 1026, + "Y": 539 + }, + { + "AssetID": "wall_1767369789", + "X": 1026, + "Y": 412 + }, + { + "AssetID": "platform_1767135546", + "X": 1091, + "Y": 318, + "moving_platform": { + "start_x": 1109, + "start_y": 304, + "end_x": 1898, + "end_y": 307, + "speed": 50 + } + }, + { + "AssetID": "desk", + "X": 1421, + "Y": 534 + }, + { + "AssetID": "desk", + "X": 1794, + "Y": 535 + }, + { + "AssetID": "coin", + "X": 1169, + "Y": 272 + }, + { + "AssetID": "coin", + "X": 1598, + "Y": 260 + } + ] +} \ No newline at end of file diff --git a/cmd/client/assets/chunks/chunk_04.json b/cmd/client/assets/chunks/chunk_04.json new file mode 100644 index 0000000..66efcdc --- /dev/null +++ b/cmd/client/assets/chunks/chunk_04.json @@ -0,0 +1,16 @@ +{ + "ID": "chunk_04", + "Width": 50, + "Objects": [ + { + "AssetID": "godmode", + "X": 569, + "Y": 535 + }, + { + "AssetID": "jumpboost", + "X": 680, + "Y": 538 + } + ] +} \ No newline at end of file diff --git a/cmd/client/assets/fonts/press-start-2p-v16-latin-regular.woff2 b/cmd/client/assets/fonts/press-start-2p-v16-latin-regular.woff2 new file mode 100644 index 0000000..792c6bd Binary files /dev/null and b/cmd/client/assets/fonts/press-start-2p-v16-latin-regular.woff2 differ diff --git a/cmd/client/assets/front.ttf b/cmd/client/assets/front.ttf new file mode 100644 index 0000000..4932a6e Binary files /dev/null and b/cmd/client/assets/front.ttf differ diff --git a/cmd/client/assets/game.mp3 b/cmd/client/assets/game.mp3 new file mode 100644 index 0000000..c2b5b27 Binary files /dev/null and b/cmd/client/assets/game.mp3 differ diff --git a/cmd/client/assets/game.wav b/cmd/client/assets/game.wav new file mode 100644 index 0000000..9004443 Binary files /dev/null and b/cmd/client/assets/game.wav differ diff --git a/cmd/client/assets/gen_plat_1767369130.png b/cmd/client/assets/gen_plat_1767369130.png new file mode 100644 index 0000000..f70e80d Binary files /dev/null and b/cmd/client/assets/gen_plat_1767369130.png differ diff --git a/cmd/client/assets/gen_wall_1767369789.png b/cmd/client/assets/gen_wall_1767369789.png new file mode 100644 index 0000000..4188d04 Binary files /dev/null and b/cmd/client/assets/gen_wall_1767369789.png differ diff --git a/cmd/client/assets/godmode.png b/cmd/client/assets/godmode.png new file mode 100644 index 0000000..dd97f02 Binary files /dev/null and b/cmd/client/assets/godmode.png differ diff --git a/cmd/client/assets/gym-background.jpg b/cmd/client/assets/gym-background.jpg deleted file mode 100644 index e4dd3ab..0000000 Binary files a/cmd/client/assets/gym-background.jpg and /dev/null differ diff --git a/cmd/client/assets/jump.wav b/cmd/client/assets/jump.wav new file mode 100644 index 0000000..2ab8462 Binary files /dev/null and b/cmd/client/assets/jump.wav differ diff --git a/cmd/client/assets/jump0.png b/cmd/client/assets/jump0.png new file mode 100644 index 0000000..90fb058 Binary files /dev/null and b/cmd/client/assets/jump0.png differ diff --git a/cmd/client/assets/jump1.png b/cmd/client/assets/jump1.png new file mode 100644 index 0000000..c1a1397 Binary files /dev/null and b/cmd/client/assets/jump1.png differ diff --git a/cmd/client/assets/jumpboost.png b/cmd/client/assets/jumpboost.png new file mode 100644 index 0000000..c111d37 Binary files /dev/null and b/cmd/client/assets/jumpboost.png differ diff --git a/cmd/client/assets/k-l.png b/cmd/client/assets/k-l.png index 1085020..3035054 100644 Binary files a/cmd/client/assets/k-l.png 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 index 3df6b53..659a2bb 100644 Binary files a/cmd/client/assets/k-m.png and b/cmd/client/assets/k-m.png differ diff --git a/cmd/client/assets/p-l.png b/cmd/client/assets/p-l.png index e659e9e..34dbe8b 100644 Binary files a/cmd/client/assets/p-l.png and b/cmd/client/assets/p-l.png differ diff --git a/cmd/client/assets/pickupCoin.wav b/cmd/client/assets/pickupCoin.wav new file mode 100644 index 0000000..b64a1b5 Binary files /dev/null and b/cmd/client/assets/pickupCoin.wav differ diff --git a/cmd/client/assets/playernew.png b/cmd/client/assets/playernew.png new file mode 100644 index 0000000..fbce2a0 Binary files /dev/null and b/cmd/client/assets/playernew.png differ diff --git a/cmd/client/assets/powerUp.wav b/cmd/client/assets/powerUp.wav new file mode 100644 index 0000000..0faee31 Binary files /dev/null and b/cmd/client/assets/powerUp.wav differ diff --git a/cmd/client/assets/r-l.png b/cmd/client/assets/r-l.png index daf3d2d..a1867fc 100644 Binary files a/cmd/client/assets/r-l.png 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 deleted file mode 100644 index b049512..0000000 Binary files a/cmd/client/assets/school-background.jpg and /dev/null differ diff --git a/cmd/client/assets/school2-background.jpg b/cmd/client/assets/school2-background.jpg deleted file mode 100644 index dc6a800..0000000 Binary files a/cmd/client/assets/school2-background.jpg and /dev/null differ diff --git a/cmd/client/assets/t-s.png b/cmd/client/assets/t-s.png index 85b6a3d..5de46b8 100644 Binary files a/cmd/client/assets/t-s.png 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 index 01353fa..fd8dd92 100644 Binary files a/cmd/client/assets/w-l.png and b/cmd/client/assets/w-l.png differ diff --git a/cmd/client/assets_native.go b/cmd/client/assets_native.go new file mode 100644 index 0000000..b391586 --- /dev/null +++ b/cmd/client/assets_native.go @@ -0,0 +1,55 @@ +//go:build !wasm +// +build !wasm + +package main + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "log" + "path/filepath" + + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +func (g *Game) loadAssets() { + // Pfad anpassen: Wir suchen im relativen Pfad + baseDir := "./cmd/client/assets" + manifestPath := filepath.Join(baseDir, "assets.json") + + data, err := ioutil.ReadFile(manifestPath) + if err == nil { + var m game.AssetManifest + json.Unmarshal(data, &m) + g.world.Manifest = m + fmt.Println("✅ Assets Manifest geladen (Native):", len(m.Assets), "Einträge") + } else { + log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath) + // Fallback: Leeres Manifest, damit das Spiel nicht abstürzt + g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)} + } + + // Chunks laden (plattformspezifisch via Build-Tags) + g.loadChunks() + + // Bilder vorladen + loadedImages := 0 + failedImages := 0 + for id, def := range g.world.Manifest.Assets { + if def.Filename != "" { + path := filepath.Join(baseDir, def.Filename) + img, _, err := ebitenutil.NewImageFromFile(path) + if err == nil { + g.assetsImages[id] = img + loadedImages++ + } else { + log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err) + failedImages++ + } + } + } + fmt.Printf("🖼️ Bilder (Native): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages) +} diff --git a/cmd/client/assets_wasm.go b/cmd/client/assets_wasm.go new file mode 100644 index 0000000..3477c6c --- /dev/null +++ b/cmd/client/assets_wasm.go @@ -0,0 +1,53 @@ +//go:build wasm +// +build wasm + +package main + +import ( + _ "embed" + "encoding/json" + "fmt" + "log" + "path/filepath" + + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +//go:embed assets/assets.json +var assetsJSON []byte + +func (g *Game) loadAssets() { + // Assets Manifest aus eingebetteter Datei laden + var m game.AssetManifest + if err := json.Unmarshal(assetsJSON, &m); err != nil { + log.Printf("⚠️ Fehler beim Parsen von assets.json: %v", err) + g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)} + } else { + g.world.Manifest = m + fmt.Println("✅ Assets Manifest geladen (WASM):", len(m.Assets), "Einträge") + } + + // Chunks laden (auch per go:embed in chunks_wasm.go) + g.loadChunks() + + // Bilder vorladen - In WASM sind die Assets relativ zum HTML + baseDir := "./assets" + loadedImages := 0 + failedImages := 0 + for id, def := range g.world.Manifest.Assets { + if def.Filename != "" { + path := filepath.Join(baseDir, def.Filename) + img, _, err := ebitenutil.NewImageFromFile(path) + if err == nil { + g.assetsImages[id] = img + loadedImages++ + } else { + log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err) + failedImages++ + } + } + } + fmt.Printf("🖼️ Bilder (WASM): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages) +} diff --git a/cmd/client/audio.go b/cmd/client/audio.go new file mode 100644 index 0000000..d413379 --- /dev/null +++ b/cmd/client/audio.go @@ -0,0 +1,237 @@ +package main + +import ( + "bytes" + _ "embed" + "log" + + "github.com/hajimehoshi/ebiten/v2/audio" + "github.com/hajimehoshi/ebiten/v2/audio/mp3" + "github.com/hajimehoshi/ebiten/v2/audio/wav" +) + +const ( + SampleRate = 44100 // Muss mit der MP3 Sample-Rate übereinstimmen +) + +//go:embed assets/game.mp3 +var gameMusicData []byte + +//go:embed assets/jump.wav +var jumpSoundData []byte + +//go:embed assets/pickupCoin.wav +var coinSoundData []byte + +//go:embed assets/powerUp.wav +var powerUpSoundData []byte + +// AudioSystem verwaltet Musik und Sound-Effekte +type AudioSystem struct { + audioContext *audio.Context + + // Musik + musicPlayer *audio.Player + musicVolume float64 + + // Sound-Effekte + jumpSound []byte + coinSound []byte + powerUpSound []byte + sfxVolume float64 + + // Mute + muted bool +} + +// NewAudioSystem erstellt ein neues Audio-System +func NewAudioSystem() *AudioSystem { + log.Println("🎵 Initialisiere Audio-System...") + ctx := audio.NewContext(SampleRate) + + as := &AudioSystem{ + audioContext: ctx, + musicVolume: 0.3, // 30% Standard-Lautstärke + sfxVolume: 0.5, // 50% Standard-Lautstärke + muted: false, + } + + // Musik laden + log.Printf("📀 Lade Musik (%.2f MB)...", float64(len(gameMusicData))/(1024*1024)) + as.loadMusic() + + // Sound-Effekte dekodieren + log.Println("🔊 Lade Sound-Effekte...") + as.jumpSound = as.loadWav(jumpSoundData) + as.coinSound = as.loadWav(coinSoundData) + as.powerUpSound = as.loadWav(powerUpSoundData) + + log.Println("✅ Audio-System bereit") + return as +} + +// loadMusic lädt und startet die Hintergrundmusik +func (as *AudioSystem) loadMusic() { + // MP3 dekodieren + stream, err := mp3.DecodeWithSampleRate(SampleRate, bytes.NewReader(gameMusicData)) + if err != nil { + log.Printf("❌ Fehler beim Laden der Musik: %v", err) + return + } + + // Infinite Loop + loop := audio.NewInfiniteLoop(stream, stream.Length()) + + // Player erstellen + player, err := as.audioContext.NewPlayer(loop) + if err != nil { + log.Printf("❌ Fehler beim Erstellen des Music Players: %v", err) + return + } + + as.musicPlayer = player + as.updateMusicVolume() + + log.Println("🎵 Musik geladen") +} + +// loadWav lädt eine WAV-Datei und gibt die dekodierten Bytes zurück +func (as *AudioSystem) loadWav(data []byte) []byte { + stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data)) + if err != nil { + log.Printf("❌ Fehler beim Laden von WAV: %v", err) + return nil + } + + // Stream in Bytes lesen + decoded := bytes.NewBuffer(nil) + _, err = decoded.ReadFrom(stream) + if err != nil { + log.Printf("❌ Fehler beim Dekodieren von WAV: %v", err) + return nil + } + + return decoded.Bytes() +} + +// PlayMusic startet die Hintergrundmusik +func (as *AudioSystem) PlayMusic() { + if as.musicPlayer == nil { + log.Println("⚠️ Music Player ist nil - Musik kann nicht gestartet werden") + return + } + if as.musicPlayer.IsPlaying() { + log.Println("⚠️ Musik läuft bereits") + return + } + as.musicPlayer.Play() + log.Printf("▶️ Musik gestartet (Volume: %.2f, Muted: %v)", as.musicVolume, as.muted) +} + +// StopMusic stoppt die Hintergrundmusik +func (as *AudioSystem) StopMusic() { + if as.musicPlayer != nil && as.musicPlayer.IsPlaying() { + as.musicPlayer.Pause() + log.Println("⏸️ Musik gestoppt") + } +} + +// PlayJump spielt den Jump-Sound ab +func (as *AudioSystem) PlayJump() { + if as.muted { + return + } + as.playSoundEffect(as.jumpSound, as.sfxVolume) +} + +// PlayCoin spielt den Coin-Pickup-Sound ab +func (as *AudioSystem) PlayCoin() { + if as.muted { + return + } + as.playSoundEffect(as.coinSound, as.sfxVolume) +} + +// PlayPowerUp spielt den PowerUp-Sound ab +func (as *AudioSystem) PlayPowerUp() { + if as.muted { + return + } + as.playSoundEffect(as.powerUpSound, as.sfxVolume) +} + +// playSoundEffect spielt einen Sound-Effekt ab +func (as *AudioSystem) playSoundEffect(soundData []byte, volume float64) { + if soundData == nil { + return + } + + player := as.audioContext.NewPlayerFromBytes(soundData) + if player == nil { + return + } + + player.SetVolume(volume) + player.Play() +} + +// SetMusicVolume setzt die Musik-Lautstärke (0.0 - 1.0) +func (as *AudioSystem) SetMusicVolume(volume float64) { + as.musicVolume = clamp(volume, 0.0, 1.0) + as.updateMusicVolume() +} + +// SetSFXVolume setzt die Sound-Effekt-Lautstärke (0.0 - 1.0) +func (as *AudioSystem) SetSFXVolume(volume float64) { + as.sfxVolume = clamp(volume, 0.0, 1.0) +} + +// ToggleMute schaltet Mute an/aus +func (as *AudioSystem) ToggleMute() { + as.muted = !as.muted + as.updateMusicVolume() + log.Printf("🔇 Mute: %v", as.muted) +} + +// SetMuted setzt Mute-Status +func (as *AudioSystem) SetMuted(muted bool) { + as.muted = muted + as.updateMusicVolume() +} + +// IsMuted gibt zurück, ob der Sound gemutet ist +func (as *AudioSystem) IsMuted() bool { + return as.muted +} + +// GetMusicVolume gibt die aktuelle Musik-Lautstärke zurück +func (as *AudioSystem) GetMusicVolume() float64 { + return as.musicVolume +} + +// GetSFXVolume gibt die aktuelle SFX-Lautstärke zurück +func (as *AudioSystem) GetSFXVolume() float64 { + return as.sfxVolume +} + +// updateMusicVolume aktualisiert die Musik-Lautstärke +func (as *AudioSystem) updateMusicVolume() { + if as.musicPlayer != nil { + if as.muted { + as.musicPlayer.SetVolume(0) + } else { + as.musicPlayer.SetVolume(as.musicVolume) + } + } +} + +// clamp begrenzt einen Wert zwischen min und max +func clamp(value, min, max float64) float64 { + if value < min { + return min + } + if value > max { + return max + } + return value +} diff --git a/cmd/client/chunks_native.go b/cmd/client/chunks_native.go new file mode 100644 index 0000000..020af05 --- /dev/null +++ b/cmd/client/chunks_native.go @@ -0,0 +1,25 @@ +//go:build !wasm +// +build !wasm + +package main + +import ( + "log" + "path/filepath" +) + +// loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop) +func (g *Game) loadChunks() { + baseDir := "cmd/client/assets" + chunkDir := filepath.Join(baseDir, "chunks") + + err := g.world.LoadChunkLibrary(chunkDir) + if err != nil { + log.Println("⚠️ Chunks konnten nicht geladen werden:", err) + } else { + log.Printf("✅ Chunks geladen: %d Einträge", len(g.world.ChunkLibrary)) + for id, chunk := range g.world.ChunkLibrary { + log.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d", id, chunk.Width, len(chunk.Objects)) + } + } +} diff --git a/cmd/client/chunks_wasm.go b/cmd/client/chunks_wasm.go new file mode 100644 index 0000000..bcad62c --- /dev/null +++ b/cmd/client/chunks_wasm.go @@ -0,0 +1,55 @@ +//go:build wasm +// +build wasm + +package main + +import ( + _ "embed" + "encoding/json" + "log" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +//go:embed assets/chunks/start.json +var chunkStartData []byte + +//go:embed assets/chunks/chunk_01.json +var chunk01Data []byte + +//go:embed assets/chunks/chunk_02.json +var chunk02Data []byte + +//go:embed assets/chunks/chunk_03.json +var chunk03Data []byte + +//go:embed assets/chunks/chunk_04.json +var chunk04Data []byte + +// loadChunks lädt alle Chunks aus eingebetteten Daten (WebAssembly) +func (g *Game) loadChunks() { + chunkDataMap := map[string][]byte{ + "start": chunkStartData, + "chunk_01": chunk01Data, + "chunk_02": chunk02Data, + "chunk_03": chunk03Data, + "chunk_04": chunk04Data, + } + + count := 0 + for id, data := range chunkDataMap { + var c game.Chunk + if err := json.Unmarshal(data, &c); err == nil { + if c.ID == "" { + c.ID = id + } + g.world.ChunkLibrary[c.ID] = c + count++ + log.Printf("📦 Chunk geladen: %s (Width=%d, Objects=%d)", c.ID, c.Width, len(c.Objects)) + } else { + log.Printf("⚠️ Fehler beim Laden von Chunk %s: %v", id, err) + } + } + + log.Printf("✅ %d Chunks für WASM geladen", count) +} diff --git a/cmd/client/connection_native.go b/cmd/client/connection_native.go new file mode 100644 index 0000000..7136fc5 --- /dev/null +++ b/cmd/client/connection_native.go @@ -0,0 +1,268 @@ +//go:build !wasm +// +build !wasm + +package main + +import ( + "encoding/json" + "log" + "net/url" + "time" + + "github.com/gorilla/websocket" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// wsConn verwaltet die WebSocket-Verbindung (Native Desktop) +type wsConn struct { + conn *websocket.Conn + send chan []byte + stopChan chan struct{} +} + +// WebSocketMessage ist das Format für WebSocket-Nachrichten +type WebSocketMessage struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// connectToServer verbindet sich über WebSocket mit dem Gateway (Native Desktop) +func (g *Game) connectToServer() { + serverURL := "ws://localhost:8080/ws" + log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL) + + u, err := url.Parse(serverURL) + if err != nil { + log.Printf("❌ URL Parse Fehler: %v", err) + return + } + + conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) + if err != nil { + log.Printf("❌ WebSocket Connect Fehler: %v", err) + return + } + + wsConn := &wsConn{ + conn: conn, + send: make(chan []byte, 100), + stopChan: make(chan struct{}), + } + g.wsConn = wsConn + + log.Println("✅ WebSocket verbunden!") + g.connected = true + + // Goroutines für Lesen und Schreiben starten + go g.wsReadPump() + go g.wsWritePump() + + // JOIN senden + g.sendJoinRequest() +} + +// wsReadPump liest Nachrichten vom WebSocket +func (g *Game) wsReadPump() { + defer func() { + g.wsConn.conn.Close() + g.connected = false + }() + + for { + var msg WebSocketMessage + err := g.wsConn.conn.ReadJSON(&msg) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("⚠️ WebSocket Fehler: %v", err) + } + break + } + + switch msg.Type { + case "game_update": + // GameState Update + payloadBytes, _ := json.Marshal(msg.Payload) + var state game.GameState + if err := json.Unmarshal(payloadBytes, &state); err == nil { + // Server Reconciliation für lokalen Spieler (VOR dem Lock) + for _, p := range state.Players { + if p.Name == g.playerName { + g.ReconcileWithServer(p) + break + } + } + + g.stateMutex.Lock() + g.gameState = state + g.stateMutex.Unlock() + } + + case "leaderboard_response": + // Leaderboard Response + payloadBytes, _ := json.Marshal(msg.Payload) + var resp game.LeaderboardResponse + if err := json.Unmarshal(payloadBytes, &resp); err == nil { + g.leaderboardMutex.Lock() + g.leaderboard = resp.Entries + g.leaderboardMutex.Unlock() + log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries)) + } + } + } +} + +// wsWritePump sendet Nachrichten zum WebSocket +func (g *Game) wsWritePump() { + defer g.wsConn.conn.Close() + + for { + select { + case message, ok := <-g.wsConn.send: + if !ok { + g.wsConn.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + err := g.wsConn.conn.WriteMessage(websocket.TextMessage, message) + if err != nil { + log.Printf("⚠️ Fehler beim Senden: %v", err) + return + } + + case <-g.wsConn.stopChan: + return + } + } +} + +// sendWebSocketMessage sendet eine Nachricht über WebSocket +func (g *Game) sendWebSocketMessage(msg WebSocketMessage) { + if g.wsConn == nil || !g.connected { + log.Println("⚠️ WebSocket nicht verbunden") + return + } + + data, err := json.Marshal(msg) + if err != nil { + log.Printf("❌ Fehler beim Marshallen: %v", err) + return + } + + select { + case g.wsConn.send <- data: + default: + log.Println("⚠️ Send channel voll, Nachricht verworfen") + } +} + +// sendJoinRequest sendet Join-Request über WebSocket +func (g *Game) sendJoinRequest() { + msg := WebSocketMessage{ + Type: "join", + Payload: game.JoinRequest{ + Name: g.playerName, + RoomID: g.roomID, + GameMode: g.gameMode, + IsHost: g.isHost, + TeamName: g.teamName, + }, + } + g.sendWebSocketMessage(msg) + log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID) +} + +// sendStartRequest sendet Start-Request über WebSocket +func (g *Game) sendStartRequest() { + msg := WebSocketMessage{ + Type: "start", + Payload: game.StartRequest{ + RoomID: g.roomID, + }, + } + g.sendWebSocketMessage(msg) + log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID) +} + +// publishInput sendet Input über WebSocket +func (g *Game) publishInput(input game.ClientInput) { + msg := WebSocketMessage{ + Type: "input", + Payload: input, + } + g.sendWebSocketMessage(msg) +} + +// connectForLeaderboard verbindet für Leaderboard (Native) +func (g *Game) connectForLeaderboard() { + if g.wsConn != nil && g.connected { + // Bereits verbunden + g.requestLeaderboard() + return + } + + // Neue Verbindung aufbauen + g.connectToServer() + + // Kurz warten und dann Leaderboard anfragen + time.Sleep(500 * time.Millisecond) + g.requestLeaderboard() +} + +// requestLeaderboard fordert Leaderboard an (Native) +func (g *Game) requestLeaderboard() { + mode := "solo" + if g.gameMode == "coop" { + mode = "coop" + } + + msg := WebSocketMessage{ + Type: "leaderboard_request", + Payload: game.LeaderboardRequest{ + Mode: mode, + }, + } + g.sendWebSocketMessage(msg) + log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode) +} + +// submitScore sendet Score ans Leaderboard (Native) +func (g *Game) submitScore() { + if g.scoreSubmitted { + return + } + + g.stateMutex.Lock() + score := 0 + for _, p := range g.gameState.Players { + if p.Name == g.playerName { + score = p.Score + break + } + } + g.stateMutex.Unlock() + + if score == 0 { + log.Println("⚠️ Score ist 0, überspringe Submission") + return + } + + name := g.playerName + if g.gameMode == "coop" && g.teamName != "" { + name = g.teamName + } + + msg := WebSocketMessage{ + Type: "score_submit", + Payload: game.ScoreSubmission{ + PlayerCode: g.playerCode, + Name: name, + Score: score, + Mode: g.gameMode, + }, + } + g.sendWebSocketMessage(msg) + + g.scoreSubmitted = true + log.Printf("📊 Score submitted: %s = %d", name, score) +} diff --git a/cmd/client/connection_wasm.go b/cmd/client/connection_wasm.go new file mode 100644 index 0000000..3ca4379 --- /dev/null +++ b/cmd/client/connection_wasm.go @@ -0,0 +1,270 @@ +//go:build wasm +// +build wasm + +package main + +import ( + "encoding/json" + "log" + "syscall/js" + "time" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +// WebSocketMessage ist das Format für WebSocket-Nachrichten +type WebSocketMessage struct { + Type string `json:"type"` + Payload interface{} `json:"payload"` +} + +// wsConn verwaltet die WebSocket-Verbindung im Browser +type wsConn struct { + ws js.Value + messagesChan chan []byte + connected bool +} + +// connectToServer verbindet sich über WebSocket mit dem Gateway +func (g *Game) connectToServer() { + serverURL := "ws://localhost:8080/ws" + log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL) + + ws := js.Global().Get("WebSocket").New(serverURL) + + conn := &wsConn{ + ws: ws, + messagesChan: make(chan []byte, 100), + connected: false, + } + + // OnOpen Handler + ws.Call("addEventListener", "open", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + log.Println("✅ WebSocket verbunden!") + conn.connected = true + g.connected = true + return nil + })) + + // OnMessage Handler + ws.Call("addEventListener", "message", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) == 0 { + return nil + } + + data := args[0].Get("data").String() + + var msg WebSocketMessage + if err := json.Unmarshal([]byte(data), &msg); err != nil { + log.Printf("❌ Fehler beim Parsen der Nachricht: %v", err) + return nil + } + + switch msg.Type { + case "game_update": + // GameState Update + payloadBytes, _ := json.Marshal(msg.Payload) + var state game.GameState + if err := json.Unmarshal(payloadBytes, &state); err == nil { + // Server Reconciliation für lokalen Spieler (VOR dem Lock) + for _, p := range state.Players { + if p.Name == g.playerName { + g.ReconcileWithServer(p) + break + } + } + + g.stateMutex.Lock() + g.gameState = state + g.stateMutex.Unlock() + } + + case "leaderboard_response": + // Leaderboard Response + payloadBytes, _ := json.Marshal(msg.Payload) + var resp game.LeaderboardResponse + if err := json.Unmarshal(payloadBytes, &resp); err == nil { + g.leaderboardMutex.Lock() + g.leaderboard = resp.Entries + g.leaderboardMutex.Unlock() + log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries)) + + // An JavaScript senden + g.sendLeaderboardToJS() + } + } + + return nil + })) + + // OnError Handler + ws.Call("addEventListener", "error", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + log.Println("❌ WebSocket Fehler!") + conn.connected = false + g.connected = false + return nil + })) + + // OnClose Handler + ws.Call("addEventListener", "close", js.FuncOf(func(this js.Value, args []js.Value) interface{} { + log.Println("🔌 WebSocket geschlossen") + conn.connected = false + g.connected = false + return nil + })) + + // Warte bis Verbindung hergestellt ist + for i := 0; i < 50; i++ { + if conn.connected { + break + } + time.Sleep(100 * time.Millisecond) + } + + if !conn.connected { + log.Println("❌ WebSocket-Verbindung Timeout") + return + } + + // WebSocket-Wrapper speichern + g.wsConn = conn + + // JOIN senden + joinMsg := WebSocketMessage{ + Type: "join", + Payload: game.JoinRequest{ + Name: g.playerName, + RoomID: g.roomID, + GameMode: g.gameMode, + IsHost: g.isHost, + TeamName: g.teamName, + }, + } + g.sendWebSocketMessage(joinMsg) + + log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID) +} + +// sendWebSocketMessage sendet eine Nachricht über WebSocket +func (g *Game) sendWebSocketMessage(msg WebSocketMessage) { + if g.wsConn == nil || !g.wsConn.connected { + log.Println("⚠️ WebSocket nicht verbunden") + return + } + + data, err := json.Marshal(msg) + if err != nil { + log.Printf("❌ Fehler beim Marshallen: %v", err) + return + } + + g.wsConn.ws.Call("send", string(data)) +} + +// sendInput sendet einen Input über WebSocket +func (g *Game) sendInput(input game.ClientInput) { + msg := WebSocketMessage{ + Type: "input", + Payload: input, + } + g.sendWebSocketMessage(msg) +} + +// startGame sendet den Start-Befehl über WebSocket +func (g *Game) startGame() { + msg := WebSocketMessage{ + Type: "start", + Payload: game.StartRequest{ + RoomID: g.roomID, + }, + } + g.sendWebSocketMessage(msg) + log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID) +} + +// connectForLeaderboard verbindet für Leaderboard-Abfrage +func (g *Game) connectForLeaderboard() { + if g.wsConn != nil && g.wsConn.connected { + // Bereits verbunden + g.requestLeaderboard() + return + } + + // Neue Verbindung aufbauen + g.connectToServer() + + // Kurz warten und dann Leaderboard anfragen + time.Sleep(500 * time.Millisecond) + g.requestLeaderboard() +} + +// requestLeaderboard fordert das Leaderboard an +func (g *Game) requestLeaderboard() { + mode := "solo" + if g.gameMode == "coop" { + mode = "coop" + } + + msg := WebSocketMessage{ + Type: "leaderboard_request", + Payload: game.LeaderboardRequest{ + Mode: mode, + }, + } + g.sendWebSocketMessage(msg) + log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode) +} + +// submitScore sendet den Score ans Leaderboard +func (g *Game) submitScore() { + if g.scoreSubmitted { + return + } + + g.stateMutex.Lock() + score := 0 + for _, p := range g.gameState.Players { + if p.Name == g.playerName { + score = p.Score + break + } + } + g.stateMutex.Unlock() + + if score == 0 { + log.Println("⚠️ Score ist 0, überspringe Submission") + return + } + + name := g.playerName + if g.gameMode == "coop" && g.teamName != "" { + name = g.teamName + } + + msg := WebSocketMessage{ + Type: "score_submit", + Payload: game.ScoreSubmission{ + PlayerCode: g.playerCode, + Name: name, + Score: score, + Mode: g.gameMode, + }, + } + g.sendWebSocketMessage(msg) + + g.scoreSubmitted = true + log.Printf("📊 Score submitted: %s = %d", name, score) +} + +// Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes +func (g *Game) sendJoinRequest() { + // Wird in connectToServer aufgerufen +} + +func (g *Game) sendStartRequest() { + g.startGame() +} + +func (g *Game) publishInput(input game.ClientInput) { + g.sendInput(input) +} diff --git a/cmd/client/draw_native.go b/cmd/client/draw_native.go new file mode 100644 index 0000000..351f190 --- /dev/null +++ b/cmd/client/draw_native.go @@ -0,0 +1,22 @@ +//go:build !wasm +// +build !wasm + +package main + +import ( + "github.com/hajimehoshi/ebiten/v2" +) + +// In Native: Nutze die normalen Draw-Funktionen + +func (g *Game) drawMenu(screen *ebiten.Image) { + g.DrawMenu(screen) +} + +func (g *Game) drawLobby(screen *ebiten.Image) { + g.DrawLobby(screen) +} + +func (g *Game) drawLeaderboard(screen *ebiten.Image) { + g.DrawLeaderboard(screen) +} diff --git a/cmd/client/draw_wasm.go b/cmd/client/draw_wasm.go new file mode 100644 index 0000000..90e96de --- /dev/null +++ b/cmd/client/draw_wasm.go @@ -0,0 +1,28 @@ +//go:build js && wasm +// +build js,wasm + +package main + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" +) + +// In WASM: Menü und Leaderboard werden in HTML angezeigt +// Lobby wird aber normal gezeichnet (für Co-op Warteraum) + +func (g *Game) drawMenu(screen *ebiten.Image) { + // Schwarzer Hintergrund - HTML-Menü ist darüber + screen.Fill(color.RGBA{0, 0, 0, 255}) +} + +func (g *Game) drawLobby(screen *ebiten.Image) { + // Lobby wird normal gezeichnet (für Co-op Warteraum) + g.DrawLobby(screen) +} + +func (g *Game) drawLeaderboard(screen *ebiten.Image) { + // Schwarzer Hintergrund - HTML-Leaderboard ist darüber + screen.Fill(color.RGBA{0, 0, 0, 255}) +} diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go index 5f4e073..a4cf967 100644 --- a/cmd/client/game_render.go +++ b/cmd/client/game_render.go @@ -19,16 +19,26 @@ import ( // --- INPUT & UPDATE LOGIC --- func (g *Game) UpdateGame() { - // --- 1. KEYBOARD INPUT --- + // --- 1. MUTE TOGGLE --- + if inpututil.IsKeyJustPressed(ebiten.KeyM) { + g.audio.ToggleMute() + } + + // --- 2. 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 --- + // Tastatur-Nutzung erkennen (für Mobile Controls ausblenden) + if keyLeft || keyRight || keyDown || keyJump { + g.keyboardUsed = true + } + + // --- 3. TOUCH INPUT HANDLING --- g.handleTouchInput() - // --- 3. INPUT STATE ERSTELLEN --- + // --- 4. INPUT STATE ERSTELLEN --- joyDir := 0.0 if g.joyActive { diffX := g.joyStickX - g.joyBaseX @@ -64,6 +74,34 @@ func (g *Game) UpdateGame() { // Lokale Physik sofort anwenden (Prediction) g.ApplyInput(input) + + // Sanfte Korrektur anwenden (20% pro Frame) + const smoothingFactor = 0.2 + if g.correctionX != 0 || g.correctionY != 0 { + g.predictedX += g.correctionX * smoothingFactor + g.predictedY += g.correctionY * smoothingFactor + + g.correctionX *= (1.0 - smoothingFactor) + g.correctionY *= (1.0 - smoothingFactor) + + // Korrektur beenden wenn sehr klein + if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 0.01 { + g.correctionX = 0 + g.correctionY = 0 + } + } + + // Landing Detection für Partikel + if !g.lastGroundState && g.predictedGround { + // Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen) + // Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H + // = Y - 231 + 42 + 184 = Y - 5 + feetY := g.predictedY - 231 + 42 + 184 + centerX := g.predictedX - 56 + 68 + 73/2 + g.SpawnLandingParticles(centerX, feetY) + } + g.lastGroundState = g.predictedGround + g.predictionMutex.Unlock() // Input an Server senden @@ -72,10 +110,8 @@ func (g *Game) UpdateGame() { // --- 5. KAMERA LOGIK --- g.stateMutex.Lock() - defer g.stateMutex.Unlock() - - // Wir folgen strikt dem Server-Scroll. targetCam := g.gameState.ScrollX + g.stateMutex.Unlock() // Negative Kamera verhindern if targetCam < 0 { @@ -84,6 +120,12 @@ func (g *Game) UpdateGame() { // Kamera hart setzen g.camX = targetCam + + // --- 6. PARTIKEL UPDATEN --- + g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms + + // --- 7. PARTIKEL SPAWNEN (State Changes Detection) --- + g.DetectAndSpawnParticles() } // Verarbeitet Touch-Eingaben für Joystick und Buttons @@ -178,6 +220,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } g.stateMutex.Unlock() + // In WASM: HTML Game Over Screen anzeigen + if !g.scoreSubmitted { + g.scoreSubmitted = true + g.submitScore() + g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen + } + g.DrawGameOverLeaderboard(screen, myScore) return // Früher Return, damit Game-UI nicht mehr gezeichnet wird } @@ -197,12 +246,41 @@ func (g *Game) DrawGame(screen *ebiten.Image) { } g.stateMutex.Unlock() - // 1. Hintergrund & Boden - screen.Fill(ColSky) + // 1. Hintergrund (wechselt alle 5000 Punkte) + backgroundID := "background" + if myScore >= 10000 { + backgroundID = "background2" + } else if myScore >= 5000 { + backgroundID = "background1" + } - 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) + // Hintergrundbild zeichnen (skaliert auf Bildschirmgröße) + if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil { + op := &ebiten.DrawImageOptions{} + + // Skalierung berechnen, um Bildschirm zu füllen + bgW, bgH := bgImg.Size() + scaleX := float64(ScreenWidth) / float64(bgW) + scaleY := float64(ScreenHeight) / float64(bgH) + scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen + + op.GeoM.Scale(scale, scale) + + // Zentrieren + scaledW := float64(bgW) * scale + scaledH := float64(bgH) * scale + offsetX := (float64(ScreenWidth) - scaledW) / 2 + offsetY := (float64(ScreenHeight) - scaledH) / 2 + op.GeoM.Translate(offsetX, offsetY) + + screen.DrawImage(bgImg, op) + } else { + // Fallback: Einfarbiger Himmel + screen.Fill(ColSky) + } + + // Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera) + g.RenderGround(screen, g.camX) // State Locken für Datenzugriff g.stateMutex.Lock() @@ -218,12 +296,38 @@ func (g *Game) DrawGame(screen *ebiten.Image) { // Start-Chunk hat absichtlich keine Objekte - for _, obj := range chunkDef.Objects { + for objIdx, obj := range chunkDef.Objects { + // Skip Moving Platforms - die werden separat gerendert + if obj.MovingPlatform != nil { + continue + } + + // Prüfe ob Coin/Powerup bereits eingesammelt wurde + assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID] + if hasAsset { + key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx) + + if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] { + // Coin wurde eingesammelt, nicht zeichnen + continue + } + + if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] { + // Powerup wurde eingesammelt, nicht zeichnen + continue + } + } + // Asset zeichnen g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y) } } + // 2.5 Bewegende Plattformen (von Server synchronisiert) + for _, mp := range g.gameState.MovingPlatforms { + g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y) + } + // 3. Spieler // MyID ohne Lock holen (wir haben bereits den stateMutex) myID := "" @@ -237,12 +341,33 @@ func (g *Game) DrawGame(screen *ebiten.Image) { for id, p := range g.gameState.Players { // Für lokalen Spieler: Verwende vorhergesagte Position posX, posY := p.X, p.Y + vy := p.VY + onGround := p.OnGround if id == myID && g.connected { posX = g.predictedX posY = g.predictedY + vy = g.predictedVY + onGround = g.predictedGround } - g.DrawAsset(screen, "player", posX, posY) + // Wähle Sprite basierend auf Sprung-Status + sprite := "player" // Default: am Boden + + // Nur Jump-Animation wenn wirklich in der Luft + // (nicht auf Boden, nicht auf Platform mit VY ~= 0) + isInAir := !onGround && (vy < -1.0 || vy > 1.0) + + if isInAir { + if vy < -2.0 { + // Springt nach oben + sprite = "jump0" + } else { + // Fällt oder höchster Punkt + sprite = "jump1" + } + } + + g.DrawAsset(screen, sprite, posX, posY) // Name Tag name := p.Name @@ -284,28 +409,32 @@ func (g *Game) DrawGame(screen *ebiten.Image) { 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 + // 6. PARTIKEL RENDERN (vor UI) + g.RenderParticles(screen) - // 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) + // 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde) + if !g.keyboardUsed { + // A) Joystick Base (dunkelgrau und durchsichtig) + baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig + vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false) + vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false) - // B) Joystick Knob - knobCol := color.RGBA{255, 255, 255, 150} - if g.joyActive { - knobCol = color.RGBA{100, 255, 100, 200} + // B) Joystick Knob (dunkelgrau, außer wenn aktiv) + knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig + if g.joyActive { + knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig + } + vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false) + + // C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung) + jumpX := float32(ScreenWidth - 150) + jumpY := float32(ScreenHeight - 150) + vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false) + vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false) + text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150}) } - 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) + // 8. DEBUG INFO (Oben Links) myPosStr := "N/A" for _, p := range g.gameState.Players { myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y) diff --git a/cmd/client/gameover_native.go b/cmd/client/gameover_native.go new file mode 100644 index 0000000..23ab410 --- /dev/null +++ b/cmd/client/gameover_native.go @@ -0,0 +1,9 @@ +//go:build !wasm +// +build !wasm + +package main + +// sendGameOverToJS ist ein Stub für Native (kein HTML) +func (g *Game) sendGameOverToJS(score int) { + // Native hat kein HTML-Overlay, nichts zu tun +} diff --git a/cmd/client/ground_system.go b/cmd/client/ground_system.go new file mode 100644 index 0000000..c36ffa7 --- /dev/null +++ b/cmd/client/ground_system.go @@ -0,0 +1,160 @@ +package main + +import ( + "image/color" + "math" + "math/rand" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// GroundTile repräsentiert ein Boden-Segment +type GroundTile struct { + X float64 + DirtVariants []DirtPatch + Stones []Stone +} + +// DirtPatch ist ein Dirt-Fleck im Boden +type DirtPatch struct { + OffsetX float64 + OffsetY float64 + Width float64 + Height float64 + Color color.RGBA +} + +// Stone ist ein Stein auf dem Boden +type Stone struct { + X float64 + Y float64 + Size float64 + Color color.RGBA + Shape int // 0=rund, 1=eckig +} + +// GroundCache speichert generierte Tiles +var groundCache = make(map[int]GroundTile) + +// ClearGroundCache leert den Cache (z.B. bei Änderungen) +func ClearGroundCache() { + groundCache = make(map[int]GroundTile) +} + +// GenerateGroundTile generiert ein prozedurales Boden-Segment (gecacht) +func GenerateGroundTile(tileIdx int) GroundTile { + // Prüfe Cache + if cached, exists := groundCache[tileIdx]; exists { + return cached + } + + // Deterministischer Seed basierend auf Tile-Index + rng := rand.New(rand.NewSource(int64(tileIdx * 12345))) + + tile := GroundTile{ + X: float64(tileIdx) * 128.0, + DirtVariants: make([]DirtPatch, 0), + Stones: make([]Stone, 0), + } + + // Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe) + numDirt := 15 + rng.Intn(10) + dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe + for i := 0; i < numDirt; i++ { + darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne + tile.DirtVariants = append(tile.DirtVariants, DirtPatch{ + OffsetX: rng.Float64() * 128, + OffsetY: rng.Float64()*dirtHeight + 20, // Über die ganze Dirt-Schicht verteilt + Width: 10 + rng.Float64()*30, + Height: 10 + rng.Float64()*25, + Color: color.RGBA{darkness, darkness - 10, darkness - 20, 255}, + }) + } + + // Keine Steine mehr auf dem Gras + + // In Cache speichern + groundCache[tileIdx] = tile + return tile +} + +// RenderGround rendert den Boden mit Bewegung +func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) { + floorY := float32(RefFloorY) + floorH := float32(ScreenHeight - RefFloorY) + + // 1. Basis Gras-Schicht + vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false) + + // 2. Dirt-Schicht (Basis) + vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false) + + // 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera) + // Berechne welche Tiles sichtbar sind + tileWidth := 128.0 + startTile := int(math.Floor(cameraX / tileWidth)) + endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth)) + + // Tiles rendern + for tileIdx := startTile; tileIdx <= endTile; tileIdx++ { + tile := GenerateGroundTile(tileIdx) + + // Dirt-Patches rendern + for _, dirt := range tile.DirtVariants { + worldX := tile.X + dirt.OffsetX + screenX := float32(worldX - cameraX) + screenY := float32(RefFloorY) + float32(dirt.OffsetY) + + // Nur rendern wenn im sichtbaren Bereich + if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) { + vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false) + } + } + + // Steine rendern (auf dem Gras) + for _, stone := range tile.Stones { + worldX := tile.X + stone.X + screenX := float32(worldX - cameraX) + screenY := float32(RefFloorY) + float32(stone.Y) + + // Nur rendern wenn im sichtbaren Bereich + if screenX > -20 && screenX < float32(ScreenWidth)+20 { + if stone.Shape == 0 { + // Runder Stein + vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false) + // Highlight für 3D-Effekt + highlightCol := color.RGBA{ + uint8(math.Min(float64(stone.Color.R)+40, 255)), + uint8(math.Min(float64(stone.Color.G)+40, 255)), + uint8(math.Min(float64(stone.Color.B)+40, 255)), + 200, + } + vector.DrawFilledCircle(screen, screenX-float32(stone.Size*0.15), screenY-float32(stone.Size*0.15), float32(stone.Size/4), highlightCol, false) + } else { + // Eckiger Stein + vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false) + // Schatten für 3D-Effekt + shadowCol := color.RGBA{ + uint8(float64(stone.Color.R) * 0.6), + uint8(float64(stone.Color.G) * 0.6), + uint8(float64(stone.Color.B) * 0.6), + 150, + } + vector.DrawFilledRect(screen, screenX-float32(stone.Size/2)+2, screenY-float32(stone.Size/2)+2, float32(stone.Size), float32(stone.Size), shadowCol, false) + // Original drüber + vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false) + } + } + } + } + + // Cache aufräumen (nur Tiles außerhalb des Sichtbereichs entfernen) + if len(groundCache) > 100 { + for idx := range groundCache { + if idx < startTile-10 || idx > endTile+10 { + delete(groundCache, idx) + } + } + } +} diff --git a/cmd/client/main.go b/cmd/client/main.go index 486c217..3d08f63 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -1,22 +1,18 @@ package main import ( - "encoding/json" "fmt" "image/color" - _ "image/png" - "io/ioutil" + _ "image/jpeg" // JPEG-Decoder + _ "image/png" // PNG-Decoder "log" mrand "math/rand" - "path/filepath" - "runtime" "sort" "strings" "sync" "time" "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" @@ -28,8 +24,8 @@ import ( // --- KONFIGURATION --- const ( - ScreenWidth = 1280 - ScreenHeight = 720 + ScreenWidth = 1280 + ScreenHeight = 720 StateMenu = 0 StateLobby = 1 StateGame = 2 @@ -59,6 +55,7 @@ type InputState struct { type Game struct { appState int conn *nats.EncodedConn + wsConn *wsConn // WebSocket für WASM gameState game.GameState stateMutex sync.Mutex connected bool @@ -95,6 +92,21 @@ type Game struct { lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz predictionMutex sync.Mutex // Mutex für pendingInputs + // Smooth Correction + correctionX float64 // Verbleibende Korrektur in X + correctionY float64 // Verbleibende Korrektur in Y + + // Particle System + particles []Particle + particlesMutex sync.Mutex + lastGroundState bool // Für Landing-Detection + lastCollectedCoins map[string]bool // Für Coin-Partikel + lastCollectedPowerups map[string]bool // Für Powerup-Partikel + lastPlayerStates map[string]game.PlayerState // Für Death-Partikel + + // Audio System + audio *AudioSystem + // Kamera camX float64 @@ -104,6 +116,7 @@ type Game struct { joyActive bool joyTouchID ebiten.TouchID btnJumpActive bool + keyboardUsed bool // Wurde Tastatur benutzt? } func NewGame() *Game { @@ -114,67 +127,35 @@ func NewGame() *Game { gameState: game.GameState{Players: make(map[string]game.PlayerState)}, playerName: "Student", - activeField: "name", + activeField: "", gameMode: "", pendingInputs: make(map[uint32]InputState), leaderboard: make([]game.LeaderboardEntry, 0), + // Particle tracking + lastCollectedCoins: make(map[string]bool), + lastCollectedPowerups: make(map[string]bool), + lastPlayerStates: make(map[string]game.PlayerState), + + // Audio System + audio: NewAudioSystem(), + joyBaseX: 150, joyBaseY: ScreenHeight - 150, joyStickX: 150, joyStickY: ScreenHeight - 150, } g.loadAssets() g.loadOrCreatePlayerCode() + + // Gespeicherten Namen laden + savedName := g.loadPlayerName() + if savedName != "" { + g.playerName = savedName + } + return g } -func (g *Game) loadAssets() { - // Pfad anpassen: Wir suchen im relativen Pfad - baseDir := "./cmd/client/assets" - manifestPath := filepath.Join(baseDir, "assets.json") - - data, err := ioutil.ReadFile(manifestPath) - if err == nil { - var m game.AssetManifest - json.Unmarshal(data, &m) - g.world.Manifest = m - fmt.Println("✅ Assets Manifest geladen:", len(m.Assets), "Einträge") - } else { - log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath) - // Fallback: Leeres Manifest, damit das Spiel nicht abstürzt - g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)} - } - - // Chunks laden - chunkDir := filepath.Join(baseDir, "chunks") - err = g.world.LoadChunkLibrary(chunkDir) - if err != nil { - log.Println("⚠️ Chunks konnten nicht geladen werden:", err) - } else { - fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge") - // DEBUG: Details der geladenen Chunks - for id, chunk := range g.world.ChunkLibrary { - fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects)) - } - } - - // Bilder vorladen - loadedImages := 0 - failedImages := 0 - for id, def := range g.world.Manifest.Assets { - if def.Filename != "" { - path := filepath.Join(baseDir, def.Filename) - img, _, err := ebitenutil.NewImageFromFile(path) - if err == nil { - g.assetsImages[id] = img - loadedImages++ - } else { - log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err) - failedImages++ - } - } - } - fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages) -} +// loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert // --- UPDATE --- func (g *Game) Update() error { @@ -220,6 +201,17 @@ func (g *Game) Update() error { } } + // Musik-Start-Check (unabhängig vom State) + if g.gameState.Status == "RUNNING" && g.lastStatus != "RUNNING" { + log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, g.gameState.Status) + g.audio.PlayMusic() + } + // Musik stoppen wenn Game Over + if g.gameState.Status == "GAMEOVER" && g.lastStatus == "RUNNING" { + g.audio.StopMusic() + } + g.lastStatus = g.gameState.Status + switch g.appState { case StateMenu: g.updateMenu() @@ -236,6 +228,30 @@ func (g *Game) Update() error { func (g *Game) updateMenu() { g.handleMenuInput() + // Volume Sliders (unten links) + volumeX := 20 + volumeY := ScreenHeight - 100 + sliderWidth := 200 + sliderHeight := 10 + + // Music Volume Slider + musicSliderY := volumeY + 10 + if isSliderHit(volumeX, musicSliderY, sliderWidth, sliderHeight) { + newVolume := getSliderValue(volumeX, sliderWidth) + g.audio.SetMusicVolume(newVolume) + return + } + + // SFX Volume Slider + sfxSliderY := volumeY + 50 + if isSliderHit(volumeX, sfxSliderY, sliderWidth, sliderHeight) { + newVolume := getSliderValue(volumeX, sliderWidth) + g.audio.SetSFXVolume(newVolume) + // Test-Sound abspielen + g.audio.PlayCoin() + return + } + // Leaderboard Button lbBtnW, lbBtnH := 200, 50 lbBtnX := ScreenWidth - lbBtnW - 20 @@ -324,7 +340,7 @@ func (g *Game) updateLobby() { if isHit(btnX, btnY, btnW, btnH) { // START GAME - g.SendCommand("START") + g.sendStartRequest() } } @@ -347,18 +363,27 @@ func (g *Game) updateLobby() { // --- DRAW --- func (g *Game) Draw(screen *ebiten.Image) { + // In WASM: Nur das Spiel zeichnen, kein Menü/Lobby (HTML übernimmt das) + // In Native: Alles zeichnen + g.draw(screen) +} + +// draw ist die plattform-übergreifende Zeichenfunktion +func (g *Game) draw(screen *ebiten.Image) { switch g.appState { case StateMenu: - g.DrawMenu(screen) + g.drawMenu(screen) case StateLobby: - g.DrawLobby(screen) + g.drawLobby(screen) case StateGame: g.DrawGame(screen) case StateLeaderboard: - g.DrawLeaderboard(screen) + g.drawLeaderboard(screen) } } +// drawMenu, drawLobby, drawLeaderboard sind in draw_wasm.go und draw_native.go definiert + func (g *Game) DrawMenu(screen *ebiten.Image) { screen.Fill(color.RGBA{20, 20, 30, 255}) @@ -442,7 +467,19 @@ func (g *Game) DrawMenu(screen *ebiten.Image) { vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false) text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255}) - text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150}) + // Volume Controls (unten links) + volumeX := 20 + volumeY := ScreenHeight - 100 + + // Music Volume + text.Draw(screen, "Music Volume:", basicfont.Face7x13, volumeX, volumeY, ColText) + g.drawVolumeSlider(screen, volumeX, volumeY+10, 200, g.audio.GetMusicVolume()) + + // SFX Volume + text.Draw(screen, "SFX Volume:", basicfont.Face7x13, volumeX, volumeY+40, ColText) + g.drawVolumeSlider(screen, volumeX, volumeY+50, 200, g.audio.GetSFXVolume()) + + text.Draw(screen, "WASD / Arrows - SPACE to Jump - M to Mute", basicfont.Face7x13, ScreenWidth/2-130, ScreenHeight-15, color.Gray{150}) } func (g *Game) DrawLobby(screen *ebiten.Image) { @@ -580,6 +617,10 @@ func (g *Game) handleMenuInput() { } if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { + // Namen speichern wenn geändert + if g.activeField == "name" && g.playerName != "" { + g.savePlayerName(g.playerName) + } g.activeField = "" } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { if len(*target) > 0 { @@ -614,7 +655,7 @@ func (g *Game) handleGameOverInput() { if isHit(submitBtnX, submitBtnY, submitBtnW, 40) { if g.teamName != "" { - g.submitTeamScore() + g.submitScore() // submitScore behandelt jetzt beide Modi } return } @@ -623,7 +664,7 @@ func (g *Game) handleGameOverInput() { if g.activeField == "teamname" { if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { if g.teamName != "" { - g.submitTeamScore() + g.submitScore() // submitScore behandelt jetzt beide Modi } g.activeField = "" } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { @@ -650,68 +691,6 @@ func generateRoomCode() string { } func (g *Game) connectAndStart() { - // URL: Wasm -> WS, Desktop -> TCP - serverURL := "nats://localhost:4222" - if runtime.GOARCH == "wasm" || runtime.GOOS == "js" { - serverURL = "ws://localhost:9222" - } - - nc, err := nats.Connect(serverURL) - if err != nil { - log.Println("❌ NATS Connect Fehler:", err) - return - } - ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER) - g.conn = ec - - // Subscribe nur auf Updates für DIESEN Raum - roomChannel := fmt.Sprintf("game.update.%s", g.roomID) - log.Printf("👂 Lausche auf Channel: %s", roomChannel) - - sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) { - // Server Reconciliation für lokalen Spieler (VOR dem Lock) - for _, p := range state.Players { - if p.Name == g.playerName { - // Reconcile mit Server-State (verwendet keinen stateMutex) - g.ReconcileWithServer(p) - break - } - } - - g.stateMutex.Lock() - oldPlayerCount := len(g.gameState.Players) - oldStatus := g.gameState.Status - g.gameState = *state - g.stateMutex.Unlock() - - // Nur bei Änderungen loggen - if len(state.Players) != oldPlayerCount || state.Status != oldStatus { - log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, state.Status) - } - }) - - if err != nil { - log.Println("❌ Fehler beim Subscribe:", err) - return - } - log.Printf("👂 Subscription aktiv (Valid: %v)", sub.IsValid()) - - // Kurze Pause, damit Subscription aktiv ist - time.Sleep(100 * time.Millisecond) - - // JOIN MIT ROOM ID SENDEN - joinReq := game.JoinRequest{ - Name: g.playerName, - RoomID: g.roomID, - } - log.Printf("📤 Sende JOIN Request: Name=%s, RoomID=%s", g.playerName, g.roomID) - err = g.conn.Publish("game.join", joinReq) - if err != nil { - log.Println("❌ Fehler beim Publish:", err) - return - } - g.connected = true - // Initiale predicted Position g.predictedX = 100 g.predictedY = 200 @@ -719,7 +698,8 @@ func (g *Game) connectAndStart() { g.predictedVY = 0 g.predictedGround = false - log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...") + // Verbindung über plattformspezifische Implementierung + g.connectToServer() } func (g *Game) SendCommand(cmdType string) { @@ -727,7 +707,7 @@ func (g *Game) SendCommand(cmdType string) { return } myID := g.getMyPlayerID() - g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType}) + g.publishInput(game.ClientInput{PlayerID: myID, Type: cmdType}) } func (g *Game) SendInputWithSequence(input InputState) { @@ -739,28 +719,30 @@ func (g *Game) SendInputWithSequence(input InputState) { // Inputs als einzelne Commands senden if input.Left { - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "LEFT_DOWN", Sequence: input.Sequence, }) } if input.Right { - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "RIGHT_DOWN", Sequence: input.Sequence, }) } if input.Jump { - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "JUMP", Sequence: input.Sequence, }) + // Jump Sound abspielen + g.audio.PlayJump() } if input.Down { - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "DOWN", Sequence: input.Sequence, @@ -769,12 +751,12 @@ func (g *Game) SendInputWithSequence(input InputState) { // Wenn weder Links noch Rechts, sende STOP if !input.Left && !input.Right { - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "LEFT_UP", Sequence: input.Sequence, }) - g.conn.Publish("game.input", game.ClientInput{ + g.publishInput(game.ClientInput{ PlayerID: myID, Type: "RIGHT_UP", Sequence: input.Sequence, @@ -794,113 +776,8 @@ func (g *Game) getMyPlayerID() string { return g.playerName } -// loadOrCreatePlayerCode wird in storage_*.go implementiert (platform-specific) - -// submitScore sendet den individuellen Score an den Server (für Solo-Mode) -func (g *Game) submitScore() { - if g.scoreSubmitted || !g.connected { - return - } - - // Finde eigenen Score - myScore := 0 - for _, p := range g.gameState.Players { - if p.Name == g.playerName { - myScore = p.Score - break - } - } - - submission := game.ScoreSubmission{ - PlayerName: g.playerName, - PlayerCode: g.playerCode, - Score: myScore, - } - - g.conn.Publish("score.submit", submission) - g.scoreSubmitted = true - log.Printf("📊 Score eingereicht: %d Punkte", myScore) - - // Leaderboard abrufen - g.requestLeaderboard() -} - -// submitTeamScore sendet den Team-Score an den Server (für Coop-Mode) -func (g *Game) submitTeamScore() { - if g.scoreSubmitted || !g.connected || g.teamName == "" { - return - } - - // Berechne Team-Score (Summe aller Spieler-Scores) - teamScore := 0 - for _, p := range g.gameState.Players { - teamScore += p.Score - } - - submission := game.ScoreSubmission{ - PlayerName: g.teamName, // Team-Name statt Spieler-Name - PlayerCode: g.playerCode, - Score: teamScore, - } - - g.conn.Publish("score.submit", submission) - g.scoreSubmitted = true - g.activeField = "" - log.Printf("📊 Team-Score eingereicht: %s - %d Punkte", g.teamName, teamScore) - - // Leaderboard abrufen - g.requestLeaderboard() -} - -// requestLeaderboard fordert das Leaderboard vom Server an (asynchron) -func (g *Game) requestLeaderboard() { - if !g.connected { - return - } - - go func() { - inbox := g.conn.Conn.NewRespInbox() - sub, err := g.conn.Subscribe(inbox, func(entries *[]game.LeaderboardEntry) { - g.leaderboardMutex.Lock() - g.leaderboard = *entries - g.leaderboardMutex.Unlock() - log.Printf("📊 Leaderboard empfangen: %d Einträge", len(*entries)) - }) - - if err != nil { - log.Printf("⚠️ Fehler beim Leaderboard-Request: %v", err) - return - } - - // Request senden - g.conn.PublishRequest("leaderboard.get", inbox, &struct{}{}) - - // Warte kurz auf Antwort, dann unsubscribe - time.Sleep(100 * time.Millisecond) - sub.Unsubscribe() - }() -} - -func (g *Game) connectForLeaderboard() { - serverURL := "nats://localhost:4222" - nc, err := nats.Connect(serverURL) - if err != nil { - log.Printf("❌ NATS Verbindung fehlgeschlagen: %v", err) - return - } - - g.conn, err = nats.NewEncodedConn(nc, nats.JSON_ENCODER) - if err != nil { - log.Printf("❌ EncodedConn Fehler: %v", err) - return - } - - g.connected = true - log.Println("✅ Verbunden für Leaderboard") - - // Leaderboard abrufen - g.requestLeaderboard() -} +// submitScore, requestLeaderboard, connectForLeaderboard +// sind in connection_native.go und connection_wasm.go definiert func (g *Game) updateLeaderboard() { // Back Button (oben links) - Touch Support @@ -977,12 +854,46 @@ func (g *Game) DrawLeaderboard(screen *ebiten.Image) { text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150}) } -func main() { - ebiten.SetWindowSize(ScreenWidth, ScreenHeight) - ebiten.SetWindowTitle("Escape From Teacher") - ebiten.SetTPS(60) // Tick Per Second auf 60 setzen - ebiten.SetVsyncEnabled(true) // VSync aktivieren - if err := ebiten.RunGame(NewGame()); err != nil { - log.Fatal(err) - } +// main() ist jetzt in main_wasm.go und main_native.go definiert + +// drawVolumeSlider zeichnet einen Volume-Slider +func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) { + // Hintergrund + vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false) + vector.StrokeRect(screen, float32(x), float32(y), float32(width), 10, 1, color.White, false) + + // Füllstand + fillWidth := int(float64(width) * volume) + vector.DrawFilledRect(screen, float32(x), float32(y), float32(fillWidth), 10, color.RGBA{0, 200, 100, 255}, false) + + // Prozent-Anzeige + pct := fmt.Sprintf("%.0f%%", volume*100) + text.Draw(screen, pct, basicfont.Face7x13, x+width+10, y+10, ColText) +} + +// isSliderHit prüft, ob auf einen Slider geklickt wurde +func isSliderHit(x, y, width, height int) bool { + // Erweitere den Klickbereich vertikal für bessere Touch-Support + return isHit(x, y-10, width, height+20) +} + +// getSliderValue berechnet den Slider-Wert basierend auf Mausposition +func getSliderValue(sliderX, sliderWidth int) float64 { + mx, _ := ebiten.CursorPosition() + // Bei Touch: Ersten Touch nutzen + touches := ebiten.TouchIDs() + if len(touches) > 0 { + mx, _ = ebiten.TouchPosition(touches[0]) + } + + // Berechne relative Position im Slider + relX := float64(mx - sliderX) + if relX < 0 { + relX = 0 + } + if relX > float64(sliderWidth) { + relX = float64(sliderWidth) + } + + return relX / float64(sliderWidth) } diff --git a/cmd/client/main_native.go b/cmd/client/main_native.go new file mode 100644 index 0000000..8c8a079 --- /dev/null +++ b/cmd/client/main_native.go @@ -0,0 +1,20 @@ +//go:build !wasm +// +build !wasm + +package main + +import ( + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + ebiten.SetWindowTitle("Escape From Teacher") + ebiten.SetTPS(60) + ebiten.SetVsyncEnabled(true) + if err := ebiten.RunGame(NewGame()); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/client/main_wasm.go b/cmd/client/main_wasm.go new file mode 100644 index 0000000..c8c4cb1 --- /dev/null +++ b/cmd/client/main_wasm.go @@ -0,0 +1,33 @@ +//go:build js && wasm +// +build js,wasm + +package main + +import ( + "log" + + "github.com/hajimehoshi/ebiten/v2" +) + +func main() { + log.Println("🚀 WASM Version startet...") + + // Spiel initialisieren + game := NewGame() + + // JavaScript Bridge registrieren (für HTML-Menü Kommunikation) + game.setupJavaScriptBridge() + + // Spiel ohne eigenes Menü starten - HTML übernimmt das Menü + // Das Spiel wartet im Hintergrund bis startGame() von JavaScript aufgerufen wird + log.Println("⏳ Warte auf Start-Signal vom HTML-Menü...") + + ebiten.SetWindowSize(ScreenWidth, ScreenHeight) + ebiten.SetWindowTitle("Escape From Teacher") + ebiten.SetTPS(60) + ebiten.SetVsyncEnabled(true) + + if err := ebiten.RunGame(game); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/client/particles.go b/cmd/client/particles.go new file mode 100644 index 0000000..1833936 --- /dev/null +++ b/cmd/client/particles.go @@ -0,0 +1,336 @@ +package main + +import ( + "fmt" + "image/color" + "math" + "math/rand" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// Particle definiert ein einzelnes Partikel +type Particle struct { + X, Y float64 + VX, VY float64 + Life float64 // 0.0 bis 1.0 (1.0 = neu, 0.0 = tot) + MaxLife float64 // Ursprüngliche Lebensdauer in Sekunden + Size float64 + Color color.RGBA + Type string // "coin", "powerup", "landing", "death" + Gravity bool // Soll Gravitation angewendet werden? + FadeOut bool // Soll ausblenden? +} + +// SpawnCoinParticles erstellt Partikel für Coin-Einsammlung +func (g *Game) SpawnCoinParticles(x, y float64) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + // 10-15 goldene Partikel - spawnen in alle Richtungen + count := 10 + rand.Intn(6) + for i := 0; i < count; i++ { + angle := rand.Float64() * 2 * math.Pi + speed := 2.0 + rand.Float64()*3.0 + + g.particles = append(g.particles, Particle{ + X: x, + Y: y, + VX: math.Cos(angle) * speed, + VY: math.Sin(angle) * speed, + Life: 1.0, + MaxLife: 0.5 + rand.Float64()*0.3, + Size: 3.0 + rand.Float64()*2.0, + Color: color.RGBA{255, 215, 0, 255}, // Gold + Type: "coin", + Gravity: true, + FadeOut: true, + }) + } +} + +// SpawnPowerupAura erstellt Partikel-Aura um Spieler mit aktivem Powerup +func (g *Game) SpawnPowerupAura(x, y float64, powerupType string) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + // Farbe je nach Powerup-Typ + var particleColor color.RGBA + switch powerupType { + case "doublejump": + particleColor = color.RGBA{100, 200, 255, 200} // Hellblau + case "godmode": + particleColor = color.RGBA{255, 215, 0, 200} // Gold + default: + particleColor = color.RGBA{200, 100, 255, 200} // Lila + } + + // Nur gelegentlich spawnen (nicht jedes Frame) - 1 Partikel alle 3-4 Frames + if rand.Intn(3) != 0 { + return + } + + // 1 Partikel für sanfte Aura - spawnen in alle Richtungen + angle := rand.Float64() * 2 * math.Pi + distance := 25.0 + rand.Float64()*20.0 + + g.particles = append(g.particles, Particle{ + X: x + math.Cos(angle)*distance, + Y: y + math.Sin(angle)*distance, + VX: math.Cos(angle) * 0.5, + VY: math.Sin(angle) * 0.5, + Life: 1.0, + MaxLife: 1.0 + rand.Float64()*0.5, + Size: 2.5 + rand.Float64()*1.5, + Color: particleColor, + Type: "powerup", + Gravity: false, + FadeOut: true, + }) +} + +// SpawnLandingParticles erstellt Staub-Partikel beim Landen +func (g *Game) SpawnLandingParticles(x, y float64) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + // 5-8 Staub-Partikel + count := 5 + rand.Intn(4) + for i := 0; i < count; i++ { + angle := math.Pi + (rand.Float64()-0.5)*0.8 // Nach unten/seitlich + speed := 1.0 + rand.Float64()*2.0 + + g.particles = append(g.particles, Particle{ + X: x + (rand.Float64()-0.5)*30.0, + Y: y + 10, + VX: math.Cos(angle) * speed, + VY: math.Sin(angle) * speed, + Life: 1.0, + MaxLife: 0.3 + rand.Float64()*0.2, + Size: 2.0 + rand.Float64()*2.0, + Color: color.RGBA{150, 150, 150, 180}, // Grau + Type: "landing", + Gravity: false, + FadeOut: true, + }) + } +} + +// SpawnDeathParticles erstellt Explosions-Partikel beim Tod +func (g *Game) SpawnDeathParticles(x, y float64) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + // 20-30 rote Partikel + count := 20 + rand.Intn(11) + for i := 0; i < count; i++ { + angle := rand.Float64() * 2 * math.Pi + speed := 3.0 + rand.Float64()*5.0 + + g.particles = append(g.particles, Particle{ + X: x, + Y: y + 10, + VX: math.Cos(angle) * speed, + VY: math.Sin(angle) * speed, + Life: 1.0, + MaxLife: 0.8 + rand.Float64()*0.4, + Size: 3.0 + rand.Float64()*3.0, + Color: color.RGBA{255, 50, 50, 255}, // Rot + Type: "death", + Gravity: true, + FadeOut: true, + }) + } +} + +// UpdateParticles aktualisiert alle Partikel +func (g *Game) UpdateParticles(dt float64) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + // Filtern: Nur lebende Partikel behalten + alive := make([]Particle, 0, len(g.particles)) + + for i := range g.particles { + p := &g.particles[i] + + // Position updaten + p.X += p.VX + p.Y += p.VY + + // Gravitation + if p.Gravity { + p.VY += 0.3 // Gravitation + } + + // Friction + p.VX *= 0.98 + p.VY *= 0.98 + + // Leben verringern + p.Life -= dt / p.MaxLife + + // Nur behalten wenn noch am Leben + if p.Life > 0 { + alive = append(alive, *p) + } + } + + g.particles = alive +} + +// RenderParticles zeichnet alle Partikel +func (g *Game) RenderParticles(screen *ebiten.Image) { + g.particlesMutex.Lock() + defer g.particlesMutex.Unlock() + + for i := range g.particles { + p := &g.particles[i] + + // Alpha-Wert basierend auf Leben + alpha := uint8(255) + if p.FadeOut { + alpha = uint8(float64(p.Color.A) * p.Life) + } + + col := color.RGBA{p.Color.R, p.Color.G, p.Color.B, alpha} + + // Position relativ zur Kamera + screenX := float32(p.X - g.camX) + screenY := float32(p.Y) + + // Partikel als Kreis zeichnen + vector.DrawFilledCircle(screen, screenX, screenY, float32(p.Size), col, false) + } +} + +// DetectAndSpawnParticles prüft Game-State-Änderungen und spawnt Partikel +func (g *Game) DetectAndSpawnParticles() { + // Kopiere relevante Daten unter kurzen Lock + g.stateMutex.Lock() + + // Kopiere Coins + currentCoins := make(map[string]bool, len(g.gameState.CollectedCoins)) + for k, v := range g.gameState.CollectedCoins { + currentCoins[k] = v + } + + // Kopiere Powerups + currentPowerups := make(map[string]bool, len(g.gameState.CollectedPowerups)) + for k, v := range g.gameState.CollectedPowerups { + currentPowerups[k] = v + } + + // Kopiere Spieler + currentPlayers := make(map[string]game.PlayerState, len(g.gameState.Players)) + for k, v := range g.gameState.Players { + currentPlayers[k] = v + } + + // Kopiere WorldChunks für Position-Lookup + worldChunks := make([]game.ActiveChunk, len(g.gameState.WorldChunks)) + copy(worldChunks, g.gameState.WorldChunks) + + g.stateMutex.Unlock() + + // Ab hier ohne Lock arbeiten + + // Alte Coins entfernen, die nicht mehr in currentCoins sind (Chunk wurde entfernt) + for key := range g.lastCollectedCoins { + if !currentCoins[key] { + delete(g.lastCollectedCoins, key) + } + } + + // 1. Prüfe neue gesammelte Coins + for coinKey := range currentCoins { + if !g.lastCollectedCoins[coinKey] { + // Neuer Coin gesammelt! + if pos := g.findObjectPosition(coinKey, worldChunks, "coin"); pos != nil { + g.SpawnCoinParticles(pos.X, pos.Y) + g.audio.PlayCoin() // Coin Sound abspielen + } + g.lastCollectedCoins[coinKey] = true + } + } + + // Alte Powerups entfernen + for key := range g.lastCollectedPowerups { + if !currentPowerups[key] { + delete(g.lastCollectedPowerups, key) + } + } + + // 2. Prüfe neue gesammelte Powerups + for powerupKey := range currentPowerups { + if !g.lastCollectedPowerups[powerupKey] { + // Neues Powerup gesammelt! + if pos := g.findObjectPosition(powerupKey, worldChunks, "powerup"); pos != nil { + g.SpawnCoinParticles(pos.X, pos.Y) + g.audio.PlayPowerUp() // PowerUp Sound abspielen + } + g.lastCollectedPowerups[powerupKey] = true + } + } + + // 3. Prüfe Spieler-Status und spawn Aura/Death Partikel + for playerID, player := range currentPlayers { + lastState, existed := g.lastPlayerStates[playerID] + + // Death Partikel + if existed && lastState.IsAlive && !player.IsAlive { + // Berechne Spieler-Mitte + centerX := player.X - 56 + 68 + 73/2 + centerY := player.Y - 231 + 42 + 184/2 + g.SpawnDeathParticles(centerX, centerY) + } + + // Powerup Aura (kontinuierlich) + if player.IsAlive && !player.IsSpectator { + // Berechne Spieler-Mitte mit Draw-Offsets + // DrawOffX: -56, DrawOffY: -231 + // Hitbox: OffsetX: 68, OffsetY: 42, W: 73, H: 184 + centerX := player.X - 56 + 68 + 73/2 + centerY := player.Y - 231 + 42 + 184/2 + + if player.HasDoubleJump { + g.SpawnPowerupAura(centerX, centerY, "doublejump") + } + if player.HasGodMode { + g.SpawnPowerupAura(centerX, centerY, "godmode") + } + } + + // State aktualisieren + g.lastPlayerStates[playerID] = player + } +} + +// findObjectPosition finiert die Welt-Position eines Objects (Coin/Powerup) basierend auf Key +func (g *Game) findObjectPosition(objectKey string, worldChunks []game.ActiveChunk, objectType string) *struct{ X, Y float64 } { + for _, activeChunk := range worldChunks { + chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID] + if !exists { + continue + } + + for objIdx, obj := range chunkDef.Objects { + key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx) + if key == objectKey { + assetDef, ok := g.world.Manifest.Assets[obj.AssetID] + if ok && assetDef.Type == objectType { + // Berechne die Mitte der Hitbox + centerX := activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX + assetDef.Hitbox.W/2 + centerY := obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY + assetDef.Hitbox.H/2 + return &struct{ X, Y float64 }{ + X: centerX, + Y: centerY, + } + } + } + } + } + return nil +} diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go index 2d17231..64c6624 100644 --- a/cmd/client/prediction.go +++ b/cmd/client/prediction.go @@ -63,20 +63,60 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) { } } - // Setze vorhergesagte Position auf Server-Position - g.predictedX = serverState.X - g.predictedY = serverState.Y - g.predictedVX = serverState.VX - g.predictedVY = serverState.VY - g.predictedGround = serverState.OnGround + // Temporäre Position für Replay + replayX := serverState.X + replayY := serverState.Y + replayVX := serverState.VX + replayVY := serverState.VY + replayGround := serverState.OnGround // Replay alle noch nicht bestätigten Inputs - // (Sortiert nach Sequenz) if len(g.pendingInputs) > 0 { for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ { if input, ok := g.pendingInputs[seq]; ok { + // Temporär auf Replay-Position setzen + oldX, oldY := g.predictedX, g.predictedY + oldVX, oldVY := g.predictedVX, g.predictedVY + oldGround := g.predictedGround + + g.predictedX = replayX + g.predictedY = replayY + g.predictedVX = replayVX + g.predictedVY = replayVY + g.predictedGround = replayGround + g.ApplyInput(input) + + replayX = g.predictedX + replayY = g.predictedY + replayVX = g.predictedVX + replayVY = g.predictedVY + replayGround = g.predictedGround + + // Zurücksetzen + g.predictedX = oldX + g.predictedY = oldY + g.predictedVX = oldVX + g.predictedVY = oldVY + g.predictedGround = oldGround } } } + + // Berechne Differenz zwischen aktueller Prediction und Server-Replay + diffX := replayX - g.predictedX + diffY := replayY - g.predictedY + + // Nur korrigieren wenn Differenz signifikant (> 5 Pixel) + const threshold = 5.0 + if diffX*diffX+diffY*diffY > threshold*threshold { + // Speichere Korrektur für sanfte Interpolation + g.correctionX = diffX + g.correctionY = diffY + } + + // Velocity und Ground immer sofort übernehmen + g.predictedVX = replayVX + g.predictedVY = replayVY + g.predictedGround = replayGround } diff --git a/cmd/client/storage_native.go b/cmd/client/storage_native.go index 3fc7dd3..95f96b9 100644 --- a/cmd/client/storage_native.go +++ b/cmd/client/storage_native.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "io/ioutil" "log" + "strings" ) // loadOrCreatePlayerCode lädt oder erstellt einen eindeutigen Spieler-Code (Desktop Version) @@ -35,3 +36,28 @@ func (g *Game) loadOrCreatePlayerCode() { log.Printf("🆕 Neuer Player-Code erstellt: %s", g.playerCode) } + +// loadPlayerName lädt gespeicherten Spielernamen (Desktop Version) +func (g *Game) loadPlayerName() string { + const nameFile = "player_name.txt" + + if data, err := ioutil.ReadFile(nameFile); err == nil { + name := strings.TrimSpace(string(data)) + if name != "" { + log.Printf("👤 Spielername geladen: %s", name) + return name + } + } + return "" +} + +// savePlayerName speichert Spielernamen (Desktop Version) +func (g *Game) savePlayerName(name string) { + const nameFile = "player_name.txt" + + if err := ioutil.WriteFile(nameFile, []byte(name), 0644); err != nil { + log.Printf("⚠️ Fehler beim Speichern des Spielernamens: %v", err) + } else { + log.Printf("💾 Spielername gespeichert: %s", name) + } +} diff --git a/cmd/client/storage_wasm.go b/cmd/client/storage_wasm.go index 165e14f..9376a02 100644 --- a/cmd/client/storage_wasm.go +++ b/cmd/client/storage_wasm.go @@ -43,3 +43,33 @@ func (g *Game) loadOrCreatePlayerCode() { } } } + +// loadPlayerName lädt gespeicherten Spielernamen (WebAssembly Version) +func (g *Game) loadPlayerName() string { + const storageKey = "escape_from_teacher_player_name" + + if jsGlobal := js.Global(); !jsGlobal.IsUndefined() { + localStorage := jsGlobal.Get("localStorage") + if !localStorage.IsUndefined() { + stored := localStorage.Call("getItem", storageKey) + if !stored.IsNull() && stored.String() != "" { + log.Printf("👤 Spielername aus LocalStorage geladen: %s", stored.String()) + return stored.String() + } + } + } + return "" +} + +// savePlayerName speichert Spielernamen (WebAssembly Version) +func (g *Game) savePlayerName(name string) { + const storageKey = "escape_from_teacher_player_name" + + if jsGlobal := js.Global(); !jsGlobal.IsUndefined() { + localStorage := jsGlobal.Get("localStorage") + if !localStorage.IsUndefined() { + localStorage.Call("setItem", storageKey, name) + log.Printf("💾 Spielername in LocalStorage gespeichert: %s", name) + } + } +} diff --git a/cmd/client/wasm_bridge.go b/cmd/client/wasm_bridge.go new file mode 100644 index 0000000..62ee9f7 --- /dev/null +++ b/cmd/client/wasm_bridge.go @@ -0,0 +1,119 @@ +//go:build js && wasm +// +build js,wasm + +package main + +import ( + "fmt" + "log" + "syscall/js" + "time" +) + +// setupJavaScriptBridge registriert JavaScript-Funktionen für HTML-Menü Interaktion +func (g *Game) setupJavaScriptBridge() { + // startGame(mode, playerName, roomID, teamName, isHost) + startGameFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) < 2 { + log.Println("❌ startGame: Nicht genug Argumente") + return nil + } + + mode := args[0].String() // "solo" oder "coop" + playerName := args[1].String() // Spielername + + // Spieler-Daten setzen + g.playerName = playerName + g.gameMode = mode + g.savePlayerName(playerName) + + if mode == "solo" { + // Solo Mode - direkt ins Spiel + g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano()) + g.isHost = true + g.appState = StateGame + log.Printf("🎮 Solo-Spiel gestartet: %s", playerName) + } else if mode == "coop" && len(args) >= 5 { + // Co-op Mode - in die Lobby + roomID := args[2].String() + teamName := args[3].String() + isHost := args[4].Bool() + + g.roomID = roomID + g.teamName = teamName + g.isHost = isHost + g.appState = StateLobby + + log.Printf("🎮 Co-op-Lobby erstellt: %s (Room: %s, Team: %s, Host: %v)", playerName, roomID, teamName, isHost) + } + + // Verbindung starten + go g.connectAndStart() + + return nil + }) + + // requestLeaderboard() + requestLeaderboardFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + log.Println("📊 Leaderboard angefordert") + go g.requestLeaderboard() + return nil + }) + + // setMusicVolume(volume) + setMusicVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) > 0 { + volume := args[0].Float() + g.audio.SetMusicVolume(volume) + log.Printf("🎵 Musik-Lautstärke: %.0f%%", volume*100) + } + return nil + }) + + // setSFXVolume(volume) + setSFXVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + if len(args) > 0 { + volume := args[0].Float() + g.audio.SetSFXVolume(volume) + log.Printf("🔊 SFX-Lautstärke: %.0f%%", volume*100) + } + return nil + }) + + // Im globalen Scope registrieren + js.Global().Set("startGame", startGameFunc) + js.Global().Set("requestLeaderboard", requestLeaderboardFunc) + js.Global().Set("setMusicVolume", setMusicVolumeFunc) + js.Global().Set("setSFXVolume", setSFXVolumeFunc) + + log.Println("✅ JavaScript Bridge registriert") +} + +// Leaderboard an JavaScript senden +func (g *Game) sendLeaderboardToJS() { + g.leaderboardMutex.Lock() + entries := make([]interface{}, len(g.leaderboard)) + for i, entry := range g.leaderboard { + entries[i] = map[string]interface{}{ + "player_name": entry.PlayerName, + "score": entry.Score, + "player_code": entry.PlayerCode, + } + } + g.leaderboardMutex.Unlock() + + // JavaScript-Funktion aufrufen + if updateFunc := js.Global().Get("updateLeaderboard"); !updateFunc.IsUndefined() { + jsEntries := js.ValueOf(entries) + updateFunc.Invoke(jsEntries) + log.Printf("📤 Leaderboard an JavaScript gesendet: %d Einträge", len(entries)) + } +} + +// Game Over Screen an JavaScript senden +func (g *Game) sendGameOverToJS(score int) { + if gameOverFunc := js.Global().Get("showGameOver"); !gameOverFunc.IsUndefined() { + gameOverFunc.Invoke(score) + log.Printf("💀 Game Over an JavaScript gesendet: Score=%d", score) + } +} diff --git a/cmd/client/web/README.md b/cmd/client/web/README.md new file mode 100644 index 0000000..12ccf76 --- /dev/null +++ b/cmd/client/web/README.md @@ -0,0 +1,89 @@ +# Escape From Teacher - WASM Web Version + +## 🎮 Starten + +```bash +# Im web-Verzeichnis einen HTTP-Server starten +cd cmd/client/web +python3 -m http.server 8000 +``` + +Dann im Browser öffnen: **http://localhost:8000** + +## ✨ Features + +### Modernes HTML/CSS Menü +- **Custom Font**: Nutzt `front.ttf` aus dem assets-Ordner +- **Responsive Design**: Funktioniert auf Desktop, Tablet und Smartphone +- **Glassmorphism**: Moderner durchsichtiger Look mit Blur-Effekt +- **Smooth Animations**: Fade-in, Slide-up, Pulse-Effekte + +### Menü-Optionen +1. **Solo spielen**: Einzelspieler-Modus +2. **Co-op spielen**: Multiplayer mit Raum-Code +3. **Leaderboard**: Top 10 Spieler anzeigen +4. **Einstellungen**: Musik & SFX Lautstärke einstellen + +### Mobile-Optimiert +- Touch-freundliche Buttons +- Responsive Layout für kleine Bildschirme +- Viewport-Anpassung für Smartphones + +## 🎨 Design-Features + +- **Gradient Background**: Lila-Blauer Farbverlauf +- **Button Hover-Effekte**: Ripple-Animationen +- **Custom Scrollbars**: Für Leaderboard +- **Loading Screen**: Spinner während WASM lädt +- **Emoji-Icons**: 🏃 👥 🏆 ⚙️ + +## 📁 Dateien + +- `index.html`: HTML-Struktur +- `style.css`: Alle Styles mit Custom Font +- `game.js`: JavaScript-Bridge zwischen HTML und WASM +- `wasm_exec.js`: Go WASM Runtime (von Go kopiert) +- `main.wasm`: Kompiliertes Spiel + +## 🔧 Entwicklung + +### WASM neu kompilieren +```bash +GOOS=js GOARCH=wasm go build -o cmd/client/web/main.wasm ./cmd/client +``` + +### Font ändern +Ersetze `cmd/client/assets/front.ttf` und aktualisiere den Pfad in `style.css`: +```css +@font-face { + font-family: 'GameFont'; + src: url('../assets/front.ttf') format('truetype'); +} +``` + +## 🎯 Keyboard Shortcuts + +- **ESC**: Zurück zum Menü (während des Spiels) + +## 📱 Mobile Testing + +Für lokales Mobile Testing: +```bash +python3 -m http.server 8000 +# Öffne auf Smartphone: http://[DEINE-IP]:8000 +``` + +## 🚀 Production Deployment + +Für Production alle Dateien hochladen: +- index.html +- style.css +- game.js +- wasm_exec.js +- main.wasm +- assets/ (Font-Ordner) + +Server muss WASM mit korrektem MIME-Type ausliefern: +``` +Content-Type: application/wasm +``` diff --git a/cmd/client/web/assets b/cmd/client/web/assets new file mode 120000 index 0000000..ec2e4be --- /dev/null +++ b/cmd/client/web/assets @@ -0,0 +1 @@ +../assets \ No newline at end of file diff --git a/cmd/client/web/game.js b/cmd/client/web/game.js new file mode 100644 index 0000000..24a56a5 --- /dev/null +++ b/cmd/client/web/game.js @@ -0,0 +1,341 @@ +// Game State +let wasmReady = false; +let gameStarted = false; +let audioMuted = false; + +// Initialize WASM +async function initWASM() { + const go = new Go(); + + try { + const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject); + go.run(result.instance); + wasmReady = true; + + // Hide loading screen + document.getElementById('loading').style.display = 'none'; + + console.log('✅ WASM loaded successfully'); + + // Load initial leaderboard + setTimeout(() => { + if (window.requestLeaderboard) { + window.requestLeaderboard(); + } + }, 500); + } catch (err) { + console.error('❌ Failed to load WASM:', err); + document.getElementById('loading').innerHTML = '

Fehler beim Laden: ' + err.message + '

'; + } +} + +// Menu Navigation +function showMainMenu() { + hideAllScreens(); + document.getElementById('menu').classList.remove('hidden'); +} + +function showCoopMenu() { + hideAllScreens(); + document.getElementById('coopMenu').classList.remove('hidden'); +} + +function showSettings() { + hideAllScreens(); + document.getElementById('settingsMenu').classList.remove('hidden'); +} + +function showLeaderboard() { + hideAllScreens(); + document.getElementById('leaderboardMenu').classList.remove('hidden'); + loadLeaderboard(); +} + +function hideAllScreens() { + document.querySelectorAll('.overlay-screen').forEach(screen => { + screen.classList.add('hidden'); + }); +} + +function hideMenu() { + document.getElementById('menu').style.display = 'none'; + // Canvas sichtbar machen für Gameplay + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.classList.add('game-active'); + } +} + +function showMenu() { + document.getElementById('menu').style.display = 'flex'; + document.getElementById('menu').classList.remove('hidden'); + showMainMenu(); + // Canvas verstecken im Menü + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.classList.remove('game-active'); + } +} + +// Game Functions +function startSoloGame() { + if (!wasmReady) { + alert('Spiel wird noch geladen...'); + return; + } + + const playerName = document.getElementById('playerName').value || 'ANON'; + + // Store in localStorage for WASM to read + localStorage.setItem('escape_player_name', playerName); + localStorage.setItem('escape_game_mode', 'solo'); + localStorage.setItem('escape_room_id', ''); + + // Hide menu and show canvas + hideAllScreens(); + gameStarted = true; + + // Trigger WASM game start + if (window.startGame) { + window.startGame('solo', playerName, ''); + } + + console.log('🎮 Solo game started:', playerName); +} + +function createRoom() { + if (!wasmReady) { + alert('Spiel wird noch geladen...'); + return; + } + + const playerName = document.getElementById('playerName').value || 'ANON'; + const roomID = 'R' + Math.random().toString(36).substr(2, 5).toUpperCase(); + const teamName = 'TEAM'; + + // Store in localStorage + localStorage.setItem('escape_player_name', playerName); + localStorage.setItem('escape_game_mode', 'coop'); + localStorage.setItem('escape_room_id', roomID); + localStorage.setItem('escape_team_name', teamName); + localStorage.setItem('escape_is_host', 'true'); + + // Hide menu and show canvas (Lobby wird vom WASM gezeichnet) + hideAllScreens(); + gameStarted = true; + + // Canvas sichtbar machen für Lobby + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.classList.add('game-active'); + } + + // Trigger WASM game start + if (window.startGame) { + window.startGame('coop', playerName, roomID, teamName, true); + } + + console.log('🎮 Room created:', roomID); +} + +function joinRoom() { + if (!wasmReady) { + alert('Spiel wird noch geladen...'); + return; + } + + const playerName = document.getElementById('playerName').value || 'ANON'; + const roomID = document.getElementById('joinRoomCode').value.toUpperCase(); + const teamName = document.getElementById('teamNameJoin').value || 'TEAM'; + + if (!roomID || roomID.length < 4) { + alert('Bitte gib einen gültigen Raum-Code ein!'); + return; + } + + // Store in localStorage + localStorage.setItem('escape_player_name', playerName); + localStorage.setItem('escape_game_mode', 'coop'); + localStorage.setItem('escape_room_id', roomID); + localStorage.setItem('escape_team_name', teamName); + localStorage.setItem('escape_is_host', 'false'); + + // Hide menu and show canvas + hideAllScreens(); + gameStarted = true; + + // Canvas sichtbar machen für Lobby + const canvas = document.querySelector('canvas'); + if (canvas) { + canvas.classList.add('game-active'); + } + + // Trigger WASM game start + if (window.startGame) { + window.startGame('coop', playerName, roomID, teamName, false); + } + + console.log('🎮 Joining room:', roomID); +} + +function loadLeaderboard() { + const list = document.getElementById('leaderboardList'); + list.innerHTML = '
Lädt Leaderboard...
'; + + // Request leaderboard from WASM + if (window.requestLeaderboard) { + window.requestLeaderboard(); + } + + // Fallback timeout + setTimeout(() => { + if (list.innerHTML.includes('Lädt')) { + list.innerHTML = '
Keine Daten verfügbar
'; + } + }, 3000); +} + +// Called by WASM to update leaderboard +function updateLeaderboard(entries) { + // Update main leaderboard page + const list = document.getElementById('leaderboardList'); + + // Update start screen leaderboard + const startList = document.getElementById('startLeaderboardList'); + + if (!entries || entries.length === 0) { + const emptyMsg = '
Noch keine Einträge
'; + if (list) list.innerHTML = emptyMsg; + if (startList) startList.innerHTML = emptyMsg; + return; + } + + const html = entries.slice(0, 10).map((entry, index) => { + const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}.`; + return ` +
+
${medal}
+
${entry.player_name}
+
${entry.score}
+
+ `; + }).join(''); + + if (list) list.innerHTML = html; + if (startList) startList.innerHTML = html; + + console.log('📊 Leaderboard updated with', entries.length, 'entries'); +} + +// Audio Toggle +function toggleAudio() { + audioMuted = !audioMuted; + const btn = document.getElementById('mute-btn'); + + if (audioMuted) { + btn.textContent = '🔇'; + if (window.setMusicVolume) window.setMusicVolume(0); + if (window.setSFXVolume) window.setSFXVolume(0); + } else { + btn.textContent = '🔊'; + const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 70) / 100; + const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 70) / 100; + if (window.setMusicVolume) window.setMusicVolume(musicVol); + if (window.setSFXVolume) window.setSFXVolume(sfxVol); + } +} + +// Settings Volume Sliders +document.addEventListener('DOMContentLoaded', () => { + const musicSlider = document.getElementById('musicVolume'); + const sfxSlider = document.getElementById('sfxVolume'); + const musicValue = document.getElementById('musicValue'); + const sfxValue = document.getElementById('sfxValue'); + + if (musicSlider) { + musicSlider.addEventListener('input', (e) => { + const value = e.target.value; + musicValue.textContent = value + '%'; + localStorage.setItem('escape_music_volume', value); + + if (window.setMusicVolume && !audioMuted) { + window.setMusicVolume(value / 100); + } + }); + + // Load saved value + const savedMusic = localStorage.getItem('escape_music_volume') || 70; + musicSlider.value = savedMusic; + musicValue.textContent = savedMusic + '%'; + } + + if (sfxSlider) { + sfxSlider.addEventListener('input', (e) => { + const value = e.target.value; + sfxValue.textContent = value + '%'; + localStorage.setItem('escape_sfx_volume', value); + + if (window.setSFXVolume && !audioMuted) { + window.setSFXVolume(value / 100); + } + }); + + // Load saved value + const savedSFX = localStorage.getItem('escape_sfx_volume') || 70; + sfxSlider.value = savedSFX; + sfxValue.textContent = savedSFX + '%'; + } + + // Load saved player name + const savedName = localStorage.getItem('escape_player_name'); + if (savedName) { + document.getElementById('playerName').value = savedName; + } + + // Load local highscore + const highscore = localStorage.getItem('escape_local_highscore') || 0; + const hsElement = document.getElementById('localHighscore'); + if (hsElement) { + hsElement.textContent = highscore; + } +}); + +// Keyboard shortcuts +document.addEventListener('keydown', (e) => { + // ESC to show menu (only if game is started) + if (e.key === 'Escape' && gameStarted) { + showMenu(); + gameStarted = false; + } +}); + +// Show Game Over Screen (called by WASM) +function showGameOver(score) { + hideAllScreens(); + document.getElementById('gameOverScreen').classList.remove('hidden'); + document.getElementById('finalScore').textContent = score; + + // Update local highscore + const currentHS = parseInt(localStorage.getItem('escape_local_highscore') || 0); + if (score > currentHS) { + localStorage.setItem('escape_local_highscore', score); + } + + // Request leaderboard + if (window.requestLeaderboard) { + window.requestLeaderboard(); + } + + console.log('💀 Game Over! Score:', score); +} + +// Export functions for WASM to call +window.showMenu = showMenu; +window.hideMenu = hideMenu; +window.updateLeaderboard = updateLeaderboard; +window.showGameOver = showGameOver; + +// Initialize on load +initWASM(); + +console.log('🎮 Game.js loaded - Retro Edition'); diff --git a/cmd/client/web/index.html b/cmd/client/web/index.html new file mode 100644 index 0000000..b9078a5 --- /dev/null +++ b/cmd/client/web/index.html @@ -0,0 +1,147 @@ + + + + + + Escape From Teacher + + + + + + +
+
📱↻
+

Bitte Gerät drehen!

+ Querformat benötigt +
+ +
+ + + + + + + + + + + + + + + + +
+
+

Lade Escape From Teacher...

+
+
+ + + + + + + diff --git a/cmd/client/web/main.wasm b/cmd/client/web/main.wasm new file mode 100755 index 0000000..4f29bae Binary files /dev/null and b/cmd/client/web/main.wasm differ diff --git a/cmd/client/web/style.css b/cmd/client/web/style.css new file mode 100644 index 0000000..fbd3888 --- /dev/null +++ b/cmd/client/web/style.css @@ -0,0 +1 @@ +/* ========================================= LOKALE SCHRIFTARTEN (DSGVO Konform) ========================================= */@font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')}body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;display:flex;justify-content:center;align-items:center;font-family:'Press Start 2P',cursive;font-size:14px}#game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000;display:flex;overflow:hidden}canvas{position:fixed!important;top:0!important;left:0!important;width:100%!important;height:100%!important;z-index:1!important;background:#000;image-rendering:pixelated;image-rendering:crisp-edges;opacity:0;pointer-events:none;transition:opacity .3s}canvas.game-active{opacity:1;pointer-events:auto}.overlay-screen{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.95);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px}.overlay-screen.hidden{display:none!important}#startScreen{display:flex;flex-direction:row;gap:40px;width:100%;height:100%;align-items:center;justify-content:center}.start-left{flex:2;display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:60%}.start-right{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:35%}.center-box{display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:600px;width:100%}h1{font-size:32px;color:#f44;text-shadow:4px 4px 0 #000;line-height:1.4;margin:10px 0 25px;text-align:center;text-transform:uppercase}button{font-family:'Press Start 2P',cursive;background:#fc0;border:4px solid #fff;padding:18px 30px;font-size:18px;cursor:pointer;color:#000;box-shadow:0 6px 0 #997a00;text-transform:uppercase;margin:12px;transition:all .1s}button:hover{background:#ffd700}button:active{transform:translateY(4px);box-shadow:0 1px 0 #997a00}button:disabled{background:#555;color:#888;box-shadow:none;cursor:not-allowed}.big-btn{font-size:22px;padding:20px 40px}.back-btn{background:0 0;border:2px solid #666;color:#888;box-shadow:none;font-size:12px;padding:10px 20px;margin-top:30px}.back-btn:hover{background:#333;color:#fff;border-color:#fff}.legal-btn{font-size:10px;padding:8px 12px;margin:5px;background:0 0;border:1px solid #666;color:#888;box-shadow:none}.legal-btn:hover{background:#333;color:#fff;border-color:#fff}input[type=range],input[type=text]{font-family:'Press Start 2P',cursive;padding:12px;font-size:16px;border:3px solid #fff;background:#222;color:#fff;text-align:center;margin-bottom:15px;width:100%;max-width:350px;outline:0;box-sizing:border-box}input[type=text]::placeholder{color:#666}input[type=text]:focus{border-color:#fc0;box-shadow:0 0 10px rgba(255,204,0,.5)}.info-box{background:rgba(255,255,255,.1);border:2px solid #555;padding:12px;margin:8px 0;width:100%;max-width:320px;text-align:left;box-sizing:border-box}.info-box p{font-family:sans-serif;font-size:14px;color:#ccc;line-height:1.4;margin:0}.info-title{color:#fc0;font-size:12px;margin-bottom:6px;text-align:center;text-decoration:underline}.legal-bar{margin-top:20px;display:flex;gap:15px;flex-wrap:wrap;justify-content:center}.hall-of-fame-box{background:rgba(0,0,0,.6);border:4px solid #fc0;padding:15px;width:100%;max-height:300px;overflow-y:auto;box-shadow:0 0 15px rgba(255,204,0,.1)}.hall-of-fame-box h3{color:#fc0;text-align:center;margin-top:0;margin-bottom:10px;border-bottom:2px solid #555;padding-bottom:8px;font-size:14px}.leaderboard-box{background:rgba(0,0,0,.5);border:4px solid #666;padding:15px;width:100%;max-width:500px;max-height:400px;overflow-y:auto;font-family:sans-serif;color:#ddd}.hof-entry,.leaderboard-item{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px dotted #444;font-size:12px;font-family:sans-serif;color:#ddd;line-height:1.4}.hof-rank,.leaderboard-rank{color:#fc0;font-weight:700;margin-right:10px;min-width:40px}.hof-name,.leaderboard-name{flex:1}.hof-score,.leaderboard-score{color:#fff;font-weight:700}.settings-group{display:flex;flex-direction:column;gap:25px;width:100%;max-width:500px;margin:20px 0}.setting-item{display:flex;flex-direction:column;align-items:center;gap:10px}.setting-item label{color:#fc0;font-size:12px}.setting-item input[type=range]{width:100%;max-width:300px}.setting-item span{color:#fff;font-size:14px}.loading-screen{position:absolute;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.95);display:flex;flex-direction:column;justify-content:center;align-items:center;z-index:9999}.spinner{border:8px solid #333;border-top:8px solid #fc0;border-radius:50%;width:60px;height:60px;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}#mute-btn{position:fixed;top:10px;left:10px;z-index:10000;background:rgba(0,0,0,.5);border:2px solid #555;color:#fff;font-size:20px;width:40px;height:40px;border-radius:50%;cursor:pointer;padding:0;margin:0;display:flex;align-items:center;justify-content:center;box-shadow:0 0 10px rgba(0,0,0,.5)}#mute-btn:hover{background:rgba(255,255,255,.2);border-color:#fff}#rotate-overlay{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:#222;z-index:99999;color:#fff;flex-direction:column;align-items:center;justify-content:center;text-align:center}.icon{font-size:60px;margin-bottom:20px}@media screen and (orientation:portrait){#rotate-overlay{display:flex}#game-container{display:none!important}}@media (min-width:1024px){h1{font-size:48px;margin-bottom:40px}button{font-size:22px;padding:20px 40px}input[type=text]{max-width:450px;font-size:20px;padding:15px}.info-box{max-width:500px}.info-box p{font-size:16px}.info-title{font-size:14px}.hall-of-fame-box{max-height:400px}.hof-entry,.leaderboard-item{font-size:14px;padding:10px 0}.hall-of-fame-box h3{font-size:18px;margin-bottom:15px}}@media (max-height:500px){#startScreen{flex-direction:row;align-items:center;justify-content:center;gap:20px;padding:10px;overflow-y:auto}.start-left{width:45%;max-width:none;align-items:center;text-align:center;margin:0}.start-right{width:45%;max-width:none;height:auto;margin:0}h1{font-size:18px;margin:0 0 10px;line-height:1.2}.info-box{display:none}.legal-bar{margin-top:5px}button{padding:8px 12px;font-size:10px;margin:5px}.hall-of-fame-box{max-height:180px}.hall-of-fame-box h3{font-size:10px;margin-bottom:5px}.hof-entry,.leaderboard-item{padding:2px 0;font-size:8px}input[type=text]{padding:5px;font-size:12px;max-width:200px;margin-bottom:5px}} diff --git a/cmd/client/web/wasm_exec.js b/cmd/client/web/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/cmd/client/web/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/cmd/levelbuilder/main.go b/cmd/levelbuilder/main.go index 08fead3..9b7cc64 100644 --- a/cmd/levelbuilder/main.go +++ b/cmd/levelbuilder/main.go @@ -28,10 +28,11 @@ const ( AssetFile = "./cmd/client/assets/assets.json" ChunkDir = "./cmd/client/assets/chunks" - SidebarWidth = 250 - TopBarHeight = 40 - CanvasHeight = 720 - CanvasWidth = 1280 + LeftSidebarWidth = 250 + RightSidebarWidth = 250 + TopBarHeight = 40 + CanvasHeight = 720 + CanvasWidth = 1280 TileSize = 64 RefFloorY = 540 @@ -55,12 +56,15 @@ type LevelEditor struct { assetList []string assetsImages map[string]*ebiten.Image - currentChunk game.Chunk + currentChunk game.Chunk + currentChunkFile string // Aktuell geladene Datei + chunkFiles []string // Liste aller Chunk-Dateien - scrollX float64 - zoom float64 - listScroll float64 - statusMsg string + scrollX float64 + zoom float64 + listScroll float64 + chunkListScroll float64 // Scroll für Chunk-Liste + statusMsg string showGrid bool enableSnap bool @@ -75,20 +79,31 @@ type LevelEditor struct { dragAssetID string dragTargetIndex int dragOffset game.Vec2 + + // Bewegende Plattform-Modus + movingPlatformMode bool // Ist Bewegende-Plattform-Modus aktiv? + movingPlatformObjIndex int // Index des aktuellen Plattform-Objekts + movingPlatformSetStart bool // true = setze Start, false = setze End } 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 + 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 + movingPlatformObjIndex: -1, } le.LoadAssets() - le.LoadChunk("chunk_01.json") + le.RefreshChunkList() + if len(le.chunkFiles) > 0 { + le.LoadChunk(le.chunkFiles[0]) + } else { + le.currentChunkFile = "chunk_new.json" + } return le } @@ -117,29 +132,82 @@ func (le *LevelEditor) LoadAssets() { sort.Strings(le.assetList) } +func (le *LevelEditor) RefreshChunkList() { + le.chunkFiles = []string{} + files, err := ioutil.ReadDir(ChunkDir) + if err != nil { + return + } + for _, f := range files { + if !f.IsDir() && strings.HasSuffix(f.Name(), ".json") { + le.chunkFiles = append(le.chunkFiles, f.Name()) + } + } + sort.Strings(le.chunkFiles) +} + 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.currentChunkFile = filename le.statusMsg = "Geladen: " + filename } else { le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename)) + le.currentChunk.Width = 50 + le.currentChunk.Objects = []game.LevelObject{} + le.currentChunkFile = filename le.statusMsg = "Neu erstellt: " + le.currentChunk.ID } } +func (le *LevelEditor) CreateNewChunk(name string) { + if name == "" { + name = "chunk_new" + } + filename := name + ".json" + le.currentChunk = game.Chunk{ + ID: name, + Width: 50, + Objects: []game.LevelObject{}, + } + le.currentChunkFile = filename + le.SaveChunk() + le.RefreshChunkList() + le.statusMsg = "Neuer Chunk erstellt: " + filename +} + +func (le *LevelEditor) DeleteChunk(filename string) { + path := filepath.Join(ChunkDir, filename) + err := os.Remove(path) + if err == nil { + le.statusMsg = "Gelöscht: " + filename + le.RefreshChunkList() + // Lade ersten verfügbaren Chunk oder erstelle neuen + if len(le.chunkFiles) > 0 { + le.LoadChunk(le.chunkFiles[0]) + } else { + le.CreateNewChunk("chunk_new") + } + } else { + le.statusMsg = "Fehler beim Löschen: " + err.Error() + } +} + 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.currentChunkFile = filename + le.RefreshChunkList() le.statusMsg = "GESPEICHERT als " + filename } func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) { - screenX := float64(mx - SidebarWidth) + screenX := float64(mx - LeftSidebarWidth) screenY := float64(my - TopBarHeight) worldX := (screenX / le.zoom) + le.scrollX worldY := screenY / le.zoom @@ -200,6 +268,17 @@ func (le *LevelEditor) Update() error { le.currentChunk.Width = v } } + if le.activeField == "newchunk" { + le.CreateNewChunk(le.inputBuffer) + } + if le.activeField == "mpspeed" { + if v, err := strconv.ParseFloat(le.inputBuffer, 64); err == nil && le.movingPlatformObjIndex != -1 { + if le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform != nil { + le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.Speed = v + le.statusMsg = fmt.Sprintf("Speed gesetzt: %.0f", v) + } + } + } le.activeField = "" } else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { le.activeField = "" @@ -214,7 +293,9 @@ func (le *LevelEditor) Update() error { } // Hotkeys - if mx > SidebarWidth { + canvasStartX := LeftSidebarWidth + canvasEndX := CanvasWidth - RightSidebarWidth + if mx > canvasStartX && mx < canvasEndX { _, wy := ebiten.Wheel() if wy != 0 { le.zoom += wy * 0.1 @@ -238,6 +319,15 @@ func (le *LevelEditor) Update() error { if inpututil.IsKeyJustPressed(ebiten.KeyP) { le.showPlayerRef = !le.showPlayerRef } // NEU: Toggle Player + if inpututil.IsKeyJustPressed(ebiten.KeyM) { + le.movingPlatformMode = !le.movingPlatformMode + if !le.movingPlatformMode { + le.movingPlatformObjIndex = -1 + le.statusMsg = "Moving Platform Mode deaktiviert" + } else { + le.statusMsg = "Moving Platform Mode: Plattform auswählen" + } + } if ebiten.IsKeyPressed(ebiten.KeyRight) { le.scrollX += 10 / le.zoom } @@ -263,8 +353,8 @@ func (le *LevelEditor) Update() error { return nil } - // Palette - if mx < SidebarWidth { + // Left Sidebar - Asset Palette + if mx < LeftSidebarWidth { _, wy := ebiten.Wheel() le.listScroll -= wy * 20 if le.listScroll < 0 { @@ -284,23 +374,119 @@ func (le *LevelEditor) Update() error { return nil } - // Canvas Logic + // Right Sidebar - Chunk Manager + if mx > CanvasWidth-RightSidebarWidth { + _, wy := ebiten.Wheel() + le.chunkListScroll -= wy * 20 + if le.chunkListScroll < 0 { + le.chunkListScroll = 0 + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + // "Neuer Chunk" Button (Y: TopBarHeight+30 bis TopBarHeight+60) + if mx >= CanvasWidth-RightSidebarWidth+10 && mx < CanvasWidth-RightSidebarWidth+240 && + my >= TopBarHeight+30 && my < TopBarHeight+60 { + le.activeField = "newchunk" + le.inputBuffer = "" + return nil + } + + // Chunk-Liste (startet bei TopBarHeight+70) + if my >= TopBarHeight+70 { + clickY := float64(my-TopBarHeight-70) + le.chunkListScroll + idx := int(clickY / 30) + if idx >= 0 && idx < len(le.chunkFiles) { + le.LoadChunk(le.chunkFiles[idx]) + } + } + } + + // Rechtsklick zum Löschen in Chunk-Liste + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) && my >= TopBarHeight+70 { + clickY := float64(my-TopBarHeight-70) + le.chunkListScroll + idx := int(clickY / 30) + if idx >= 0 && idx < len(le.chunkFiles) { + if len(le.chunkFiles) > 1 || le.chunkFiles[idx] != le.currentChunkFile { + le.DeleteChunk(le.chunkFiles[idx]) + } else { + le.statusMsg = "Kann einzigen Chunk nicht löschen!" + } + } + } + return nil + } + + // Canvas Logic (nur wenn wir wirklich im Canvas-Bereich sind) + if mx < LeftSidebarWidth || mx > CanvasWidth-RightSidebarWidth || my < TopBarHeight { + return nil + } + worldX, worldY := le.ScreenToWorld(mx, my) - // DELETE + // MOVING PLATFORM MODE + if le.movingPlatformMode && inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + // Keine Plattform ausgewählt? → Plattform auswählen + if le.movingPlatformObjIndex == -1 { + for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- { + obj := le.currentChunk.Objects[i] + assetDef, ok := le.assetManifest.Assets[obj.AssetID] + if !ok || assetDef.Type != "platform" { + continue + } + w, h := le.GetAssetSize(obj.AssetID) + if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h { + le.movingPlatformObjIndex = i + le.movingPlatformSetStart = true + + // Wenn noch keine MovingPlatform-Daten → initialisiere + if obj.MovingPlatform == nil { + le.currentChunk.Objects[i].MovingPlatform = &game.MovingPlatformData{ + StartX: obj.X, + StartY: obj.Y, + EndX: obj.X + 200, // Default Endpunkt + EndY: obj.Y, + Speed: 100, // Default Speed + } + } + le.statusMsg = "Plattform gewählt - Klicke Start-Punkt" + le.activeField = "mpspeed" + le.inputBuffer = fmt.Sprintf("%.0f", le.currentChunk.Objects[i].MovingPlatform.Speed) + return nil + } + } + } else { + // Plattform ist ausgewählt → setze Start oder End + if le.movingPlatformSetStart { + le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartX = worldX + le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartY = worldY + le.movingPlatformSetStart = false + le.statusMsg = "Start gesetzt - Klicke End-Punkt" + } else { + le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndX = worldX + le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndY = worldY + le.statusMsg = "End gesetzt - Drücke M zum Beenden oder wähle neue Plattform" + le.movingPlatformObjIndex = -1 + } + return nil + } + return nil + } + + // DELETE mit Rechtsklick 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:]...) + le.statusMsg = fmt.Sprintf("Objekt gelöscht: %s", obj.AssetID) return nil } } } // MOVE - if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging { + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging && !le.movingPlatformMode { for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- { obj := le.currentChunk.Objects[i] w, h := le.GetAssetSize(obj.AssetID) @@ -352,11 +538,13 @@ func (le *LevelEditor) Update() error { 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) + vector.DrawFilledRect(screen, 0, TopBarHeight, LeftSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false) + vector.DrawFilledRect(screen, CanvasWidth-RightSidebarWidth, TopBarHeight, RightSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false) text.Draw(screen, "ID: "+le.currentChunk.ID, basicfont.Face7x13, 75, 25, color.White) - // ASSET LISTE + // LEFT SIDEBAR - ASSET LISTE + text.Draw(screen, "ASSETS", basicfont.Face7x13, 10, TopBarHeight+20, ColHighlight) startY := float64(TopBarHeight+40) - le.listScroll for i, id := range le.assetList { y := startY + float64(i*25) @@ -370,26 +558,81 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) { text.Draw(screen, id, basicfont.Face7x13, 10, int(y+15), col) } + // RIGHT SIDEBAR - CHUNK MANAGER + rightX := CanvasWidth - RightSidebarWidth + text.Draw(screen, "CHUNKS", basicfont.Face7x13, rightX+10, TopBarHeight+20, ColHighlight) + + // "Neuer Chunk" Button + btnX := float32(rightX + 10) + btnY := float32(TopBarHeight + 30) + btnW := float32(230) + btnH := float32(30) + vector.DrawFilledRect(screen, btnX, btnY, btnW, btnH, color.RGBA{60, 120, 80, 255}, false) + vector.StrokeRect(screen, btnX, btnY, btnW, btnH, 2, ColHighlight, false) + + if le.activeField == "newchunk" { + text.Draw(screen, "Name: "+le.inputBuffer+"_", basicfont.Face7x13, rightX+15, TopBarHeight+50, color.White) + } else { + text.Draw(screen, "[+] Neuer Chunk", basicfont.Face7x13, rightX+65, TopBarHeight+50, color.White) + } + + // Chunk-Liste + chunkStartY := float64(TopBarHeight+70) - le.chunkListScroll + for i, filename := range le.chunkFiles { + y := chunkStartY + float64(i*30) + if y < float64(TopBarHeight+70) || y > CanvasHeight { + continue + } + + col := ColText + bgCol := color.RGBA{50, 54, 62, 255} + if filename == le.currentChunkFile { + col = color.RGBA{100, 255, 100, 255} + bgCol = color.RGBA{40, 80, 40, 255} + } + + // Hintergrund für aktuellen Chunk + vector.DrawFilledRect(screen, float32(rightX+5), float32(y), float32(RightSidebarWidth-10), 28, bgCol, false) + + // Dateiname + displayName := strings.TrimSuffix(filename, ".json") + if len(displayName) > 20 { + displayName = displayName[:20] + "..." + } + text.Draw(screen, displayName, basicfont.Face7x13, rightX+10, int(y+18), col) + } + + // Hinweis + text.Draw(screen, "L-Click: Load", basicfont.Face7x13, rightX+10, CanvasHeight-40, color.Gray{100}) + text.Draw(screen, "R-Click: Delete", basicfont.Face7x13, rightX+10, CanvasHeight-25, color.Gray{100}) + // CANVAS - canvasOffX := float64(SidebarWidth) + canvasOffX := float64(LeftSidebarWidth) canvasOffY := float64(TopBarHeight) + canvasWidth := float32(CanvasWidth - LeftSidebarWidth - RightSidebarWidth) + + // Canvas Hintergrund + vector.DrawFilledRect(screen, float32(canvasOffX), float32(canvasOffY), canvasWidth, CanvasHeight-TopBarHeight, ColBgCanvas, false) // GRID + canvasEndX := float32(CanvasWidth - RightSidebarWidth) if le.showGrid { startGridX := int(le.scrollX/TileSize) * TileSize - for x := startGridX; x < startGridX+int(CanvasWidth/le.zoom)+TileSize; x += TileSize { + for x := startGridX; x < startGridX+int(float64(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) + if drawX >= float32(canvasOffX) && drawX <= canvasEndX { + 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) + vector.StrokeLine(screen, float32(canvasOffX), drawY, canvasEndX, drawY, 1, ColGrid, false) } } // BODEN LINIE floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY) - vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, float32(CanvasWidth), floorScreenY, 2, ColFloor, false) + vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, canvasEndX, floorScreenY, 2, ColFloor, false) // PLAYER REFERENCE (GHOST) // PLAYER REFERENCE (GHOST) @@ -422,14 +665,54 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) { } // OBJEKTE - for _, obj := range le.currentChunk.Objects { + for i, obj := range le.currentChunk.Objects { le.DrawAsset(screen, obj.AssetID, obj.X, obj.Y, canvasOffX, canvasOffY, 1.0) + + // MOVING PLATFORM MARKER + if obj.MovingPlatform != nil { + mpd := obj.MovingPlatform + + // Start-Punkt (grün) + sxStart := float32((mpd.StartX-le.scrollX)*le.zoom + canvasOffX) + syStart := float32(mpd.StartY*le.zoom + canvasOffY) + + // End-Punkt (rot) + sxEnd := float32((mpd.EndX-le.scrollX)*le.zoom + canvasOffX) + syEnd := float32(mpd.EndY*le.zoom + canvasOffY) + + // Linie zwischen Start und End (gelb gestrichelt) + vector.StrokeLine(screen, sxStart, syStart, sxEnd, syEnd, 2, color.RGBA{255, 255, 0, 200}, false) + + // Start-Marker (grüner Kreis) + vector.DrawFilledCircle(screen, sxStart, syStart, 8, color.RGBA{0, 255, 0, 255}, false) + vector.StrokeCircle(screen, sxStart, syStart, 8, 2, color.RGBA{0, 200, 0, 255}, false) + + // End-Marker (roter Kreis) + vector.DrawFilledCircle(screen, sxEnd, syEnd, 8, color.RGBA{255, 0, 0, 255}, false) + vector.StrokeCircle(screen, sxEnd, syEnd, 8, 2, color.RGBA{200, 0, 0, 255}, false) + + // Speed Label + midX := int((sxStart + sxEnd) / 2) + midY := int((syStart + syEnd) / 2) + speedLabel := fmt.Sprintf("%.0f u/s", mpd.Speed) + text.Draw(screen, speedLabel, basicfont.Face7x13, midX-20, midY-10, color.RGBA{255, 255, 0, 255}) + } + + // Highlight wenn ausgewählt im Moving Platform Mode + if le.movingPlatformMode && i == le.movingPlatformObjIndex { + w, h := le.GetAssetSize(obj.AssetID) + sX := float32((obj.X-le.scrollX)*le.zoom + canvasOffX) + sY := float32(obj.Y*le.zoom + canvasOffY) + sW := float32(w * le.zoom) + sH := float32(h * le.zoom) + vector.StrokeRect(screen, sX, sY, sW, sH, 3, color.RGBA{255, 255, 0, 255}, false) + } } // DRAG GHOST if le.isDragging && le.dragType == "new" { mx, my := ebiten.CursorPosition() - if mx > SidebarWidth && my > TopBarHeight { + if mx > LeftSidebarWidth && mx < CanvasWidth-RightSidebarWidth && my > TopBarHeight { wRawX, wRawY := le.ScreenToWorld(mx, my) _, h := le.GetAssetSize(le.dragAssetID) snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h) @@ -441,15 +724,33 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) { } // 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) + statusText := "[S]ave | [G]rid | [H]itbox | [P]layer | [M]oving Platform | R-Click=Del" + text.Draw(screen, statusText, basicfont.Face7x13, 380, 25, color.Gray{100}) + + // Moving Platform Mode Indicator + if le.movingPlatformMode { + modeText := "🟡 MOVING PLATFORM MODE" + text.Draw(screen, modeText, basicfont.Face7x13, LeftSidebarWidth+10, TopBarHeight+20, color.RGBA{255, 255, 0, 255}) + + // Speed Input Field wenn Plattform ausgewählt + if le.movingPlatformObjIndex != -1 && le.activeField == "mpspeed" { + speedFieldX := LeftSidebarWidth + 10 + speedFieldY := TopBarHeight + 40 + fieldText := "Speed: " + le.inputBuffer + "_" + text.Draw(screen, fieldText, basicfont.Face7x13, speedFieldX, speedFieldY, color.RGBA{0, 255, 0, 255}) + text.Draw(screen, "[Enter] to confirm", basicfont.Face7x13, speedFieldX, speedFieldY+20, color.Gray{150}) + } + } + + text.Draw(screen, le.statusMsg, basicfont.Face7x13, LeftSidebarWidth+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 { + // Culling: Nicht zeichnen wenn außerhalb Canvas + if sX < float64(LeftSidebarWidth)-100 || sX > float64(CanvasWidth-RightSidebarWidth)+100 { return } diff --git a/cmd/server/main.go b/cmd/server/main.go index caa2818..0d1ada4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -113,15 +113,33 @@ func main() { } }) - // 6. HANDLER: LEADERBOARD REQUEST + // 6. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität) _, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) { top10 := server.GlobalLeaderboard.GetTop10() log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10)) ec.Publish(reply, top10) }) + // 7. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway) + _, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) { + top10 := server.GlobalLeaderboard.GetTop10() + log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10)) + + // Response an den angegebenen Channel senden + if req.ResponseChannel != "" { + resp := game.LeaderboardResponse{ + Entries: top10, + } + ec.Publish(req.ResponseChannel, &resp) + log.Printf("📤 Leaderboard-Response gesendet an %s", req.ResponseChannel) + } + }) + log.Println("✅ Server bereit. Warte auf Spieler...") + // 5. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients) + go StartWebSocketGateway("8080", ec) + // Block forever select {} } diff --git a/cmd/server/websocket_gateway.go b/cmd/server/websocket_gateway.go new file mode 100644 index 0000000..8559249 --- /dev/null +++ b/cmd/server/websocket_gateway.go @@ -0,0 +1,228 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/nats-io/nats.go" + + "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true // Erlaube alle Origins für Development + }, +} + +// WebSocketMessage ist das allgemeine Format für WebSocket-Nachrichten +type WebSocketMessage struct { + Type string `json:"type"` // "join", "input", "start", "leaderboard_request" + Payload json.RawMessage `json:"payload"` // Beliebige JSON-Daten +} + +// WebSocketClient repräsentiert einen verbundenen WebSocket-Client +type WebSocketClient struct { + conn *websocket.Conn + natsConn *nats.EncodedConn + playerID string + roomID string + send chan []byte + mutex sync.Mutex + subUpdates *nats.Subscription +} + +// handleWebSocket verwaltet eine WebSocket-Verbindung +func handleWebSocket(w http.ResponseWriter, r *http.Request, ec *nats.EncodedConn) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("❌ WebSocket Upgrade Fehler: %v", err) + return + } + + client := &WebSocketClient{ + conn: conn, + natsConn: ec, + send: make(chan []byte, 256), + } + + log.Printf("🔌 Neuer WebSocket-Client verbunden: %s", conn.RemoteAddr()) + + // Goroutinen für Lesen und Schreiben starten + go client.writePump() + go client.readPump() +} + +// readPump liest Nachrichten vom WebSocket-Client +func (c *WebSocketClient) readPump() { + defer func() { + if c.subUpdates != nil { + c.subUpdates.Unsubscribe() + } + c.conn.Close() + log.Printf("🔌 WebSocket-Client getrennt: %s", c.conn.RemoteAddr()) + }() + + for { + var msg WebSocketMessage + err := c.conn.ReadJSON(&msg) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("⚠️ WebSocket Fehler: %v", err) + } + break + } + + // Nachricht basierend auf Typ verarbeiten + c.handleMessage(msg) + } +} + +// writePump sendet Nachrichten zum WebSocket-Client +func (c *WebSocketClient) writePump() { + defer c.conn.Close() + + for { + message, ok := <-c.send + if !ok { + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + err := c.conn.WriteMessage(websocket.TextMessage, message) + if err != nil { + log.Printf("⚠️ Fehler beim Senden: %v", err) + return + } + } +} + +// handleMessage verarbeitet eingehende Nachrichten vom Client +func (c *WebSocketClient) handleMessage(msg WebSocketMessage) { + switch msg.Type { + case "join": + var req game.JoinRequest + if err := json.Unmarshal(msg.Payload, &req); err != nil { + log.Printf("❌ Join-Payload ungültig: %v", err) + return + } + + c.playerID = req.Name + c.roomID = req.RoomID + if c.roomID == "" { + c.roomID = "lobby" + } + + log.Printf("📥 WebSocket JOIN: Name=%s, RoomID=%s", req.Name, req.RoomID) + + // An NATS weiterleiten + c.natsConn.Publish("game.join", &req) + + // Auf Game-Updates für diesen Raum subscriben + roomChannel := "game.update." + c.roomID + sub, err := c.natsConn.Subscribe(roomChannel, func(state *game.GameState) { + // GameState an WebSocket-Client senden + data, _ := json.Marshal(map[string]interface{}{ + "type": "game_update", + "payload": state, + }) + select { + case c.send <- data: + default: + log.Printf("⚠️ Send channel voll, Nachricht verworfen") + } + }) + + if err != nil { + log.Printf("❌ Fehler beim Subscribe auf %s: %v", roomChannel, err) + } else { + c.subUpdates = sub + log.Printf("👂 WebSocket-Client lauscht auf %s", roomChannel) + } + + case "input": + var input game.ClientInput + if err := json.Unmarshal(msg.Payload, &input); err != nil { + log.Printf("❌ Input-Payload ungültig: %v", err) + return + } + + // PlayerID setzen falls nicht vorhanden + if input.PlayerID == "" { + input.PlayerID = c.playerID + } + + // An NATS weiterleiten + c.natsConn.Publish("game.input", &input) + + case "start": + var req game.StartRequest + if err := json.Unmarshal(msg.Payload, &req); err != nil { + log.Printf("❌ Start-Payload ungültig: %v", err) + return + } + + log.Printf("▶️ WebSocket START: RoomID=%s", req.RoomID) + c.natsConn.Publish("game.start", &req) + + case "leaderboard_request": + var req game.LeaderboardRequest + if err := json.Unmarshal(msg.Payload, &req); err != nil { + log.Printf("❌ Leaderboard-Request ungültig: %v", err) + return + } + + log.Printf("🏆 WebSocket Leaderboard-Request: Mode=%s", req.Mode) + + // Auf Leaderboard-Response subscriben (einmalig) + responseChannel := "leaderboard.response." + c.playerID + sub, _ := c.natsConn.Subscribe(responseChannel, func(resp *game.LeaderboardResponse) { + data, _ := json.Marshal(map[string]interface{}{ + "type": "leaderboard_response", + "payload": resp, + }) + select { + case c.send <- data: + default: + } + }) + + // Nach 5 Sekunden unsubscriben + go func() { + <-make(chan struct{}) + sub.Unsubscribe() + }() + + // Request mit ResponseChannel an NATS senden + req.ResponseChannel = responseChannel + c.natsConn.Publish("leaderboard.request", &req) + + case "score_submit": + var submit game.ScoreSubmission + if err := json.Unmarshal(msg.Payload, &submit); err != nil { + log.Printf("❌ Score-Submit ungültig: %v", err) + return + } + + log.Printf("📊 WebSocket Score-Submit: Player=%s, Score=%d", submit.PlayerCode, submit.Score) + c.natsConn.Publish("score.submit", &submit) + + default: + log.Printf("⚠️ Unbekannter Nachrichtentyp: %s", msg.Type) + } +} + +// StartWebSocketGateway startet den WebSocket-Server +func StartWebSocketGateway(port string, ec *nats.EncodedConn) { + http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + handleWebSocket(w, r, ec) + }) + + log.Printf("🌐 WebSocket-Gateway läuft auf http://localhost:%s/ws", port) + if err := http.ListenAndServe(":"+port, nil); err != nil { + log.Fatal("❌ WebSocket-Server Fehler: ", err) + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 9d0033d..7558f51 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: ports: - "4222:4222" # Client Port (für unsere Go Apps) - "8222:8222" # Dashboard / Monitoring - command: "-js" # JetStream aktivieren (optional, aber gut für später) + command: ["-js"] # JetStream aktivieren volumes: redis_data: \ No newline at end of file diff --git a/go.mod b/go.mod index f20612e..672b1f1 100644 --- a/go.mod +++ b/go.mod @@ -12,4 +12,5 @@ require ( require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/gorilla/websocket v1.5.3 // indirect ) diff --git a/go.sum b/go.sum index bd63e53..60076d2 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +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/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM= github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= diff --git a/nats.conf b/nats.conf new file mode 100644 index 0000000..667e6fb --- /dev/null +++ b/nats.conf @@ -0,0 +1,12 @@ +# NATS Server Konfiguration mit WebSocket Support +port: 4222 +http_port: 8222 + +# WebSocket Support für Browser-Clients +websocket { + port: 9222 + no_tls: true +} + +# JetStream aktivieren +jetstream: enabled diff --git a/pkg/game/data.go b/pkg/game/data.go index 15dc9fd..9e0b417 100644 --- a/pkg/game/data.go +++ b/pkg/game/data.go @@ -33,6 +33,17 @@ type AssetManifest struct { type LevelObject struct { AssetID string X, Y float64 + + // Für bewegende Plattformen + MovingPlatform *MovingPlatformData `json:"moving_platform,omitempty"` +} + +type MovingPlatformData struct { + StartX float64 `json:"start_x"` // Start-Position X (relativ zum Chunk) + StartY float64 `json:"start_y"` // Start-Position Y + EndX float64 `json:"end_x"` // End-Position X + EndY float64 `json:"end_y"` // End-Position Y + Speed float64 `json:"speed"` // Geschwindigkeit (Einheiten pro Sekunde) } type Chunk struct { ID string @@ -62,34 +73,51 @@ type ClientInput struct { } type JoinRequest struct { - Name string `json:"name"` - RoomID string `json:"room_id"` + Name string `json:"name"` + RoomID string `json:"room_id"` + GameMode string `json:"game_mode"` // "solo" oder "coop" + IsHost bool `json:"is_host"` + TeamName string `json:"team_name"` } type PlayerState struct { - ID string `json:"id"` - Name string `json:"name"` - X float64 `json:"x"` - Y float64 `json:"y"` - VX float64 `json:"vx"` - VY float64 `json:"vy"` - State string `json:"state"` - OnGround bool `json:"on_ground"` - LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz - Score int `json:"score"` // Punkte des Spielers - IsAlive bool `json:"is_alive"` // Lebt der Spieler noch? - IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus + ID string `json:"id"` + Name string `json:"name"` + X float64 `json:"x"` + Y float64 `json:"y"` + VX float64 `json:"vx"` + VY float64 `json:"vy"` + State string `json:"state"` + OnGround bool `json:"on_ground"` + OnWall bool `json:"on_wall"` // Ist an einer Wand + LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz + Score int `json:"score"` // Punkte des Spielers + IsAlive bool `json:"is_alive"` // Lebt der Spieler noch? + IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus + HasDoubleJump bool `json:"has_double_jump"` // Hat Double Jump Powerup + HasGodMode bool `json:"has_godmode"` // Hat Godmode Powerup } type GameState struct { - RoomID string `json:"room_id"` - Players map[string]PlayerState `json:"players"` - Status string `json:"status"` - TimeLeft int `json:"time_left"` - WorldChunks []ActiveChunk `json:"world_chunks"` - HostID string `json:"host_id"` - ScrollX float64 `json:"scroll_x"` - CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex) + RoomID string `json:"room_id"` + Players map[string]PlayerState `json:"players"` + Status string `json:"status"` + TimeLeft int `json:"time_left"` + WorldChunks []ActiveChunk `json:"world_chunks"` + HostID string `json:"host_id"` + ScrollX float64 `json:"scroll_x"` + CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex) + CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt + MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen +} + +// MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform +type MovingPlatformSync struct { + ChunkID string `json:"chunk_id"` + ObjectIdx int `json:"object_idx"` + AssetID string `json:"asset_id"` + X float64 `json:"x"` + Y float64 `json:"y"` } // Leaderboard-Eintrag @@ -105,4 +133,22 @@ type ScoreSubmission struct { PlayerName string `json:"player_name"` PlayerCode string `json:"player_code"` Score int `json:"score"` + Name string `json:"name"` // Alternativer Name-Feld (für Kompatibilität) + Mode string `json:"mode"` // "solo" oder "coop" +} + +// Start-Request vom Client +type StartRequest struct { + RoomID string `json:"room_id"` +} + +// Leaderboard-Request vom Client +type LeaderboardRequest struct { + Mode string `json:"mode"` // "solo" oder "coop" + ResponseChannel string `json:"response_channel"` +} + +// Leaderboard-Response vom Server +type LeaderboardResponse struct { + Entries []LeaderboardEntry `json:"entries"` } diff --git a/pkg/protocol/messages.go b/pkg/protocol/messages.go index 35588f6..881975c 100644 --- a/pkg/protocol/messages.go +++ b/pkg/protocol/messages.go @@ -8,9 +8,10 @@ type InputMessage struct { // State: Wo alles ist (Server -> Client) type GameStateMessage struct { - Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16) - Score float64 `json:"score"` - Multiplier int `json:"multiplier"` + Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16) + Score float64 `json:"score"` + Multiplier int `json:"multiplier"` + MovingPlatforms []*MovingPlatformState `json:"moving_platforms"` // Bewegende Plattformen } type PlayerState struct { @@ -18,3 +19,11 @@ type PlayerState struct { X float64 `json:"x"` Y float64 `json:"y"` } + +type MovingPlatformState struct { + ChunkID string `json:"chunk_id"` + ObjectIdx int `json:"object_idx"` + AssetID string `json:"asset_id"` + X float64 `json:"x"` + Y float64 `json:"y"` +} diff --git a/pkg/server/leaderboard.go b/pkg/server/leaderboard.go index ee46fea..50753ff 100644 --- a/pkg/server/leaderboard.go +++ b/pkg/server/leaderboard.go @@ -40,48 +40,42 @@ func InitLeaderboard(redisAddr string) error { } func (lb *Leaderboard) AddScore(name, code string, score int) bool { - // Prüfe ob Spieler bereits existiert - existingScoreStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result() - if err == nil { - var existingScore int - json.Unmarshal([]byte(existingScoreStr), &existingScore) - if score <= existingScore { - return false // Neuer Score nicht besser - } - } + // Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp + timestamp := time.Now().Unix() + uniqueKey := code + "_" + time.Now().Format("20060102_150405") // Score speichern entry := game.LeaderboardEntry{ PlayerName: name, PlayerCode: code, Score: score, - Timestamp: time.Now().Unix(), + Timestamp: timestamp, } data, _ := json.Marshal(entry) - lb.rdb.HSet(lb.ctx, "leaderboard:players", code, string(data)) + lb.rdb.HSet(lb.ctx, "leaderboard:entries", uniqueKey, string(data)) - // In Sorted Set mit Score als Wert + // In Sorted Set mit Score als Wert (uniqueKey statt code!) lb.rdb.ZAdd(lb.ctx, leaderboardKey, redis.Z{ Score: float64(score), - Member: code, + Member: uniqueKey, }) - log.Printf("🏆 Leaderboard Update: %s mit %d Punkten", name, score) + log.Printf("🏆 Leaderboard: %s mit %d Punkten (Entry: %s)", name, score, uniqueKey) return true } func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry { // Hole Top 10 (höchste Scores zuerst) - codes, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result() + uniqueKeys, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result() if err != nil { log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err) return []game.LeaderboardEntry{} } entries := make([]game.LeaderboardEntry, 0) - for _, code := range codes { - dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result() + for _, uniqueKey := range uniqueKeys { + dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:entries", uniqueKey).Result() if err != nil { continue } diff --git a/pkg/server/room.go b/pkg/server/room.go index 8dc1166..5b45118 100644 --- a/pkg/server/room.go +++ b/pkg/server/room.go @@ -2,6 +2,7 @@ package server import ( "log" + "math" "math/rand" "sync" "time" @@ -12,34 +13,70 @@ import ( ) type ServerPlayer struct { - ID string - Name string - X, Y float64 - VX, VY float64 - OnGround bool - InputX float64 // -1 (Links), 0, 1 (Rechts) - LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz - Score int - IsAlive bool - IsSpectator bool + ID string + Name string + X, Y float64 + VX, VY float64 + OnGround bool + OnWall bool // Ist an einer Wand + OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht + InputX float64 // -1 (Links), 0, 1 (Rechts) + LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz + Score int + IsAlive bool + IsSpectator bool + + // Powerups + HasDoubleJump bool // Doppelsprung aktiv? + DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt? + HasGodMode bool // Godmode aktiv? + GodModeEndTime time.Time // Wann endet Godmode? +} + +type MovingPlatform struct { + ChunkID string // Welcher Chunk + ObjectIdx int // Index im Chunk + AssetID string // Asset-ID + CurrentX float64 // Aktuelle Position X (Welt-Koordinaten) + CurrentY float64 // Aktuelle Position Y + StartX float64 // Start-Position X (Welt-Koordinaten) + StartY float64 // Start-Position Y + EndX float64 // End-Position X (Welt-Koordinaten) + EndY float64 // End-Position Y + Speed float64 // Geschwindigkeit + Direction float64 // 1.0 = zu End, -1.0 = zu Start + IsActive bool // Hat die Bewegung bereits begonnen? + HitboxW float64 // Cached Hitbox + HitboxH float64 + DrawOffX float64 + DrawOffY float64 + HitboxOffX float64 + HitboxOffY float64 } 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 - CollectedCoins map[string]bool // Key: "chunkID_objectIndex" - ScoreAccum float64 // Akkumulator für Distanz-Score + ID string + NC *nats.Conn + World *game.World + Mutex sync.RWMutex + Players map[string]*ServerPlayer + ActiveChunks []game.ActiveChunk + Colliders []game.Collider + Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER" + GlobalScrollX float64 + MapEndX float64 + Countdown int + NextStart time.Time + HostID string + CollectedCoins map[string]bool // Key: "chunkID_objectIndex" + CollectedPowerups map[string]bool // Key: "chunkID_objectIndex" + ScoreAccum float64 // Akkumulator für Distanz-Score + + // Chunk-Pool für fairen Random-Spawn + ChunkPool []string // Verfügbare Chunks für nächsten Spawn + ChunkSpawnedCount map[string]int // Wie oft wurde jeder Chunk gespawnt + MovingPlatforms []*MovingPlatform // Aktive bewegende Plattformen + firstBroadcast bool // Wurde bereits geloggt? stopChan chan struct{} @@ -54,16 +91,21 @@ type Room struct { // Konstruktor func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { r := &Room{ - ID: id, - NC: nc, - World: w, - Players: make(map[string]*ServerPlayer), - Status: "LOBBY", - stopChan: make(chan struct{}), - CollectedCoins: make(map[string]bool), - pW: 40, pH: 60, // Fallback + ID: id, + NC: nc, + World: w, + Players: make(map[string]*ServerPlayer), + Status: "LOBBY", + stopChan: make(chan struct{}), + CollectedCoins: make(map[string]bool), + CollectedPowerups: make(map[string]bool), + ChunkSpawnedCount: make(map[string]int), + pW: 40, pH: 60, // Fallback } + // Initialisiere Chunk-Pool mit allen verfügbaren Chunks + r.RefillChunkPool() + // Player Werte aus Manifest laden if def, ok := w.Manifest.Assets["player"]; ok { r.pW = def.Hitbox.W @@ -214,6 +256,12 @@ func (r *Room) HandleInput(input game.ClientInput) { if p.OnGround { p.VY = -14.0 p.OnGround = false + p.DoubleJumpUsed = false // Reset double jump on ground jump + } else if p.HasDoubleJump && !p.DoubleJumpUsed { + // Double Jump in der Luft + p.VY = -14.0 + p.DoubleJumpUsed = true + log.Printf("⚡ %s verwendet Double Jump!", p.Name) } case "DOWN": p.VY = 15.0 @@ -256,6 +304,8 @@ func (r *Room) Update() { } } else if r.Status == "RUNNING" { r.GlobalScrollX += config.RunSpeed + // Bewegende Plattformen updaten + r.UpdateMovingPlatforms() } maxX := r.GlobalScrollX @@ -282,20 +332,38 @@ func (r *Room) Update() { } // X Bewegung - currentSpeed := config.RunSpeed + (p.InputX * 4.0) + // Symmetrische Geschwindigkeit: Links = Rechts + // Nach rechts: RunSpeed + 11, Nach links: RunSpeed - 11 + // Ergebnis: Rechts = 18, Links = -4 (beide gleich weit vom Scroll) + currentSpeed := config.RunSpeed + (p.InputX * 11.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" { - // Nicht blocken, sondern weiterlaufen und töten - p.X = nextX - r.KillPlayer(p) - continue + if typeX == "wall" { + // Wand getroffen - kann klettern! + p.OnWall = true + // X-Position nicht ändern (bleibt an der Wand) + } else if typeX == "obstacle" { + // Godmode prüfen + if p.HasGodMode && time.Now().Before(p.GodModeEndTime) { + // Mit Godmode - Obstacle wird zerstört, Spieler überlebt + p.X = nextX + // TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle) + log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name) + } else { + // Ohne Godmode - Spieler stirbt + p.X = nextX + r.KillPlayer(p) + continue + } + } else { + // Platform blockiert + p.OnWall = false } - // Platform blockiert } else { p.X = nextX + p.OnWall = false } // Grenzen @@ -312,28 +380,85 @@ func (r *Room) Update() { } // Y Bewegung - p.VY += config.Gravity - if p.VY > config.MaxFall { - p.VY = config.MaxFall + // An der Wand: Reduzierte Gravität + Klettern mit InputX + if p.OnWall { + // Wandrutschen (langsame Fallgeschwindigkeit) + p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand + if p.VY > 3.0 { + p.VY = 3.0 // Maximal 3.0 beim Rutschen + } + + // Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung) + if p.InputX != 0 { + p.VY = -5.0 // Kletter-Geschwindigkeit nach oben + } + } else { + // Normal: Volle Gravität + 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" { - // Nicht blocken, sondern weiterlaufen und töten + if typeY == "wall" { + // An der Wand: Nicht töten, sondern Position halten + if p.OnWall { + p.VY = 0 + } else { + // Von oben/unten gegen Wand - töten (kein Klettern in Y-Richtung) + p.Y = nextY + r.KillPlayer(p) + continue + } + } else if typeY == "obstacle" { + // Obstacle - immer töten p.Y = nextY r.KillPlayer(p) continue + } else { + // Platform blockiert + if p.VY > 0 { + p.OnGround = true + // Prüfe ob auf bewegender Plattform + platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) + p.OnMovingPlatform = platform + } + p.VY = 0 } - // Platform blockiert - if p.VY > 0 { - p.OnGround = true - } - p.VY = 0 } else { p.Y += p.VY p.OnGround = false + p.OnMovingPlatform = nil + } + + // Spieler bewegt sich mit Plattform mit + if p.OnMovingPlatform != nil && p.OnGround { + // Berechne Plattform-Geschwindigkeit + mp := p.OnMovingPlatform + var targetX, targetY float64 + if mp.Direction > 0 { + targetX, targetY = mp.EndX, mp.EndY + } else { + targetX, targetY = mp.StartX, mp.StartY + } + + dx := targetX - mp.CurrentX + dy := targetY - mp.CurrentY + dist := math.Sqrt(dx*dx + dy*dy) + + if dist > 0.1 { + movePerTick := mp.Speed / 60.0 + platformVelX := (dx / dist) * movePerTick + platformVelY := (dy / dist) * movePerTick + + // Übertrage Plattform-Geschwindigkeit auf Spieler + p.X += platformVelX + p.Y += platformVelY + } } if p.Y > 1000 { @@ -342,6 +467,15 @@ func (r *Room) Update() { // Coin Kollision prüfen r.CheckCoinCollision(p) + + // Powerup Kollision prüfen + r.CheckPowerupCollision(p) + + // Godmode Timeout prüfen + if p.HasGodMode && time.Now().After(p.GodModeEndTime) { + p.HasGodMode = false + log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name) + } } // 2b. Distanz-Score updaten @@ -380,6 +514,7 @@ func (r *Room) Update() { func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} + // 1. Statische Colliders (Chunks) for _, c := range r.Colliders { if game.CheckRectCollision(playerRect, c.Rect) { log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)", @@ -390,9 +525,44 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { } } + // 2. Bewegende Plattformen (dynamische Colliders) + for _, mp := range r.MovingPlatforms { + // Berechne Plattform-Hitbox an aktueller Position + mpRect := game.Rect{ + OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX, + OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY, + W: mp.HitboxW, + H: mp.HitboxH, + } + + if game.CheckRectCollision(playerRect, mpRect) { + return true, "platform" + } + } + return false, "" } +// CheckMovingPlatformLanding prüft ob Spieler auf einer bewegenden Plattform landet +func (r *Room) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform { + playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} + + for _, mp := range r.MovingPlatforms { + mpRect := game.Rect{ + OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX, + OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY, + W: mp.HitboxW, + H: mp.HitboxH, + } + + if game.CheckRectCollision(playerRect, mpRect) { + return mp + } + } + + return nil +} + func (r *Room) UpdateMapLogic(maxX float64) { if r.Status != "RUNNING" { return @@ -413,26 +583,178 @@ func (r *Room) UpdateMapLogic(maxX float64) { chunkWidth := float64(chunkDef.Width * config.TileSize) if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 { + // Lösche alle Coins dieses Chunks aus CollectedCoins + r.ClearChunkCoins(firstChunk.ChunkID) + + // Lösche alle Powerups dieses Chunks + r.ClearChunkPowerups(firstChunk.ChunkID) + + // Entferne bewegende Plattformen dieses Chunks + r.RemoveMovingPlatforms(firstChunk.ChunkID) + r.ActiveChunks = r.ActiveChunks[1:] r.Colliders = r.World.GenerateColliders(r.ActiveChunks) + log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID) } } } -func (r *Room) SpawnNextChunk() { - keys := make([]string, 0, len(r.World.ChunkLibrary)) - for k := range r.World.ChunkLibrary { - keys = append(keys, k) +// ClearChunkCoins löscht alle eingesammelten Coins eines Chunks +func (r *Room) ClearChunkCoins(chunkID string) { + prefix := chunkID + "_" + coinsCleared := 0 + for key := range r.CollectedCoins { + if len(key) >= len(prefix) && key[:len(prefix)] == prefix { + delete(r.CollectedCoins, key) + coinsCleared++ + } + } + if coinsCleared > 0 { + log.Printf("💰 %d Coins von Chunk %s zurückgesetzt", coinsCleared, chunkID) + } +} + +// InitMovingPlatforms initialisiert bewegende Plattformen für einen Chunk +func (r *Room) InitMovingPlatforms(chunkID string, chunkWorldX float64) { + chunkDef, exists := r.World.ChunkLibrary[chunkID] + if !exists { + return } - if len(keys) > 0 { - // Zufälligen Chunk wählen - randomID := keys[rand.Intn(len(keys))] - chunkDef := r.World.ChunkLibrary[randomID] + for objIdx, obj := range chunkDef.Objects { + if obj.MovingPlatform != nil { + assetDef, ok := r.World.Manifest.Assets[obj.AssetID] + if !ok || assetDef.Type != "platform" { + continue + } + mpData := obj.MovingPlatform + platform := &MovingPlatform{ + ChunkID: chunkID, + ObjectIdx: objIdx, + AssetID: obj.AssetID, + StartX: chunkWorldX + mpData.StartX, + StartY: mpData.StartY, + EndX: chunkWorldX + mpData.EndX, + EndY: mpData.EndY, + Speed: mpData.Speed, + Direction: 1.0, // Start bei StartX, bewege zu EndX + HitboxW: assetDef.Hitbox.W, + HitboxH: assetDef.Hitbox.H, + DrawOffX: assetDef.DrawOffX, + DrawOffY: assetDef.DrawOffY, + HitboxOffX: assetDef.Hitbox.OffsetX, + HitboxOffY: assetDef.Hitbox.OffsetY, + } + platform.CurrentX = platform.StartX + platform.CurrentY = platform.StartY + + r.MovingPlatforms = append(r.MovingPlatforms, platform) + log.Printf("🔄 Bewegende Plattform initialisiert: %s in Chunk %s", obj.AssetID, chunkID) + } + } +} + +// RemoveMovingPlatforms entfernt alle Plattformen eines Chunks +func (r *Room) RemoveMovingPlatforms(chunkID string) { + newPlatforms := make([]*MovingPlatform, 0) + removedCount := 0 + for _, p := range r.MovingPlatforms { + if p.ChunkID != chunkID { + newPlatforms = append(newPlatforms, p) + } else { + removedCount++ + } + } + r.MovingPlatforms = newPlatforms + if removedCount > 0 { + log.Printf("🗑️ %d bewegende Plattformen von Chunk %s entfernt", removedCount, chunkID) + } +} + +// UpdateMovingPlatforms bewegt alle aktiven Plattformen +func (r *Room) UpdateMovingPlatforms() { + // Sichtbarer Bereich: GlobalScrollX bis GlobalScrollX + 1400 + // Aktivierung bei 3/4: GlobalScrollX + (1400 * 3/4) = GlobalScrollX + 1050 + activationPoint := r.GlobalScrollX + 1050 + + for _, p := range r.MovingPlatforms { + // Prüfe ob Plattform den Aktivierungspunkt erreicht hat + if !p.IsActive { + // Aktiviere Plattform, wenn sie bei 3/4 des Bildschirms ist + if p.CurrentX <= activationPoint { + p.IsActive = true + log.Printf("▶️ Plattform aktiviert: %s (X=%.0f)", p.ChunkID, p.CurrentX) + } else { + // Noch nicht weit genug gescrollt, nicht bewegen + continue + } + } + + // Bewegung berechnen (Speed pro Sekunde, bei 60 FPS = Speed/60) + movePerTick := p.Speed / 60.0 + + // Bewegungsvektor von CurrentPos zu Ziel + var targetX, targetY float64 + if p.Direction > 0 { + targetX, targetY = p.EndX, p.EndY + } else { + targetX, targetY = p.StartX, p.StartY + } + + dx := targetX - p.CurrentX + dy := targetY - p.CurrentY + dist := math.Sqrt(dx*dx + dy*dy) + + if dist < movePerTick { + // Ziel erreicht, umkehren + p.CurrentX = targetX + p.CurrentY = targetY + p.Direction *= -1.0 + } else { + // Weiterbewegen + p.CurrentX += (dx / dist) * movePerTick + p.CurrentY += (dy / dist) * movePerTick + } + } +} + +// RefillChunkPool füllt den Pool mit allen verfügbaren Chunks +func (r *Room) RefillChunkPool() { + r.ChunkPool = make([]string, 0, len(r.World.ChunkLibrary)) + for chunkID := range r.World.ChunkLibrary { + if chunkID != "start" { // Start-Chunk nicht in Pool + r.ChunkPool = append(r.ChunkPool, chunkID) + } + } + // Mische Pool für zufällige Reihenfolge + rand.Shuffle(len(r.ChunkPool), func(i, j int) { + r.ChunkPool[i], r.ChunkPool[j] = r.ChunkPool[j], r.ChunkPool[i] + }) + log.Printf("🔄 Chunk-Pool neu gefüllt: %d Chunks", len(r.ChunkPool)) +} + +func (r *Room) SpawnNextChunk() { + // Pool leer? Nachfüllen! + if len(r.ChunkPool) == 0 { + r.RefillChunkPool() + } + + if len(r.ChunkPool) > 0 { + // Nimm ersten Chunk aus Pool (bereits gemischt) + randomID := r.ChunkPool[0] + r.ChunkPool = r.ChunkPool[1:] // Entferne aus Pool + + 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) + + // Initialisiere bewegende Plattformen für diesen Chunk + r.InitMovingPlatforms(randomID, newChunk.X) + + r.ChunkSpawnedCount[randomID]++ + log.Printf("🎲 Chunk gespawnt: %s (Total: %d mal, Pool: %d übrig)", randomID, r.ChunkSpawnedCount[randomID], len(r.ChunkPool)) } else { // Fallback, falls keine Chunks da sind r.MapEndX += 1280 @@ -446,35 +768,52 @@ func (r *Room) Broadcast() { defer r.Mutex.RUnlock() state := game.GameState{ - RoomID: r.ID, - Players: make(map[string]game.PlayerState), - Status: r.Status, - TimeLeft: r.Countdown, - WorldChunks: r.ActiveChunks, - HostID: r.HostID, - ScrollX: r.GlobalScrollX, - CollectedCoins: r.CollectedCoins, + RoomID: r.ID, + Players: make(map[string]game.PlayerState), + Status: r.Status, + TimeLeft: r.Countdown, + WorldChunks: r.ActiveChunks, + HostID: r.HostID, + ScrollX: r.GlobalScrollX, + CollectedCoins: r.CollectedCoins, + CollectedPowerups: r.CollectedPowerups, + MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)), } for id, p := range r.Players { state.Players[id] = game.PlayerState{ - ID: id, - Name: p.Name, - X: p.X, - Y: p.Y, - VX: p.VX, - VY: p.VY, - OnGround: p.OnGround, - LastInputSeq: p.LastInputSeq, - Score: p.Score, - IsAlive: p.IsAlive, - IsSpectator: p.IsSpectator, + ID: id, + Name: p.Name, + X: p.X, + Y: p.Y, + VX: p.VX, + VY: p.VY, + OnGround: p.OnGround, + OnWall: p.OnWall, + LastInputSeq: p.LastInputSeq, + Score: p.Score, + IsAlive: p.IsAlive, + IsSpectator: p.IsSpectator, + HasDoubleJump: p.HasDoubleJump, + HasGodMode: p.HasGodMode, } } + // Bewegende Plattformen synchronisieren + for _, mp := range r.MovingPlatforms { + state.MovingPlatforms = append(state.MovingPlatforms, game.MovingPlatformSync{ + ChunkID: mp.ChunkID, + ObjectIdx: mp.ObjectIdx, + AssetID: mp.AssetID, + X: mp.CurrentX, + Y: mp.CurrentY, + }) + } + // DEBUG: Ersten Broadcast loggen (nur beim ersten Mal) - if len(r.Players) > 0 && r.Status == "LOBBY" { + if !r.firstBroadcast && len(r.Players) > 0 && r.Status == "LOBBY" { log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status) + r.firstBroadcast = true } // Senden an raum-spezifischen Channel: "game.update." @@ -490,3 +829,18 @@ func (r *Room) RemovePlayer(id string) { delete(r.Players, id) log.Printf("➖ Player %s left room %s", id, r.ID) } + +// ClearChunkPowerups löscht alle eingesammelten Powerups eines Chunks +func (r *Room) ClearChunkPowerups(chunkID string) { + prefix := chunkID + "_" + powerupsCleared := 0 + for key := range r.CollectedPowerups { + if len(key) >= len(prefix) && key[:len(prefix)] == prefix { + delete(r.CollectedPowerups, key) + powerupsCleared++ + } + } + if powerupsCleared > 0 { + log.Printf("⚡ %d Powerups von Chunk %s zurückgesetzt", powerupsCleared, chunkID) + } +} diff --git a/pkg/server/scoring.go b/pkg/server/scoring.go index 082cca9..95fc9d4 100644 --- a/pkg/server/scoring.go +++ b/pkg/server/scoring.go @@ -3,6 +3,7 @@ package server import ( "fmt" "log" + "time" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" ) @@ -47,10 +48,10 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) { continue } - // Coin-Hitbox + // Coin-Hitbox (muss DrawOffX/Y einbeziehen wie bei Obstacles!) coinHitbox := game.Rect{ - OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX, - OffsetY: obj.Y + assetDef.Hitbox.OffsetY, + OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX, + OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY, W: assetDef.Hitbox.W, H: assetDef.Hitbox.H, } @@ -66,39 +67,93 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) { } } +// CheckPowerupCollision prüft ob Spieler Powerups einsammelt +func (r *Room) CheckPowerupCollision(p *ServerPlayer) { + if !p.IsAlive || p.IsSpectator { + return + } + + playerHitbox := game.Rect{ + OffsetX: p.X + r.pDrawOffX + r.pHitboxOffX, + OffsetY: p.Y + r.pDrawOffY + r.pHitboxOffY, + W: r.pW, + H: r.pH, + } + + // Durch alle aktiven Chunks iterieren + for _, activeChunk := range r.ActiveChunks { + chunkDef, exists := r.World.ChunkLibrary[activeChunk.ChunkID] + if !exists { + continue + } + + // Durch alle Objekte im Chunk + for objIdx, obj := range chunkDef.Objects { + assetDef, ok := r.World.Manifest.Assets[obj.AssetID] + if !ok { + continue + } + + // Nur Powerups prüfen + if assetDef.Type != "powerup" { + continue + } + + // Eindeutiger Key für dieses Powerup + powerupKey := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx) + + // Wurde bereits eingesammelt? + if r.CollectedPowerups[powerupKey] { + continue + } + + // Powerup-Hitbox + powerupHitbox := game.Rect{ + OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX, + OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY, + W: assetDef.Hitbox.W, + H: assetDef.Hitbox.H, + } + + // Kollision? + if game.CheckRectCollision(playerHitbox, powerupHitbox) { + // Powerup einsammeln! + r.CollectedPowerups[powerupKey] = true + + // Powerup-Effekt anwenden + switch obj.AssetID { + case "jumpboost": + p.HasDoubleJump = true + p.DoubleJumpUsed = false + log.Printf("⚡ %s hat Double Jump erhalten!", p.Name) + + case "godmode": + p.HasGodMode = true + p.GodModeEndTime = time.Now().Add(10 * time.Second) + log.Printf("🛡️ %s hat Godmode erhalten! (10 Sekunden)", p.Name) + } + } + } + } +} + // UpdateDistanceScore aktualisiert Distanz-basierte Punkte func (r *Room) UpdateDistanceScore() { if r.Status != "RUNNING" { return } - // Anzahl lebender Spieler zählen - aliveCount := 0 + // Jeder Spieler bekommt Punkte basierend auf seiner eigenen Distanz + // Punkte = (X-Position / TileSize) = Distanz in Tiles for _, p := range r.Players { if p.IsAlive && !p.IsSpectator { - aliveCount++ - } - } + // Berechne Score basierend auf X-Position + // 1 Punkt pro Tile (64px) + newScore := int(p.X / 64.0) - if aliveCount == 0 { - return - } - - // Multiplier = Anzahl lebender Spieler - multiplier := float64(aliveCount) - - // Akkumulator erhöhen: multiplier Punkte pro Sekunde - // Bei 60 FPS: multiplier / 60.0 Punkte pro Tick - r.ScoreAccum += multiplier / 60.0 - - // Wenn Akkumulator >= 1.0, Punkte vergeben - if r.ScoreAccum >= 1.0 { - pointsToAdd := int(r.ScoreAccum) - r.ScoreAccum -= float64(pointsToAdd) - - for _, p := range r.Players { - if p.IsAlive && !p.IsSpectator { - p.Score += pointsToAdd + // Nur updaten wenn höher als aktueller Score + if newScore > p.Score { + p.Score = newScore } } } diff --git a/player_code.txt b/player_code.txt new file mode 100644 index 0000000..077aa9b --- /dev/null +++ b/player_code.txt @@ -0,0 +1 @@ +49badef83664a3d83cb4ec6ab0853c9e \ No newline at end of file