Private
Public Access
1
0

Add platform-specific implementations for assets, audio, WebSocket, and rendering on Desktop and WebAssembly platforms. Introduce embedded assets for WebAssembly and native file handling for Desktop. Add platform-specific chunk loading and game state synchronization.

This commit is contained in:
Sebastian Unterschütz
2026-01-04 01:25:04 +01:00
parent 85d697df19
commit 3232ee7c2f
86 changed files with 4931 additions and 486 deletions

17
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="0@localhost" uuid="5887da40-2735-46aa-8690-99c28ab9eb2e">
<driver-ref>redis</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
<jdbc-url>jdbc:redis://localhost:6379/0</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

6
.idea/data_source_mapping.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="5887da40-2735-46aa-8690-99c28ab9eb2e" />
</component>
</project>

0
.idea/queries/Query.sql generated Normal file
View File

6
.idea/sqldialects.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="Redis" />
</component>
</project>

BIN
assets_raw/godmode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

BIN
assets_raw/jump0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

BIN
assets_raw/jump1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

BIN
assets_raw/jumpboost.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 B

BIN
assets_raw/playernew.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

BIN
bin/builder Executable file

Binary file not shown.

BIN
bin/client Executable file

Binary file not shown.

BIN
bin/levelbuilder Executable file

Binary file not shown.

BIN
bin/server Executable file

Binary file not shown.

View File

@@ -56,7 +56,7 @@ var (
ColPlayerRef = color.RGBA{0, 255, 255, 100} 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 --- // --- HILFSFUNKTIONEN ---
@@ -92,6 +92,58 @@ func generateBrickTexture(w, h int) *ebiten.Image {
return img 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 { func saveImageToDisk(img *ebiten.Image, filename string) error {
stdImg := img.SubImage(img.Bounds()) stdImg := img.SubImage(img.Bounds())
assetDir := filepath.Dir(OutFile) assetDir := filepath.Dir(OutFile)
@@ -282,6 +334,33 @@ func (e *Editor) CreatePlatform() {
e.selectedID = id 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 { func (e *Editor) Update() error {
if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" { if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" {
e.Save() e.Save()
@@ -335,9 +414,13 @@ func (e *Editor) Update() error {
currentY += float64(LineHeight) currentY += float64(LineHeight)
} }
if my > CanvasHeight-40 { // Button-Bereich unten
if my > CanvasHeight-75 && my <= CanvasHeight-40 {
e.CreatePlatform() e.CreatePlatform()
} }
if my > CanvasHeight-40 {
e.CreateWall()
}
} }
return nil return nil
} }
@@ -500,10 +583,15 @@ func (e *Editor) Draw(screen *ebiten.Image) {
// --- 1. LISTE LINKS --- // --- 1. LISTE LINKS ---
vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false) vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false)
// Button Neu // Button Platform
btnRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10) btnPlatRect := image.Rect(10, CanvasHeight-70, WidthList-10, CanvasHeight-45)
vector.DrawFilledRect(screen, float32(btnRect.Min.X), float32(btnRect.Min.Y), float32(btnRect.Dx()), float32(btnRect.Dy()), ColHighlight, false) 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-18, color.RGBA{255, 255, 255, 255}) 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 // SCROLL BEREICH
startY := 40.0 - e.listScroll 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 // Helper Funktion zum Zeichnen von Listeneinträgen mit Bild
drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) { 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) // Bild Vorschau (Thumbnail)
if img != nil { if img != nil {
// Skalierung berechnen (max 28px hoch/breit) // Skalierung berechnen (max 28px hoch/breit)

View File

@@ -90,6 +90,24 @@
"Type": "" "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": { "h-l": {
"ID": "h-l", "ID": "h-l",
"Type": "obstacle", "Type": "obstacle",
@@ -108,6 +126,78 @@
"Type": "" "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": { "k-l-monitor": {
"ID": "k-l-monitor", "ID": "k-l-monitor",
"Type": "obstacle", "Type": "obstacle",
@@ -126,6 +216,24 @@
"Type": "" "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": { "pc-trash": {
"ID": "pc-trash", "ID": "pc-trash",
"Type": "obstacle", "Type": "obstacle",
@@ -165,18 +273,90 @@
"player": { "player": {
"ID": "player", "ID": "player",
"Type": "obstacle", "Type": "obstacle",
"Filename": "player.png", "Filename": "playernew.png",
"Scale": 7, "Scale": 0.08,
"ProcWidth": 0, "ProcWidth": 0,
"ProcHeight": 0, "ProcHeight": 0,
"DrawOffX": -53, "DrawOffX": -56,
"DrawOffY": -216, "DrawOffY": -231,
"Color": {}, "Color": {},
"Hitbox": { "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, "OffsetY": 12,
"W": 108, "W": 55,
"H": 203, "H": 113,
"Type": "" "Type": ""
} }
} }

View File

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View File

@@ -31,6 +31,11 @@
"AssetID": "pc-trash", "AssetID": "pc-trash",
"X": 1960, "X": 1960,
"Y": 533 "Y": 533
},
{
"AssetID": "coin",
"X": 1024,
"Y": 412
} }
] ]
} }

View File

@@ -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
}
]
}

View File

@@ -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
}
]
}

View File

@@ -0,0 +1,16 @@
{
"ID": "chunk_04",
"Width": 50,
"Objects": [
{
"AssetID": "godmode",
"X": 569,
"Y": 535
},
{
"AssetID": "jumpboost",
"X": 680,
"Y": 538
}
]
}

BIN
cmd/client/assets/front.ttf Normal file

Binary file not shown.

BIN
cmd/client/assets/game.mp3 Normal file

Binary file not shown.

BIN
cmd/client/assets/game.wav Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

BIN
cmd/client/assets/jump.wav Normal file

Binary file not shown.

BIN
cmd/client/assets/jump0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

BIN
cmd/client/assets/jump1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -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)
}

53
cmd/client/assets_wasm.go Normal file
View File

@@ -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)
}

237
cmd/client/audio.go Normal file
View File

@@ -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
}

View File

@@ -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))
}
}
}

55
cmd/client/chunks_wasm.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

22
cmd/client/draw_native.go Normal file
View File

@@ -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)
}

28
cmd/client/draw_wasm.go Normal file
View File

@@ -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})
}

View File

