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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
ERWISCHT!
+
+
+ Dein Score: 0
+
+
+
+ Lade Leaderboard...
+
+
+
+
+
+
+
+
+
+
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