@@ -19,16 +19,26 @@ import (
// --- INPUT & UPDATE LOGIC --- // --- INPUT & UPDATE LOGIC ---
func (g *Game) UpdateGame() { 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) keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight) keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown) keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp) 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() g.handleTouchInput()
// --- 3. INPUT STATE ERSTELLEN --- // --- 4. INPUT STATE ERSTELLEN ---
joyDir := 0.0 joyDir := 0.0
if g.joyActive { if g.joyActive {
diffX := g.joyStickX - g.joyBaseX diffX := g.joyStickX - g.joyBaseX
@@ -64,6 +74,34 @@ func (g *Game) UpdateGame() {
// Lokale Physik sofort anwenden (Prediction) // Lokale Physik sofort anwenden (Prediction)
g.ApplyInput(input) 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() g.predictionMutex.Unlock()
// Input an Server senden // Input an Server senden
@@ -72,10 +110,8 @@ func (g *Game) UpdateGame() {
// --- 5. KAMERA LOGIK --- // --- 5. KAMERA LOGIK ---
g.stateMutex.Lock() g.stateMutex.Lock()
defer g.stateMutex.Unlock()
// Wir folgen strikt dem Server-Scroll.
targetCam := g.gameState.ScrollX targetCam := g.gameState.ScrollX
g.stateMutex.Unlock()
// Negative Kamera verhindern // Negative Kamera verhindern
if targetCam < 0 { if targetCam < 0 {
@@ -84,6 +120,12 @@ func (g *Game) UpdateGame() {
// Kamera hart setzen // Kamera hart setzen
g.camX = targetCam 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 // Verarbeitet Touch-Eingaben für Joystick und Buttons
@@ -178,6 +220,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
} }
g.stateMutex.Unlock() 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) g.DrawGameOverLeaderboard(screen, myScore)
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird 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() g.stateMutex.Unlock()
// 1. Hintergrund & Boden // 1. Hintergrund (wechselt alle 5000 Punkte)
screen.Fill(ColSky) backgroundID := "background"
if myScore >= 10000 {
backgroundID = "background2"
} else if myScore >= 5000 {
backgroundID = "background1"
}
floorH := float32(ScreenHeight - RefFloorY) // Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
vector.DrawFilledRect(screen, 0, float32(RefFloorY), float32(ScreenWidth), floorH, ColGrass, false) if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
vector.DrawFilledRect(screen, 0, float32(RefFloorY)+20, float32(ScreenWidth), floorH-20, ColDirt, false) 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 // State Locken für Datenzugriff
g.stateMutex.Lock() g.stateMutex.Lock()
@@ -218,12 +296,38 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
// Start-Chunk hat absichtlich keine Objekte // 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 // Asset zeichnen
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y) 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 // 3. Spieler
// MyID ohne Lock holen (wir haben bereits den stateMutex) // MyID ohne Lock holen (wir haben bereits den stateMutex)
myID := "" myID := ""
@@ -237,12 +341,33 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
for id, p := range g.gameState.Players { for id, p := range g.gameState.Players {
// Für lokalen Spieler: Verwende vorhergesagte Position // Für lokalen Spieler: Verwende vorhergesagte Position
posX, posY := p.X, p.Y posX, posY := p.X, p.Y
vy := p.VY
onGround := p.OnGround
if id == myID && g.connected { if id == myID && g.connected {
posX = g.predictedX posX = g.predictedX
posY = g.predictedY 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 Tag
name := p.Name 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) 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}) 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 // 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
baseCol := color.RGBA{255, 255, 255, 50} if !g.keyboardUsed {
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, true) // A) Joystick Base (dunkelgrau und durchsichtig)
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{255, 255, 255, 100}, true) 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 // B) Joystick Knob (dunkelgrau, außer wenn aktiv)
knobCol := color.RGBA{255, 255, 255, 150} knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
if g.joyActive { if g.joyActive {
knobCol = color.RGBA{100, 255, 100, 200} 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) // 8. DEBUG INFO (Oben Links)
jumpX := float32(ScreenWidth - 150)
jumpY := float32(ScreenHeight - 150)
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, true)
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, true)
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.White)
// 7. DEBUG INFO (Oben Links)
myPosStr := "N/A" myPosStr := "N/A"
for _, p := range g.gameState.Players { for _, p := range g.gameState.Players {
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y) myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)

View File

@@ -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
}

160
cmd/client/ground_system.go Normal file
View File

@@ -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)
}
}
}
}

View File

@@ -1,22 +1,18 @@
package main package main
import ( import (
"encoding/json"
"fmt" "fmt"
"image/color" "image/color"
_ "image/png" _ "image/jpeg" // JPEG-Decoder
"io/ioutil" _ "image/png" // PNG-Decoder
"log" "log"
mrand "math/rand" mrand "math/rand"
"path/filepath"
"runtime"
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
@@ -28,8 +24,8 @@ import (
// --- KONFIGURATION --- // --- KONFIGURATION ---
const ( const (
ScreenWidth = 1280 ScreenWidth = 1280
ScreenHeight = 720 ScreenHeight = 720
StateMenu = 0 StateMenu = 0
StateLobby = 1 StateLobby = 1
StateGame = 2 StateGame = 2
@@ -59,6 +55,7 @@ type InputState struct {
type Game struct { type Game struct {
appState int appState int
conn *nats.EncodedConn conn *nats.EncodedConn
wsConn *wsConn // WebSocket für WASM
gameState game.GameState gameState game.GameState
stateMutex sync.Mutex stateMutex sync.Mutex
connected bool connected bool
@@ -95,6 +92,21 @@ type Game struct {
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
predictionMutex sync.Mutex // Mutex für pendingInputs 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 // Kamera
camX float64 camX float64
@@ -104,6 +116,7 @@ type Game struct {
joyActive bool joyActive bool
joyTouchID ebiten.TouchID joyTouchID ebiten.TouchID
btnJumpActive bool btnJumpActive bool
keyboardUsed bool // Wurde Tastatur benutzt?
} }
func NewGame() *Game { func NewGame() *Game {
@@ -114,67 +127,35 @@ func NewGame() *Game {
gameState: game.GameState{Players: make(map[string]game.PlayerState)}, gameState: game.GameState{Players: make(map[string]game.PlayerState)},
playerName: "Student", playerName: "Student",
activeField: "name", activeField: "",
gameMode: "", gameMode: "",
pendingInputs: make(map[uint32]InputState), pendingInputs: make(map[uint32]InputState),
leaderboard: make([]game.LeaderboardEntry, 0), 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, joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150, joyStickX: 150, joyStickY: ScreenHeight - 150,
} }
g.loadAssets() g.loadAssets()
g.loadOrCreatePlayerCode() g.loadOrCreatePlayerCode()
// Gespeicherten Namen laden
savedName := g.loadPlayerName()
if savedName != "" {
g.playerName = savedName
}
return g return g
} }
func (g *Game) loadAssets() { // loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert
// 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)
}
// --- UPDATE --- // --- UPDATE ---
func (g *Game) Update() error { 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 { switch g.appState {
case StateMenu: case StateMenu:
g.updateMenu() g.updateMenu()
@@ -236,6 +228,30 @@ func (g *Game) Update() error {
func (g *Game) updateMenu() { func (g *Game) updateMenu() {
g.handleMenuInput() 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 // Leaderboard Button
lbBtnW, lbBtnH := 200, 50 lbBtnW, lbBtnH := 200, 50
lbBtnX := ScreenWidth - lbBtnW - 20 lbBtnX := ScreenWidth - lbBtnW - 20
@@ -324,7 +340,7 @@ func (g *Game) updateLobby() {
if isHit(btnX, btnY, btnW, btnH) { if isHit(btnX, btnY, btnW, btnH) {
// START GAME // START GAME
g.SendCommand("START") g.sendStartRequest()
} }
} }
@@ -347,18 +363,27 @@ func (g *Game) updateLobby() {
// --- DRAW --- // --- DRAW ---
func (g *Game) Draw(screen *ebiten.Image) { 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 { switch g.appState {
case StateMenu: case StateMenu:
g.DrawMenu(screen) g.drawMenu(screen)
case StateLobby: case StateLobby:
g.DrawLobby(screen) g.drawLobby(screen)
case StateGame: case StateGame:
g.DrawGame(screen) g.DrawGame(screen)
case StateLeaderboard: 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) { func (g *Game) DrawMenu(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255}) 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) 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, "🏆 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) { func (g *Game) DrawLobby(screen *ebiten.Image) {
@@ -580,6 +617,10 @@ func (g *Game) handleMenuInput() {
} }
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
// Namen speichern wenn geändert
if g.activeField == "name" && g.playerName != "" {
g.savePlayerName(g.playerName)
}
g.activeField = "" g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
if len(*target) > 0 { if len(*target) > 0 {
@@ -614,7 +655,7 @@ func (g *Game) handleGameOverInput() {
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) { if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
if g.teamName != "" { if g.teamName != "" {
g.submitTeamScore() g.submitScore() // submitScore behandelt jetzt beide Modi
} }
return return
} }
@@ -623,7 +664,7 @@ func (g *Game) handleGameOverInput() {
if g.activeField == "teamname" { if g.activeField == "teamname" {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) { if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
if g.teamName != "" { if g.teamName != "" {
g.submitTeamScore() g.submitScore() // submitScore behandelt jetzt beide Modi
} }
g.activeField = "" g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) { } else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
@@ -650,68 +691,6 @@ func generateRoomCode() string {
} }
func (g *Game) connectAndStart() { 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 // Initiale predicted Position
g.predictedX = 100 g.predictedX = 100
g.predictedY = 200 g.predictedY = 200
@@ -719,7 +698,8 @@ func (g *Game) connectAndStart() {
g.predictedVY = 0 g.predictedVY = 0
g.predictedGround = false g.predictedGround = false
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...") // Verbindung über plattformspezifische Implementierung
g.connectToServer()
} }
func (g *Game) SendCommand(cmdType string) { func (g *Game) SendCommand(cmdType string) {
@@ -727,7 +707,7 @@ func (g *Game) SendCommand(cmdType string) {
return return
} }
myID := g.getMyPlayerID() 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) { func (g *Game) SendInputWithSequence(input InputState) {
@@ -739,28 +719,30 @@ func (g *Game) SendInputWithSequence(input InputState) {
// Inputs als einzelne Commands senden // Inputs als einzelne Commands senden
if input.Left { if input.Left {
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "LEFT_DOWN", Type: "LEFT_DOWN",
Sequence: input.Sequence, Sequence: input.Sequence,
}) })
} }
if input.Right { if input.Right {
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "RIGHT_DOWN", Type: "RIGHT_DOWN",
Sequence: input.Sequence, Sequence: input.Sequence,
}) })
} }
if input.Jump { if input.Jump {
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "JUMP", Type: "JUMP",
Sequence: input.Sequence, Sequence: input.Sequence,
}) })
// Jump Sound abspielen
g.audio.PlayJump()
} }
if input.Down { if input.Down {
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "DOWN", Type: "DOWN",
Sequence: input.Sequence, Sequence: input.Sequence,
@@ -769,12 +751,12 @@ func (g *Game) SendInputWithSequence(input InputState) {
// Wenn weder Links noch Rechts, sende STOP // Wenn weder Links noch Rechts, sende STOP
if !input.Left && !input.Right { if !input.Left && !input.Right {
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "LEFT_UP", Type: "LEFT_UP",
Sequence: input.Sequence, Sequence: input.Sequence,
}) })
g.conn.Publish("game.input", game.ClientInput{ g.publishInput(game.ClientInput{
PlayerID: myID, PlayerID: myID,
Type: "RIGHT_UP", Type: "RIGHT_UP",
Sequence: input.Sequence, Sequence: input.Sequence,
@@ -794,113 +776,8 @@ func (g *Game) getMyPlayerID() string {
return g.playerName return g.playerName
} }
// loadOrCreatePlayerCode wird in storage_*.go implementiert (platform-specific) // submitScore, requestLeaderboard, connectForLeaderboard
// sind in connection_native.go und connection_wasm.go definiert
// 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()
}
func (g *Game) updateLeaderboard() { func (g *Game) updateLeaderboard() {
// Back Button (oben links) - Touch Support // 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}) text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
} }
func main() { // main() ist jetzt in main_wasm.go und main_native.go definiert
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher") // drawVolumeSlider zeichnet einen Volume-Slider
ebiten.SetTPS(60) // Tick Per Second auf 60 setzen func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) {
ebiten.SetVsyncEnabled(true) // VSync aktivieren // Hintergrund
if err := ebiten.RunGame(NewGame()); err != nil { vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false)
log.Fatal(err) 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)
} }

20
cmd/client/main_native.go Normal file
View File

@@ -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)
}
}

33
cmd/client/main_wasm.go Normal file
View File

@@ -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)
}
}

336
cmd/client/particles.go Normal file
View File

@@ -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
}

View File

@@ -63,20 +63,60 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
} }
} }
// Setze vorhergesagte Position auf Server-Position // Temporäre Position für Replay
g.predictedX = serverState.X replayX := serverState.X
g.predictedY = serverState.Y replayY := serverState.Y
g.predictedVX = serverState.VX replayVX := serverState.VX
g.predictedVY = serverState.VY replayVY := serverState.VY
g.predictedGround = serverState.OnGround replayGround := serverState.OnGround
// Replay alle noch nicht bestätigten Inputs // Replay alle noch nicht bestätigten Inputs
// (Sortiert nach Sequenz)
if len(g.pendingInputs) > 0 { if len(g.pendingInputs) > 0 {
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ { for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
if input, ok := g.pendingInputs[seq]; ok { 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) 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
} }

View File

@@ -8,6 +8,7 @@ import (
"encoding/hex" "encoding/hex"
"io/ioutil" "io/ioutil"
"log" "log"
"strings"
) )
// loadOrCreatePlayerCode lädt oder erstellt einen eindeutigen Spieler-Code (Desktop Version) // 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) 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)
}
}

View File

@@ -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)
}
}
}

119
cmd/client/wasm_bridge.go Normal file
View File

@@ -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)
}
}

89
cmd/client/web/README.md Normal file
View File

@@ -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
```

1
cmd/client/web/assets Symbolic link
View File

@@ -0,0 +1 @@
../assets

341
cmd/client/web/game.js Normal file
View File

@@ -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 = '<div class="spinner"></div><p>Fehler beim Laden: ' + err.message + '</p>';
}
}
// 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 = '<div style="text-align:center; padding:20px;">Lädt Leaderboard...</div>';
// Request leaderboard from WASM
if (window.requestLeaderboard) {
window.requestLeaderboard();
}
// Fallback timeout
setTimeout(() => {
if (list.innerHTML.includes('Lädt')) {
list.innerHTML = '<div style="text-align:center; padding:20px; color:#888;">Keine Daten verfügbar</div>';
}
}, 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 = '<div style="text-align:center; padding:20px; color:#888;">Noch keine Einträge</div>';
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 `
<div class="leaderboard-item">
<div class="leaderboard-rank">${medal}</div>
<div class="leaderboard-name">${entry.player_name}</div>
<div class="leaderboard-score">${entry.score}</div>
</div>
`;
}).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');

147
cmd/client/web/index.html Normal file
View File

@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Escape From Teacher</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<button id="mute-btn" onclick="toggleAudio()">🔊</button>
<div id="rotate-overlay">
<div class="icon">📱↻</div>
<p>Bitte Gerät drehen!</p>
<small>Querformat benötigt</small>
</div>
<div id="game-container">
<!-- MAIN MENU -->
<div id="menu" class="overlay-screen">
<div id="startScreen">
<div class="start-left">
<h1>ESCAPE FROM<br>TEACHER</h1>
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
<input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;">
<button id="startBtn" onclick="startSoloGame()">SOLO STARTEN</button>
<button id="coopBtn" onclick="showCoopMenu()">CO-OP SPIELEN</button>
<div class="info-box">
<div class="info-title">SCHUL-NEWS</div>
<p>
• Der Lehrer ist hinter dir her!<br>
• Spring über Hindernisse.<br>
• Sammle Power-Ups für Vorteile!
</p>
</div>
<div class="info-box">
<div class="info-title">STEUERUNG</div>
<p>
PC: <strong>Leertaste</strong> (Springen), <strong>WASD/Pfeile</strong> (Bewegen)<br>
Handy: <strong>Bildschirm-Buttons</strong>
</p>
</div>
<div class="legal-bar">
<button class="legal-btn" onclick="showLeaderboard()">🏆 TOP 10</button>
<button class="legal-btn" onclick="showSettings()">⚙️ EINSTELLUNGEN</button>
</div>
</div>
<div class="start-right">
<div class="hall-of-fame-box">
<h3>🏆 TOP SCHÜLER</h3>
<div id="startLeaderboardList">Lade...</div>
</div>
</div>
</div>
</div>
<!-- CO-OP MENU -->
<div id="coopMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>CO-OP MODUS</h1>
<button class="big-btn" onclick="createRoom()">RAUM ERSTELLEN</button>
<div style="margin: 20px 0;">- ODER -</div>
<input type="text" id="joinRoomCode" placeholder="RAUM-CODE" maxlength="6" style="text-transform:uppercase;">
<input type="text" id="teamNameJoin" placeholder="TEAM-NAME" maxlength="15">
<button onclick="joinRoom()">RAUM BEITRETEN</button>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- SETTINGS MENU -->
<div id="settingsMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>EINSTELLUNGEN</h1>
<div class="settings-group">
<div class="setting-item">
<label>MUSIK LAUTSTÄRKE:</label>
<input type="range" id="musicVolume" min="0" max="100" value="70">
<span id="musicValue">70%</span>
</div>
<div class="setting-item">
<label>SFX LAUTSTÄRKE:</label>
<input type="range" id="sfxVolume" min="0" max="100" value="70">
<span id="sfxValue">70%</span>
</div>
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- LEADERBOARD MENU -->
<div id="leaderboardMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>🏆 TOP 10 LEADERBOARD</h1>
<div id="leaderboardList" class="leaderboard-box">
Lade Leaderboard...
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- GAME OVER SCREEN -->
<div id="gameOverScreen" class="overlay-screen hidden">
<div class="center-box">
<h1>ERWISCHT!</h1>
<p style="font-size: 18px; margin: 20px 0;">
Dein Score: <span id="finalScore" style="color:yellow; font-size: 24px;">0</span>
</p>
<div id="leaderboardList" class="leaderboard-box" style="margin: 20px 0;">
Lade Leaderboard...
</div>
<button class="big-btn" onclick="location.reload()" style="background: #ff4444; margin-top: 20px;">NOCHMAL SPIELEN</button>
</div>
</div>
<!-- LOADING SCREEN -->
<div id="loading" class="loading-screen">
<div class="spinner"></div>
<p>Lade Escape From Teacher...</p>
</div>
</div>
<!-- WASM Execution -->
<script src="wasm_exec.js"></script>
<script src="game.js"></script>
</body>
</html>

BIN
cmd/client/web/main.wasm Executable file

Binary file not shown.

1
cmd/client/web/style.css Normal file

File diff suppressed because one or more lines are too long

575
cmd/client/web/wasm_exec.js Normal file
View File

@@ -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;
};
}
}
})();

View File

@@ -28,10 +28,11 @@ const (
AssetFile = "./cmd/client/assets/assets.json" AssetFile = "./cmd/client/assets/assets.json"
ChunkDir = "./cmd/client/assets/chunks" ChunkDir = "./cmd/client/assets/chunks"
SidebarWidth = 250 LeftSidebarWidth = 250
TopBarHeight = 40 RightSidebarWidth = 250
CanvasHeight = 720 TopBarHeight = 40
CanvasWidth = 1280 CanvasHeight = 720
CanvasWidth = 1280
TileSize = 64 TileSize = 64
RefFloorY = 540 RefFloorY = 540
@@ -55,12 +56,15 @@ type LevelEditor struct {
assetList []string assetList []string
assetsImages map[string]*ebiten.Image assetsImages map[string]*ebiten.Image
currentChunk game.Chunk currentChunk game.Chunk
currentChunkFile string // Aktuell geladene Datei
chunkFiles []string // Liste aller Chunk-Dateien
scrollX float64 scrollX float64
zoom float64 zoom float64
listScroll float64 listScroll float64
statusMsg string chunkListScroll float64 // Scroll für Chunk-Liste
statusMsg string
showGrid bool showGrid bool
enableSnap bool enableSnap bool
@@ -75,20 +79,31 @@ type LevelEditor struct {
dragAssetID string dragAssetID string
dragTargetIndex int dragTargetIndex int
dragOffset game.Vec2 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 { func NewLevelEditor() *LevelEditor {
le := &LevelEditor{ le := &LevelEditor{
assetsImages: make(map[string]*ebiten.Image), assetsImages: make(map[string]*ebiten.Image),
currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}}, currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
zoom: 1.0, zoom: 1.0,
showGrid: true, showGrid: true,
enableSnap: true, enableSnap: true,
showHitbox: true, showHitbox: true,
showPlayerRef: true, // Standardmäßig an showPlayerRef: true, // Standardmäßig an
movingPlatformObjIndex: -1,
} }
le.LoadAssets() 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 return le
} }
@@ -117,29 +132,82 @@ func (le *LevelEditor) LoadAssets() {
sort.Strings(le.assetList) 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) { func (le *LevelEditor) LoadChunk(filename string) {
path := filepath.Join(ChunkDir, filename) path := filepath.Join(ChunkDir, filename)
data, err := ioutil.ReadFile(path) data, err := ioutil.ReadFile(path)
if err == nil { if err == nil {
json.Unmarshal(data, &le.currentChunk) json.Unmarshal(data, &le.currentChunk)
le.currentChunkFile = filename
le.statusMsg = "Geladen: " + filename le.statusMsg = "Geladen: " + filename
} else { } else {
le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename)) 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 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() { func (le *LevelEditor) SaveChunk() {
os.MkdirAll(ChunkDir, 0755) os.MkdirAll(ChunkDir, 0755)
filename := le.currentChunk.ID + ".json" filename := le.currentChunk.ID + ".json"
path := filepath.Join(ChunkDir, filename) path := filepath.Join(ChunkDir, filename)
data, _ := json.MarshalIndent(le.currentChunk, "", " ") data, _ := json.MarshalIndent(le.currentChunk, "", " ")
ioutil.WriteFile(path, data, 0644) ioutil.WriteFile(path, data, 0644)
le.currentChunkFile = filename
le.RefreshChunkList()
le.statusMsg = "GESPEICHERT als " + filename le.statusMsg = "GESPEICHERT als " + filename
} }
func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) { func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) {
screenX := float64(mx - SidebarWidth) screenX := float64(mx - LeftSidebarWidth)
screenY := float64(my - TopBarHeight) screenY := float64(my - TopBarHeight)
worldX := (screenX / le.zoom) + le.scrollX worldX := (screenX / le.zoom) + le.scrollX
worldY := screenY / le.zoom worldY := screenY / le.zoom
@@ -200,6 +268,17 @@ func (le *LevelEditor) Update() error {
le.currentChunk.Width = v 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 = "" le.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) { } else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
le.activeField = "" le.activeField = ""
@@ -214,7 +293,9 @@ func (le *LevelEditor) Update() error {
} }
// Hotkeys // Hotkeys
if mx > SidebarWidth { canvasStartX := LeftSidebarWidth
canvasEndX := CanvasWidth - RightSidebarWidth
if mx > canvasStartX && mx < canvasEndX {
_, wy := ebiten.Wheel() _, wy := ebiten.Wheel()
if wy != 0 { if wy != 0 {
le.zoom += wy * 0.1 le.zoom += wy * 0.1
@@ -238,6 +319,15 @@ func (le *LevelEditor) Update() error {
if inpututil.IsKeyJustPressed(ebiten.KeyP) { if inpututil.IsKeyJustPressed(ebiten.KeyP) {
le.showPlayerRef = !le.showPlayerRef le.showPlayerRef = !le.showPlayerRef
} // NEU: Toggle Player } // 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) { if ebiten.IsKeyPressed(ebiten.KeyRight) {
le.scrollX += 10 / le.zoom le.scrollX += 10 / le.zoom
} }
@@ -263,8 +353,8 @@ func (le *LevelEditor) Update() error {
return nil return nil
} }
// Palette // Left Sidebar - Asset Palette
if mx < SidebarWidth { if mx < LeftSidebarWidth {
_, wy := ebiten.Wheel() _, wy := ebiten.Wheel()
le.listScroll -= wy * 20 le.listScroll -= wy * 20
if le.listScroll < 0 { if le.listScroll < 0 {
@@ -284,23 +374,119 @@ func (le *LevelEditor) Update() error {
return nil 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) 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) { if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- { for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
obj := le.currentChunk.Objects[i] obj := le.currentChunk.Objects[i]
w, h := le.GetAssetSize(obj.AssetID) w, h := le.GetAssetSize(obj.AssetID)
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h { 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.currentChunk.Objects = append(le.currentChunk.Objects[:i], le.currentChunk.Objects[i+1:]...)
le.statusMsg = fmt.Sprintf("Objekt gelöscht: %s", obj.AssetID)
return nil return nil
} }
} }
} }
// MOVE // 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-- { for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
obj := le.currentChunk.Objects[i] obj := le.currentChunk.Objects[i]
w, h := le.GetAssetSize(obj.AssetID) w, h := le.GetAssetSize(obj.AssetID)
@@ -352,11 +538,13 @@ func (le *LevelEditor) Update() error {
func (le *LevelEditor) Draw(screen *ebiten.Image) { func (le *LevelEditor) Draw(screen *ebiten.Image) {
// UI HINTERGRUND // UI HINTERGRUND
vector.DrawFilledRect(screen, 0, 0, CanvasWidth, TopBarHeight, ColBgTop, false) 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) 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 startY := float64(TopBarHeight+40) - le.listScroll
for i, id := range le.assetList { for i, id := range le.assetList {
y := startY + float64(i*25) 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) 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 // CANVAS
canvasOffX := float64(SidebarWidth) canvasOffX := float64(LeftSidebarWidth)
canvasOffY := float64(TopBarHeight) canvasOffY := float64(TopBarHeight)
canvasWidth := float32(CanvasWidth - LeftSidebarWidth - RightSidebarWidth)
// Canvas Hintergrund
vector.DrawFilledRect(screen, float32(canvasOffX), float32(canvasOffY), canvasWidth, CanvasHeight-TopBarHeight, ColBgCanvas, false)
// GRID // GRID
canvasEndX := float32(CanvasWidth - RightSidebarWidth)
if le.showGrid { if le.showGrid {
startGridX := int(le.scrollX/TileSize) * TileSize 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) 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 { for y := 0; y < int(CanvasHeight/le.zoom); y += TileSize {
drawY := float32(float64(y)*le.zoom + canvasOffY) 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 // BODEN LINIE
floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY) 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)
// PLAYER REFERENCE (GHOST) // PLAYER REFERENCE (GHOST)
@@ -422,14 +665,54 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
} }
// OBJEKTE // 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) 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 // DRAG GHOST
if le.isDragging && le.dragType == "new" { if le.isDragging && le.dragType == "new" {
mx, my := ebiten.CursorPosition() mx, my := ebiten.CursorPosition()
if mx > SidebarWidth && my > TopBarHeight { if mx > LeftSidebarWidth && mx < CanvasWidth-RightSidebarWidth && my > TopBarHeight {
wRawX, wRawY := le.ScreenToWorld(mx, my) wRawX, wRawY := le.ScreenToWorld(mx, my)
_, h := le.GetAssetSize(le.dragAssetID) _, h := le.GetAssetSize(le.dragAssetID)
snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h) snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h)
@@ -441,15 +724,33 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
} }
// STATUS // STATUS
text.Draw(screen, "[S]ave | [G]rid | [H]itbox | [P]layer Ref | R-Click=Del", basicfont.Face7x13, 400, 25, color.Gray{100}) statusText := "[S]ave | [G]rid | [H]itbox | [P]layer | [M]oving Platform | R-Click=Del"
text.Draw(screen, le.statusMsg, basicfont.Face7x13, SidebarWidth+10, CanvasHeight-10, ColHighlight) 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) { func (le *LevelEditor) DrawAsset(screen *ebiten.Image, id string, wX, wY, offX, offY float64, alpha float32) {
sX := (wX-le.scrollX)*le.zoom + offX sX := (wX-le.scrollX)*le.zoom + offX
sY := wY*le.zoom + offY 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 return
} }

View File

@@ -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{}) { _, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
top10 := server.GlobalLeaderboard.GetTop10() top10 := server.GlobalLeaderboard.GetTop10()
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10)) log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
ec.Publish(reply, 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...") log.Println("✅ Server bereit. Warte auf Spieler...")
// 5. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients)
go StartWebSocketGateway("8080", ec)
// Block forever // Block forever
select {} select {}
} }

View File

@@ -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)
}
}

View File

@@ -17,7 +17,7 @@ services:
ports: ports:
- "4222:4222" # Client Port (für unsere Go Apps) - "4222:4222" # Client Port (für unsere Go Apps)
- "8222:8222" # Dashboard / Monitoring - "8222:8222" # Dashboard / Monitoring
command: "-js" # JetStream aktivieren (optional, aber gut für später) command: ["-js"] # JetStream aktivieren
volumes: volumes:
redis_data: redis_data:

1
go.mod
View File

@@ -12,4 +12,5 @@ require (
require ( require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.3 // indirect
) )

2
go.sum
View File

@@ -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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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/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/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=

12
nats.conf Normal file
View File

@@ -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

View File

@@ -33,6 +33,17 @@ type AssetManifest struct {
type LevelObject struct { type LevelObject struct {
AssetID string AssetID string
X, Y float64 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 { type Chunk struct {
ID string ID string
@@ -62,34 +73,51 @@ type ClientInput struct {
} }
type JoinRequest struct { type JoinRequest struct {
Name string `json:"name"` Name string `json:"name"`
RoomID string `json:"room_id"` 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 { type PlayerState struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` Y float64 `json:"y"`
VX float64 `json:"vx"` VX float64 `json:"vx"`
VY float64 `json:"vy"` VY float64 `json:"vy"`
State string `json:"state"` State string `json:"state"`
OnGround bool `json:"on_ground"` OnGround bool `json:"on_ground"`
LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz OnWall bool `json:"on_wall"` // Ist an einer Wand
Score int `json:"score"` // Punkte des Spielers LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz
IsAlive bool `json:"is_alive"` // Lebt der Spieler noch? Score int `json:"score"` // Punkte des Spielers
IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus 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 { type GameState struct {
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
Players map[string]PlayerState `json:"players"` Players map[string]PlayerState `json:"players"`
Status string `json:"status"` Status string `json:"status"`
TimeLeft int `json:"time_left"` TimeLeft int `json:"time_left"`
WorldChunks []ActiveChunk `json:"world_chunks"` WorldChunks []ActiveChunk `json:"world_chunks"`
HostID string `json:"host_id"` HostID string `json:"host_id"`
ScrollX float64 `json:"scroll_x"` ScrollX float64 `json:"scroll_x"`
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex) 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 // Leaderboard-Eintrag
@@ -105,4 +133,22 @@ type ScoreSubmission struct {
PlayerName string `json:"player_name"` PlayerName string `json:"player_name"`
PlayerCode string `json:"player_code"` PlayerCode string `json:"player_code"`
Score int `json:"score"` 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"`
} }

View File

@@ -8,9 +8,10 @@ type InputMessage struct {
// State: Wo alles ist (Server -> Client) // State: Wo alles ist (Server -> Client)
type GameStateMessage struct { type GameStateMessage struct {
Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16) Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16)
Score float64 `json:"score"` Score float64 `json:"score"`
Multiplier int `json:"multiplier"` Multiplier int `json:"multiplier"`
MovingPlatforms []*MovingPlatformState `json:"moving_platforms"` // Bewegende Plattformen
} }
type PlayerState struct { type PlayerState struct {
@@ -18,3 +19,11 @@ type PlayerState struct {
X float64 `json:"x"` X float64 `json:"x"`
Y float64 `json:"y"` 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"`
}

View File

@@ -40,48 +40,42 @@ func InitLeaderboard(redisAddr string) error {
} }
func (lb *Leaderboard) AddScore(name, code string, score int) bool { func (lb *Leaderboard) AddScore(name, code string, score int) bool {
// Prüfe ob Spieler bereits existiert // Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp
existingScoreStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result() timestamp := time.Now().Unix()
if err == nil { uniqueKey := code + "_" + time.Now().Format("20060102_150405")
var existingScore int
json.Unmarshal([]byte(existingScoreStr), &existingScore)
if score <= existingScore {
return false // Neuer Score nicht besser
}
}
// Score speichern // Score speichern
entry := game.LeaderboardEntry{ entry := game.LeaderboardEntry{
PlayerName: name, PlayerName: name,
PlayerCode: code, PlayerCode: code,
Score: score, Score: score,
Timestamp: time.Now().Unix(), Timestamp: timestamp,
} }
data, _ := json.Marshal(entry) 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{ lb.rdb.ZAdd(lb.ctx, leaderboardKey, redis.Z{
Score: float64(score), 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 return true
} }
func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry { func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry {
// Hole Top 10 (höchste Scores zuerst) // 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 { if err != nil {
log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err) log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err)
return []game.LeaderboardEntry{} return []game.LeaderboardEntry{}
} }
entries := make([]game.LeaderboardEntry, 0) entries := make([]game.LeaderboardEntry, 0)
for _, code := range codes { for _, uniqueKey := range uniqueKeys {
dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result() dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:entries", uniqueKey).Result()
if err != nil { if err != nil {
continue continue
} }

View File

@@ -2,6 +2,7 @@ package server
import ( import (
"log" "log"
"math"
"math/rand" "math/rand"
"sync" "sync"
"time" "time"
@@ -12,34 +13,70 @@ import (
) )
type ServerPlayer struct { type ServerPlayer struct {
ID string ID string
Name string Name string
X, Y float64 X, Y float64
VX, VY float64 VX, VY float64
OnGround bool OnGround bool
InputX float64 // -1 (Links), 0, 1 (Rechts) OnWall bool // Ist an einer Wand
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
Score int InputX float64 // -1 (Links), 0, 1 (Rechts)
IsAlive bool LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
IsSpectator bool 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 { type Room struct {
ID string ID string
NC *nats.Conn NC *nats.Conn
World *game.World World *game.World
Mutex sync.RWMutex Mutex sync.RWMutex
Players map[string]*ServerPlayer Players map[string]*ServerPlayer
ActiveChunks []game.ActiveChunk ActiveChunks []game.ActiveChunk
Colliders []game.Collider Colliders []game.Collider
Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER" Status string // "LOBBY", "COUNTDOWN", "RUNNING", "GAMEOVER"
GlobalScrollX float64 GlobalScrollX float64
MapEndX float64 MapEndX float64
Countdown int Countdown int
NextStart time.Time NextStart time.Time
HostID string HostID string
CollectedCoins map[string]bool // Key: "chunkID_objectIndex" CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
ScoreAccum float64 // Akkumulator für Distanz-Score 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{} stopChan chan struct{}
@@ -54,16 +91,21 @@ type Room struct {
// Konstruktor // Konstruktor
func NewRoom(id string, nc *nats.Conn, w *game.World) *Room { func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
r := &Room{ r := &Room{
ID: id, ID: id,
NC: nc, NC: nc,
World: w, World: w,
Players: make(map[string]*ServerPlayer), Players: make(map[string]*ServerPlayer),
Status: "LOBBY", Status: "LOBBY",
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
CollectedCoins: make(map[string]bool), CollectedCoins: make(map[string]bool),
pW: 40, pH: 60, // Fallback 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 // Player Werte aus Manifest laden
if def, ok := w.Manifest.Assets["player"]; ok { if def, ok := w.Manifest.Assets["player"]; ok {
r.pW = def.Hitbox.W r.pW = def.Hitbox.W
@@ -214,6 +256,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
if p.OnGround { if p.OnGround {
p.VY = -14.0 p.VY = -14.0
p.OnGround = false 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": case "DOWN":
p.VY = 15.0 p.VY = 15.0
@@ -256,6 +304,8 @@ func (r *Room) Update() {
} }
} else if r.Status == "RUNNING" { } else if r.Status == "RUNNING" {
r.GlobalScrollX += config.RunSpeed r.GlobalScrollX += config.RunSpeed
// Bewegende Plattformen updaten
r.UpdateMovingPlatforms()
} }
maxX := r.GlobalScrollX maxX := r.GlobalScrollX
@@ -282,20 +332,38 @@ func (r *Room) Update() {
} }
// X Bewegung // 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 nextX := p.X + currentSpeed
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitX { if hitX {
if typeX == "obstacle" { if typeX == "wall" {
// Nicht blocken, sondern weiterlaufen und töten // Wand getroffen - kann klettern!
p.X = nextX p.OnWall = true
r.KillPlayer(p) // X-Position nicht ändern (bleibt an der Wand)
continue } 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 { } else {
p.X = nextX p.X = nextX
p.OnWall = false
} }
// Grenzen // Grenzen
@@ -312,28 +380,85 @@ func (r *Room) Update() {
} }
// Y Bewegung // Y Bewegung
p.VY += config.Gravity // An der Wand: Reduzierte Gravität + Klettern mit InputX
if p.VY > config.MaxFall { if p.OnWall {
p.VY = config.MaxFall // 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 nextY := p.Y + p.VY
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitY { if hitY {
if typeY == "obstacle" { if typeY == "wall" {
// Nicht blocken, sondern weiterlaufen und töten // 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 p.Y = nextY
r.KillPlayer(p) r.KillPlayer(p)
continue 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 { } else {
p.Y += p.VY p.Y += p.VY
p.OnGround = false 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 { if p.Y > 1000 {
@@ -342,6 +467,15 @@ func (r *Room) Update() {
// Coin Kollision prüfen // Coin Kollision prüfen
r.CheckCoinCollision(p) 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 // 2b. Distanz-Score updaten
@@ -380,6 +514,7 @@ func (r *Room) Update() {
func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) { func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h} playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
// 1. Statische Colliders (Chunks)
for _, c := range r.Colliders { for _, c := range r.Colliders {
if game.CheckRectCollision(playerRect, c.Rect) { if game.CheckRectCollision(playerRect, c.Rect) {
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)", 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, "" 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) { func (r *Room) UpdateMapLogic(maxX float64) {
if r.Status != "RUNNING" { if r.Status != "RUNNING" {
return return
@@ -413,26 +583,178 @@ func (r *Room) UpdateMapLogic(maxX float64) {
chunkWidth := float64(chunkDef.Width * config.TileSize) chunkWidth := float64(chunkDef.Width * config.TileSize)
if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 { 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.ActiveChunks = r.ActiveChunks[1:]
r.Colliders = r.World.GenerateColliders(r.ActiveChunks) r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID)
} }
} }
} }
func (r *Room) SpawnNextChunk() { // ClearChunkCoins löscht alle eingesammelten Coins eines Chunks
keys := make([]string, 0, len(r.World.ChunkLibrary)) func (r *Room) ClearChunkCoins(chunkID string) {
for k := range r.World.ChunkLibrary { prefix := chunkID + "_"
keys = append(keys, k) 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 { for objIdx, obj := range chunkDef.Objects {
// Zufälligen Chunk wählen if obj.MovingPlatform != nil {
randomID := keys[rand.Intn(len(keys))] assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
chunkDef := r.World.ChunkLibrary[randomID] 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} newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX}
r.ActiveChunks = append(r.ActiveChunks, newChunk) r.ActiveChunks = append(r.ActiveChunks, newChunk)
r.MapEndX += float64(chunkDef.Width * config.TileSize) 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 { } else {
// Fallback, falls keine Chunks da sind // Fallback, falls keine Chunks da sind
r.MapEndX += 1280 r.MapEndX += 1280
@@ -446,35 +768,52 @@ func (r *Room) Broadcast() {
defer r.Mutex.RUnlock() defer r.Mutex.RUnlock()
state := game.GameState{ state := game.GameState{
RoomID: r.ID, RoomID: r.ID,
Players: make(map[string]game.PlayerState), Players: make(map[string]game.PlayerState),
Status: r.Status, Status: r.Status,
TimeLeft: r.Countdown, TimeLeft: r.Countdown,
WorldChunks: r.ActiveChunks, WorldChunks: r.ActiveChunks,
HostID: r.HostID, HostID: r.HostID,
ScrollX: r.GlobalScrollX, ScrollX: r.GlobalScrollX,
CollectedCoins: r.CollectedCoins, CollectedCoins: r.CollectedCoins,
CollectedPowerups: r.CollectedPowerups,
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
} }
for id, p := range r.Players { for id, p := range r.Players {
state.Players[id] = game.PlayerState{ state.Players[id] = game.PlayerState{
ID: id, ID: id,
Name: p.Name, Name: p.Name,
X: p.X, X: p.X,
Y: p.Y, Y: p.Y,
VX: p.VX, VX: p.VX,
VY: p.VY, VY: p.VY,
OnGround: p.OnGround, OnGround: p.OnGround,
LastInputSeq: p.LastInputSeq, OnWall: p.OnWall,
Score: p.Score, LastInputSeq: p.LastInputSeq,
IsAlive: p.IsAlive, Score: p.Score,
IsSpectator: p.IsSpectator, 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) // 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) 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.<ROOMID>" // Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
@@ -490,3 +829,18 @@ func (r *Room) RemovePlayer(id string) {
delete(r.Players, id) delete(r.Players, id)
log.Printf(" Player %s left room %s", id, r.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)
}
}

View File

@@ -3,6 +3,7 @@ package server
import ( import (
"fmt" "fmt"
"log" "log"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game" "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
) )
@@ -47,10 +48,10 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
continue continue
} }
// Coin-Hitbox // Coin-Hitbox (muss DrawOffX/Y einbeziehen wie bei Obstacles!)
coinHitbox := game.Rect{ coinHitbox := game.Rect{
OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX, OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
OffsetY: obj.Y + assetDef.Hitbox.OffsetY, OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
W: assetDef.Hitbox.W, W: assetDef.Hitbox.W,
H: assetDef.Hitbox.H, 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 // UpdateDistanceScore aktualisiert Distanz-basierte Punkte
func (r *Room) UpdateDistanceScore() { func (r *Room) UpdateDistanceScore() {
if r.Status != "RUNNING" { if r.Status != "RUNNING" {
return return
} }
// Anzahl lebender Spieler zählen // Jeder Spieler bekommt Punkte basierend auf seiner eigenen Distanz
aliveCount := 0 // Punkte = (X-Position / TileSize) = Distanz in Tiles
for _, p := range r.Players { for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator { 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 { // Nur updaten wenn höher als aktueller Score
return if newScore > p.Score {
} p.Score = newScore
// 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
} }
} }
} }

1
player_code.txt Normal file
View File

@@ -0,0 +1 @@
49badef83664a3d83cb4ec6ab0853c9e