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.
@@ -56,7 +56,7 @@ var (
|
||||
ColPlayerRef = color.RGBA{0, 255, 255, 100}
|
||||
)
|
||||
|
||||
var AssetTypes = []string{"obstacle", "platform", "powerup", "enemy", "deco", "coin"}
|
||||
var AssetTypes = []string{"obstacle", "platform", "wall", "powerup", "enemy", "deco", "coin"}
|
||||
|
||||
// --- HILFSFUNKTIONEN ---
|
||||
|
||||
@@ -92,6 +92,58 @@ func generateBrickTexture(w, h int) *ebiten.Image {
|
||||
return img
|
||||
}
|
||||
|
||||
func generateWallTexture(w, h int) *ebiten.Image {
|
||||
img := ebiten.NewImage(w, h)
|
||||
// Dunklerer Hintergrund für Wände
|
||||
img.Fill(color.RGBA{60, 60, 70, 255})
|
||||
|
||||
stoneColor := color.RGBA{100, 100, 110, 255}
|
||||
stoneDark := color.RGBA{80, 80, 90, 255}
|
||||
stoneLight := color.RGBA{120, 120, 130, 255}
|
||||
|
||||
// Mehr Reihen und Spalten für Wände
|
||||
rows := h / 16
|
||||
if rows < 2 {
|
||||
rows = 2
|
||||
}
|
||||
cols := w / 16
|
||||
if cols < 2 {
|
||||
cols = 2
|
||||
}
|
||||
|
||||
brickH := float32(h) / float32(rows)
|
||||
brickW := float32(w) / float32(cols)
|
||||
padding := float32(1)
|
||||
|
||||
for row := 0; row < rows; row++ {
|
||||
for col := 0; col < cols; col++ {
|
||||
// Versatz für ungeraden Reihen (Mauerwerk-Muster)
|
||||
xOffset := float32(0)
|
||||
if row%2 != 0 {
|
||||
xOffset = brickW / 2
|
||||
}
|
||||
x := float32(col)*brickW + xOffset
|
||||
y := float32(row) * brickH
|
||||
|
||||
drawStone := func(bx, by float32) {
|
||||
// Hauptstein
|
||||
vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, brickH-padding*2, stoneColor, false)
|
||||
// Schatten unten
|
||||
vector.DrawFilledRect(img, bx+padding, by+brickH-padding-2, brickW-padding*2, 2, stoneDark, false)
|
||||
// Highlight oben
|
||||
vector.DrawFilledRect(img, bx+padding, by+padding, brickW-padding*2, 2, stoneLight, false)
|
||||
}
|
||||
|
||||
drawStone(x, y)
|
||||
// Wrap-around für versetzten Offset
|
||||
if x+brickW > float32(w) {
|
||||
drawStone(x-float32(w), y)
|
||||
}
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func saveImageToDisk(img *ebiten.Image, filename string) error {
|
||||
stdImg := img.SubImage(img.Bounds())
|
||||
assetDir := filepath.Dir(OutFile)
|
||||
@@ -282,6 +334,33 @@ func (e *Editor) CreatePlatform() {
|
||||
e.selectedID = id
|
||||
}
|
||||
|
||||
func (e *Editor) CreateWall() {
|
||||
w, h := 64, 128
|
||||
texImg := generateWallTexture(w, h)
|
||||
timestamp := time.Now().Unix()
|
||||
filename := fmt.Sprintf("gen_wall_%d.png", timestamp)
|
||||
id := fmt.Sprintf("wall_%d", timestamp)
|
||||
|
||||
if err := saveImageToDisk(texImg, filename); err != nil {
|
||||
log.Printf("Fehler beim Speichern: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
e.assetsImages[id] = texImg
|
||||
e.manifest.Assets[id] = game.AssetDefinition{
|
||||
ID: id,
|
||||
Type: "wall", // Neuer Type für kletterbare Wände
|
||||
Filename: filename,
|
||||
Scale: 1.0,
|
||||
Color: game.HexColor{R: 255, G: 255, B: 255, A: 255},
|
||||
DrawOffX: float64(-w) / 2,
|
||||
DrawOffY: float64(-h),
|
||||
Hitbox: game.Rect{W: float64(w), H: float64(h), OffsetX: float64(-w) / 2, OffsetY: float64(-h)},
|
||||
}
|
||||
e.RebuildList()
|
||||
e.selectedID = id
|
||||
}
|
||||
|
||||
func (e *Editor) Update() error {
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyS) && e.activeField == "" {
|
||||
e.Save()
|
||||
@@ -335,9 +414,13 @@ func (e *Editor) Update() error {
|
||||
currentY += float64(LineHeight)
|
||||
}
|
||||
|
||||
if my > CanvasHeight-40 {
|
||||
// Button-Bereich unten
|
||||
if my > CanvasHeight-75 && my <= CanvasHeight-40 {
|
||||
e.CreatePlatform()
|
||||
}
|
||||
if my > CanvasHeight-40 {
|
||||
e.CreateWall()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -500,10 +583,15 @@ func (e *Editor) Draw(screen *ebiten.Image) {
|
||||
// --- 1. LISTE LINKS ---
|
||||
vector.DrawFilledRect(screen, 0, 0, WidthList, CanvasHeight, ColPanel, false)
|
||||
|
||||
// Button Neu
|
||||
btnRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10)
|
||||
vector.DrawFilledRect(screen, float32(btnRect.Min.X), float32(btnRect.Min.Y), float32(btnRect.Dx()), float32(btnRect.Dy()), ColHighlight, false)
|
||||
text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-18, color.RGBA{255, 255, 255, 255})
|
||||
// Button Platform
|
||||
btnPlatRect := image.Rect(10, CanvasHeight-70, WidthList-10, CanvasHeight-45)
|
||||
vector.DrawFilledRect(screen, float32(btnPlatRect.Min.X), float32(btnPlatRect.Min.Y), float32(btnPlatRect.Dx()), float32(btnPlatRect.Dy()), ColHighlight, false)
|
||||
text.Draw(screen, "+ NEW PLATFORM", basicfont.Face7x13, 20, CanvasHeight-53, color.RGBA{255, 255, 255, 255})
|
||||
|
||||
// Button Wall
|
||||
btnWallRect := image.Rect(10, CanvasHeight-35, WidthList-10, CanvasHeight-10)
|
||||
vector.DrawFilledRect(screen, float32(btnWallRect.Min.X), float32(btnWallRect.Min.Y), float32(btnWallRect.Dx()), float32(btnWallRect.Dy()), color.RGBA{100, 100, 120, 255}, false)
|
||||
text.Draw(screen, "+ NEW WALL", basicfont.Face7x13, 35, CanvasHeight-18, color.RGBA{255, 255, 255, 255})
|
||||
|
||||
// SCROLL BEREICH
|
||||
startY := 40.0 - e.listScroll
|
||||
@@ -511,7 +599,7 @@ func (e *Editor) Draw(screen *ebiten.Image) {
|
||||
|
||||
// Helper Funktion zum Zeichnen von Listeneinträgen mit Bild
|
||||
drawListItem := func(label string, id string, col color.Color, img *ebiten.Image) {
|
||||
if currentY > -float64(LineHeight) && currentY < CanvasHeight-50 {
|
||||
if currentY > -float64(LineHeight) && currentY < CanvasHeight-80 {
|
||||
// Bild Vorschau (Thumbnail)
|
||||
if img != nil {
|
||||
// Skalierung berechnen (max 28px hoch/breit)
|
||||
|
||||
@@ -90,6 +90,24 @@
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"godmode": {
|
||||
"ID": "godmode",
|
||||
"Type": "powerup",
|
||||
"Filename": "godmode.png",
|
||||
"Scale": 0.08,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 3,
|
||||
"DrawOffY": -90,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": -1,
|
||||
"OffsetY": 3,
|
||||
"W": 59,
|
||||
"H": 85,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"h-l": {
|
||||
"ID": "h-l",
|
||||
"Type": "obstacle",
|
||||
@@ -108,6 +126,78 @@
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"jump0": {
|
||||
"ID": "jump0",
|
||||
"Type": "obstacle",
|
||||
"Filename": "jump0.png",
|
||||
"Scale": 0.17,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": -8,
|
||||
"DrawOffY": -193,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 22,
|
||||
"OffsetY": 6,
|
||||
"W": 72,
|
||||
"H": 183,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"jump1": {
|
||||
"ID": "jump1",
|
||||
"Type": "obstacle",
|
||||
"Filename": "jump1.png",
|
||||
"Scale": 0.16,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": -1,
|
||||
"DrawOffY": -167,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 18,
|
||||
"OffsetY": 11,
|
||||
"W": 72,
|
||||
"H": 149,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"jumpboost": {
|
||||
"ID": "jumpboost",
|
||||
"Type": "powerup",
|
||||
"Filename": "jumpboost.png",
|
||||
"Scale": 0.09,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 1,
|
||||
"DrawOffY": -81,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 3,
|
||||
"OffsetY": 2,
|
||||
"W": 97,
|
||||
"H": 76,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"k-l": {
|
||||
"ID": "k-l",
|
||||
"Type": "obstacle",
|
||||
"Filename": "k-l.png",
|
||||
"Scale": 0.12,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": -43,
|
||||
"DrawOffY": -228,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 43,
|
||||
"OffsetY": 5,
|
||||
"W": 78,
|
||||
"H": 222,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"k-l-monitor": {
|
||||
"ID": "k-l-monitor",
|
||||
"Type": "obstacle",
|
||||
@@ -126,6 +216,24 @@
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"k-m": {
|
||||
"ID": "k-m",
|
||||
"Type": "obstacle",
|
||||
"Filename": "k-m.png",
|
||||
"Scale": 1,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": -528,
|
||||
"DrawOffY": -2280,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": -528,
|
||||
"OffsetY": -2280,
|
||||
"W": 1056,
|
||||
"H": 2280,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"pc-trash": {
|
||||
"ID": "pc-trash",
|
||||
"Type": "obstacle",
|
||||
@@ -165,18 +273,90 @@
|
||||
"player": {
|
||||
"ID": "player",
|
||||
"Type": "obstacle",
|
||||
"Filename": "player.png",
|
||||
"Scale": 7,
|
||||
"Filename": "playernew.png",
|
||||
"Scale": 0.08,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": -53,
|
||||
"DrawOffY": -216,
|
||||
"DrawOffX": -56,
|
||||
"DrawOffY": -231,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 53,
|
||||
"OffsetX": 68,
|
||||
"OffsetY": 42,
|
||||
"W": 73,
|
||||
"H": 184,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"ID": "background",
|
||||
"Type": "background",
|
||||
"Filename": "background.jpg",
|
||||
"Scale": 1,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 0,
|
||||
"DrawOffY": 0,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 0,
|
||||
"OffsetY": 0,
|
||||
"W": 0,
|
||||
"H": 0,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"background1": {
|
||||
"ID": "background1",
|
||||
"Type": "background",
|
||||
"Filename": "background1.jpg",
|
||||
"Scale": 1,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 0,
|
||||
"DrawOffY": 0,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 0,
|
||||
"OffsetY": 0,
|
||||
"W": 0,
|
||||
"H": 0,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"background2": {
|
||||
"ID": "background2",
|
||||
"Type": "background",
|
||||
"Filename": "background2.jpg",
|
||||
"Scale": 1,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 0,
|
||||
"DrawOffY": 0,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 0,
|
||||
"OffsetY": 0,
|
||||
"W": 0,
|
||||
"H": 0,
|
||||
"Type": ""
|
||||
}
|
||||
},
|
||||
"wall_1767369789": {
|
||||
"ID": "wall_1767369789",
|
||||
"Type": "obstacle",
|
||||
"Filename": "gen_wall_1767369789.png",
|
||||
"Scale": 1,
|
||||
"ProcWidth": 0,
|
||||
"ProcHeight": 0,
|
||||
"DrawOffX": 1,
|
||||
"DrawOffY": -127,
|
||||
"Color": {},
|
||||
"Hitbox": {
|
||||
"OffsetX": 4,
|
||||
"OffsetY": 12,
|
||||
"W": 108,
|
||||
"H": 203,
|
||||
"W": 55,
|
||||
"H": 113,
|
||||
"Type": ""
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 184 KiB |
@@ -31,6 +31,11 @@
|
||||
"AssetID": "pc-trash",
|
||||
"X": 1960,
|
||||
"Y": 533
|
||||
},
|
||||
{
|
||||
"AssetID": "coin",
|
||||
"X": 1024,
|
||||
"Y": 412
|
||||
}
|
||||
]
|
||||
}
|
||||
61
cmd/client/assets/chunks/chunk_02.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
65
cmd/client/assets/chunks/chunk_03.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
16
cmd/client/assets/chunks/chunk_04.json
Normal 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/fonts/press-start-2p-v16-latin-regular.woff2
Normal file
BIN
cmd/client/assets/front.ttf
Normal file
BIN
cmd/client/assets/game.mp3
Normal file
BIN
cmd/client/assets/game.wav
Normal file
BIN
cmd/client/assets/gen_plat_1767369130.png
Normal file
|
After Width: | Height: | Size: 222 B |
BIN
cmd/client/assets/gen_wall_1767369789.png
Normal file
|
After Width: | Height: | Size: 307 B |
BIN
cmd/client/assets/godmode.png
Normal file
|
After Width: | Height: | Size: 653 KiB |
BIN
cmd/client/assets/jump.wav
Normal file
BIN
cmd/client/assets/jump0.png
Normal file
|
After Width: | Height: | Size: 559 KiB |
BIN
cmd/client/assets/jump1.png
Normal file
|
After Width: | Height: | Size: 545 KiB |
BIN
cmd/client/assets/jumpboost.png
Normal file
|
After Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 1.6 MiB |
BIN
cmd/client/assets/pickupCoin.wav
Normal file
BIN
cmd/client/assets/playernew.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
BIN
cmd/client/assets/powerUp.wav
Normal file
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 1.3 MiB |
55
cmd/client/assets_native.go
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
25
cmd/client/chunks_native.go
Normal 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
@@ -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)
|
||||
}
|
||||
268
cmd/client/connection_native.go
Normal 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)
|
||||
}
|
||||
270
cmd/client/connection_wasm.go
Normal 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
@@ -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
@@ -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})
|
||||
}
|
||||
@@ -19,16 +19,26 @@ import (
|
||||
// --- INPUT & UPDATE LOGIC ---
|
||||
|
||||
func (g *Game) UpdateGame() {
|
||||
// --- 1. KEYBOARD INPUT ---
|
||||
// --- 1. MUTE TOGGLE ---
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
|
||||
g.audio.ToggleMute()
|
||||
}
|
||||
|
||||
// --- 2. KEYBOARD INPUT ---
|
||||
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
|
||||
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
|
||||
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
|
||||
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
|
||||
|
||||
// --- 2. TOUCH INPUT HANDLING ---
|
||||
// Tastatur-Nutzung erkennen (für Mobile Controls ausblenden)
|
||||
if keyLeft || keyRight || keyDown || keyJump {
|
||||
g.keyboardUsed = true
|
||||
}
|
||||
|
||||
// --- 3. TOUCH INPUT HANDLING ---
|
||||
g.handleTouchInput()
|
||||
|
||||
// --- 3. INPUT STATE ERSTELLEN ---
|
||||
// --- 4. INPUT STATE ERSTELLEN ---
|
||||
joyDir := 0.0
|
||||
if g.joyActive {
|
||||
diffX := g.joyStickX - g.joyBaseX
|
||||
@@ -64,6 +74,34 @@ func (g *Game) UpdateGame() {
|
||||
|
||||
// Lokale Physik sofort anwenden (Prediction)
|
||||
g.ApplyInput(input)
|
||||
|
||||
// Sanfte Korrektur anwenden (20% pro Frame)
|
||||
const smoothingFactor = 0.2
|
||||
if g.correctionX != 0 || g.correctionY != 0 {
|
||||
g.predictedX += g.correctionX * smoothingFactor
|
||||
g.predictedY += g.correctionY * smoothingFactor
|
||||
|
||||
g.correctionX *= (1.0 - smoothingFactor)
|
||||
g.correctionY *= (1.0 - smoothingFactor)
|
||||
|
||||
// Korrektur beenden wenn sehr klein
|
||||
if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 0.01 {
|
||||
g.correctionX = 0
|
||||
g.correctionY = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Landing Detection für Partikel
|
||||
if !g.lastGroundState && g.predictedGround {
|
||||
// Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen)
|
||||
// Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H
|
||||
// = Y - 231 + 42 + 184 = Y - 5
|
||||
feetY := g.predictedY - 231 + 42 + 184
|
||||
centerX := g.predictedX - 56 + 68 + 73/2
|
||||
g.SpawnLandingParticles(centerX, feetY)
|
||||
}
|
||||
g.lastGroundState = g.predictedGround
|
||||
|
||||
g.predictionMutex.Unlock()
|
||||
|
||||
// Input an Server senden
|
||||
@@ -72,10 +110,8 @@ func (g *Game) UpdateGame() {
|
||||
|
||||
// --- 5. KAMERA LOGIK ---
|
||||
g.stateMutex.Lock()
|
||||
defer g.stateMutex.Unlock()
|
||||
|
||||
// Wir folgen strikt dem Server-Scroll.
|
||||
targetCam := g.gameState.ScrollX
|
||||
g.stateMutex.Unlock()
|
||||
|
||||
// Negative Kamera verhindern
|
||||
if targetCam < 0 {
|
||||
@@ -84,6 +120,12 @@ func (g *Game) UpdateGame() {
|
||||
|
||||
// Kamera hart setzen
|
||||
g.camX = targetCam
|
||||
|
||||
// --- 6. PARTIKEL UPDATEN ---
|
||||
g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms
|
||||
|
||||
// --- 7. PARTIKEL SPAWNEN (State Changes Detection) ---
|
||||
g.DetectAndSpawnParticles()
|
||||
}
|
||||
|
||||
// Verarbeitet Touch-Eingaben für Joystick und Buttons
|
||||
@@ -178,6 +220,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
||||
}
|
||||
g.stateMutex.Unlock()
|
||||
|
||||
// In WASM: HTML Game Over Screen anzeigen
|
||||
if !g.scoreSubmitted {
|
||||
g.scoreSubmitted = true
|
||||
g.submitScore()
|
||||
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
|
||||
}
|
||||
|
||||
g.DrawGameOverLeaderboard(screen, myScore)
|
||||
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
|
||||
}
|
||||
@@ -197,12 +246,41 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
||||
}
|
||||
g.stateMutex.Unlock()
|
||||
|
||||
// 1. Hintergrund & Boden
|
||||
screen.Fill(ColSky)
|
||||
// 1. Hintergrund (wechselt alle 5000 Punkte)
|
||||
backgroundID := "background"
|
||||
if myScore >= 10000 {
|
||||
backgroundID = "background2"
|
||||
} else if myScore >= 5000 {
|
||||
backgroundID = "background1"
|
||||
}
|
||||
|
||||
floorH := float32(ScreenHeight - RefFloorY)
|
||||
vector.DrawFilledRect(screen, 0, float32(RefFloorY), float32(ScreenWidth), floorH, ColGrass, false)
|
||||
vector.DrawFilledRect(screen, 0, float32(RefFloorY)+20, float32(ScreenWidth), floorH-20, ColDirt, false)
|
||||
// Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
|
||||
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
|
||||
op := &ebiten.DrawImageOptions{}
|
||||
|
||||
// Skalierung berechnen, um Bildschirm zu füllen
|
||||
bgW, bgH := bgImg.Size()
|
||||
scaleX := float64(ScreenWidth) / float64(bgW)
|
||||
scaleY := float64(ScreenHeight) / float64(bgH)
|
||||
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
|
||||
|
||||
op.GeoM.Scale(scale, scale)
|
||||
|
||||
// Zentrieren
|
||||
scaledW := float64(bgW) * scale
|
||||
scaledH := float64(bgH) * scale
|
||||
offsetX := (float64(ScreenWidth) - scaledW) / 2
|
||||
offsetY := (float64(ScreenHeight) - scaledH) / 2
|
||||
op.GeoM.Translate(offsetX, offsetY)
|
||||
|
||||
screen.DrawImage(bgImg, op)
|
||||
} else {
|
||||
// Fallback: Einfarbiger Himmel
|
||||
screen.Fill(ColSky)
|
||||
}
|
||||
|
||||
// Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera)
|
||||
g.RenderGround(screen, g.camX)
|
||||
|
||||
// State Locken für Datenzugriff
|
||||
g.stateMutex.Lock()
|
||||
@@ -218,12 +296,38 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
||||
|
||||
// Start-Chunk hat absichtlich keine Objekte
|
||||
|
||||
for _, obj := range chunkDef.Objects {
|
||||
for objIdx, obj := range chunkDef.Objects {
|
||||
// Skip Moving Platforms - die werden separat gerendert
|
||||
if obj.MovingPlatform != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Prüfe ob Coin/Powerup bereits eingesammelt wurde
|
||||
assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID]
|
||||
if hasAsset {
|
||||
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
|
||||
|
||||
if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] {
|
||||
// Coin wurde eingesammelt, nicht zeichnen
|
||||
continue
|
||||
}
|
||||
|
||||
if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] {
|
||||
// Powerup wurde eingesammelt, nicht zeichnen
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Asset zeichnen
|
||||
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y)
|
||||
}
|
||||
}
|
||||
|
||||
// 2.5 Bewegende Plattformen (von Server synchronisiert)
|
||||
for _, mp := range g.gameState.MovingPlatforms {
|
||||
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
|
||||
}
|
||||
|
||||
// 3. Spieler
|
||||
// MyID ohne Lock holen (wir haben bereits den stateMutex)
|
||||
myID := ""
|
||||
@@ -237,12 +341,33 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
||||
for id, p := range g.gameState.Players {
|
||||
// Für lokalen Spieler: Verwende vorhergesagte Position
|
||||
posX, posY := p.X, p.Y
|
||||
vy := p.VY
|
||||
onGround := p.OnGround
|
||||
if id == myID && g.connected {
|
||||
posX = g.predictedX
|
||||
posY = g.predictedY
|
||||
vy = g.predictedVY
|
||||
onGround = g.predictedGround
|
||||
}
|
||||
|
||||
g.DrawAsset(screen, "player", posX, posY)
|
||||
// Wähle Sprite basierend auf Sprung-Status
|
||||
sprite := "player" // Default: am Boden
|
||||
|
||||
// Nur Jump-Animation wenn wirklich in der Luft
|
||||
// (nicht auf Boden, nicht auf Platform mit VY ~= 0)
|
||||
isInAir := !onGround && (vy < -1.0 || vy > 1.0)
|
||||
|
||||
if isInAir {
|
||||
if vy < -2.0 {
|
||||
// Springt nach oben
|
||||
sprite = "jump0"
|
||||
} else {
|
||||
// Fällt oder höchster Punkt
|
||||
sprite = "jump1"
|
||||
}
|
||||
}
|
||||
|
||||
g.DrawAsset(screen, sprite, posX, posY)
|
||||
|
||||
// Name Tag
|
||||
name := p.Name
|
||||
@@ -284,28 +409,32 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
|
||||
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
|
||||
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
|
||||
|
||||
// 6. TOUCH CONTROLS OVERLAY
|
||||
// 6. PARTIKEL RENDERN (vor UI)
|
||||
g.RenderParticles(screen)
|
||||
|
||||
// A) Joystick Base
|
||||
baseCol := color.RGBA{255, 255, 255, 50}
|
||||
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, true)
|
||||
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{255, 255, 255, 100}, true)
|
||||
// 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
|
||||
if !g.keyboardUsed {
|
||||
// A) Joystick Base (dunkelgrau und durchsichtig)
|
||||
baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig
|
||||
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false)
|
||||
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
|
||||
|
||||
// B) Joystick Knob
|
||||
knobCol := color.RGBA{255, 255, 255, 150}
|
||||
if g.joyActive {
|
||||
knobCol = color.RGBA{100, 255, 100, 200}
|
||||
// B) Joystick Knob (dunkelgrau, außer wenn aktiv)
|
||||
knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
|
||||
if g.joyActive {
|
||||
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
|
||||
}
|
||||
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
|
||||
|
||||
// C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung)
|
||||
jumpX := float32(ScreenWidth - 150)
|
||||
jumpY := float32(ScreenHeight - 150)
|
||||
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
|
||||
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false)
|
||||
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
|
||||
}
|
||||
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, true)
|
||||
|
||||
// C) Jump Button (Rechts)
|
||||
jumpX := float32(ScreenWidth - 150)
|
||||
jumpY := float32(ScreenHeight - 150)
|
||||
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, true)
|
||||
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, true)
|
||||
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.White)
|
||||
|
||||
// 7. DEBUG INFO (Oben Links)
|
||||
// 8. DEBUG INFO (Oben Links)
|
||||
myPosStr := "N/A"
|
||||
for _, p := range g.gameState.Players {
|
||||
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)
|
||||
|
||||
9
cmd/client/gameover_native.go
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image/color"
|
||||
_ "image/png"
|
||||
"io/ioutil"
|
||||
_ "image/jpeg" // JPEG-Decoder
|
||||
_ "image/png" // PNG-Decoder
|
||||
"log"
|
||||
mrand "math/rand"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hajimehoshi/ebiten/v2"
|
||||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||||
"github.com/hajimehoshi/ebiten/v2/inpututil"
|
||||
"github.com/hajimehoshi/ebiten/v2/text"
|
||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||
@@ -28,8 +24,8 @@ import (
|
||||
|
||||
// --- KONFIGURATION ---
|
||||
const (
|
||||
ScreenWidth = 1280
|
||||
ScreenHeight = 720
|
||||
ScreenWidth = 1280
|
||||
ScreenHeight = 720
|
||||
StateMenu = 0
|
||||
StateLobby = 1
|
||||
StateGame = 2
|
||||
@@ -59,6 +55,7 @@ type InputState struct {
|
||||
type Game struct {
|
||||
appState int
|
||||
conn *nats.EncodedConn
|
||||
wsConn *wsConn // WebSocket für WASM
|
||||
gameState game.GameState
|
||||
stateMutex sync.Mutex
|
||||
connected bool
|
||||
@@ -95,6 +92,21 @@ type Game struct {
|
||||
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
|
||||
predictionMutex sync.Mutex // Mutex für pendingInputs
|
||||
|
||||
// Smooth Correction
|
||||
correctionX float64 // Verbleibende Korrektur in X
|
||||
correctionY float64 // Verbleibende Korrektur in Y
|
||||
|
||||
// Particle System
|
||||
particles []Particle
|
||||
particlesMutex sync.Mutex
|
||||
lastGroundState bool // Für Landing-Detection
|
||||
lastCollectedCoins map[string]bool // Für Coin-Partikel
|
||||
lastCollectedPowerups map[string]bool // Für Powerup-Partikel
|
||||
lastPlayerStates map[string]game.PlayerState // Für Death-Partikel
|
||||
|
||||
// Audio System
|
||||
audio *AudioSystem
|
||||
|
||||
// Kamera
|
||||
camX float64
|
||||
|
||||
@@ -104,6 +116,7 @@ type Game struct {
|
||||
joyActive bool
|
||||
joyTouchID ebiten.TouchID
|
||||
btnJumpActive bool
|
||||
keyboardUsed bool // Wurde Tastatur benutzt?
|
||||
}
|
||||
|
||||
func NewGame() *Game {
|
||||
@@ -114,67 +127,35 @@ func NewGame() *Game {
|
||||
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
|
||||
|
||||
playerName: "Student",
|
||||
activeField: "name",
|
||||
activeField: "",
|
||||
gameMode: "",
|
||||
pendingInputs: make(map[uint32]InputState),
|
||||
leaderboard: make([]game.LeaderboardEntry, 0),
|
||||
|
||||
// Particle tracking
|
||||
lastCollectedCoins: make(map[string]bool),
|
||||
lastCollectedPowerups: make(map[string]bool),
|
||||
lastPlayerStates: make(map[string]game.PlayerState),
|
||||
|
||||
// Audio System
|
||||
audio: NewAudioSystem(),
|
||||
|
||||
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
|
||||
joyStickX: 150, joyStickY: ScreenHeight - 150,
|
||||
}
|
||||
g.loadAssets()
|
||||
g.loadOrCreatePlayerCode()
|
||||
|
||||
// Gespeicherten Namen laden
|
||||
savedName := g.loadPlayerName()
|
||||
if savedName != "" {
|
||||
g.playerName = savedName
|
||||
}
|
||||
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Game) loadAssets() {
|
||||
// Pfad anpassen: Wir suchen im relativen Pfad
|
||||
baseDir := "./cmd/client/assets"
|
||||
manifestPath := filepath.Join(baseDir, "assets.json")
|
||||
|
||||
data, err := ioutil.ReadFile(manifestPath)
|
||||
if err == nil {
|
||||
var m game.AssetManifest
|
||||
json.Unmarshal(data, &m)
|
||||
g.world.Manifest = m
|
||||
fmt.Println("✅ Assets Manifest geladen:", len(m.Assets), "Einträge")
|
||||
} else {
|
||||
log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath)
|
||||
// Fallback: Leeres Manifest, damit das Spiel nicht abstürzt
|
||||
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
|
||||
}
|
||||
|
||||
// Chunks laden
|
||||
chunkDir := filepath.Join(baseDir, "chunks")
|
||||
err = g.world.LoadChunkLibrary(chunkDir)
|
||||
if err != nil {
|
||||
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
|
||||
} else {
|
||||
fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge")
|
||||
// DEBUG: Details der geladenen Chunks
|
||||
for id, chunk := range g.world.ChunkLibrary {
|
||||
fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects))
|
||||
}
|
||||
}
|
||||
|
||||
// Bilder vorladen
|
||||
loadedImages := 0
|
||||
failedImages := 0
|
||||
for id, def := range g.world.Manifest.Assets {
|
||||
if def.Filename != "" {
|
||||
path := filepath.Join(baseDir, def.Filename)
|
||||
img, _, err := ebitenutil.NewImageFromFile(path)
|
||||
if err == nil {
|
||||
g.assetsImages[id] = img
|
||||
loadedImages++
|
||||
} else {
|
||||
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
|
||||
failedImages++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
|
||||
}
|
||||
// loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert
|
||||
|
||||
// --- UPDATE ---
|
||||
func (g *Game) Update() error {
|
||||
@@ -220,6 +201,17 @@ func (g *Game) Update() error {
|
||||
}
|
||||
}
|
||||
|
||||
// Musik-Start-Check (unabhängig vom State)
|
||||
if g.gameState.Status == "RUNNING" && g.lastStatus != "RUNNING" {
|
||||
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, g.gameState.Status)
|
||||
g.audio.PlayMusic()
|
||||
}
|
||||
// Musik stoppen wenn Game Over
|
||||
if g.gameState.Status == "GAMEOVER" && g.lastStatus == "RUNNING" {
|
||||
g.audio.StopMusic()
|
||||
}
|
||||
g.lastStatus = g.gameState.Status
|
||||
|
||||
switch g.appState {
|
||||
case StateMenu:
|
||||
g.updateMenu()
|
||||
@@ -236,6 +228,30 @@ func (g *Game) Update() error {
|
||||
func (g *Game) updateMenu() {
|
||||
g.handleMenuInput()
|
||||
|
||||
// Volume Sliders (unten links)
|
||||
volumeX := 20
|
||||
volumeY := ScreenHeight - 100
|
||||
sliderWidth := 200
|
||||
sliderHeight := 10
|
||||
|
||||
// Music Volume Slider
|
||||
musicSliderY := volumeY + 10
|
||||
if isSliderHit(volumeX, musicSliderY, sliderWidth, sliderHeight) {
|
||||
newVolume := getSliderValue(volumeX, sliderWidth)
|
||||
g.audio.SetMusicVolume(newVolume)
|
||||
return
|
||||
}
|
||||
|
||||
// SFX Volume Slider
|
||||
sfxSliderY := volumeY + 50
|
||||
if isSliderHit(volumeX, sfxSliderY, sliderWidth, sliderHeight) {
|
||||
newVolume := getSliderValue(volumeX, sliderWidth)
|
||||
g.audio.SetSFXVolume(newVolume)
|
||||
// Test-Sound abspielen
|
||||
g.audio.PlayCoin()
|
||||
return
|
||||
}
|
||||
|
||||
// Leaderboard Button
|
||||
lbBtnW, lbBtnH := 200, 50
|
||||
lbBtnX := ScreenWidth - lbBtnW - 20
|
||||
@@ -324,7 +340,7 @@ func (g *Game) updateLobby() {
|
||||
|
||||
if isHit(btnX, btnY, btnW, btnH) {
|
||||
// START GAME
|
||||
g.SendCommand("START")
|
||||
g.sendStartRequest()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,18 +363,27 @@ func (g *Game) updateLobby() {
|
||||
|
||||
// --- DRAW ---
|
||||
func (g *Game) Draw(screen *ebiten.Image) {
|
||||
// In WASM: Nur das Spiel zeichnen, kein Menü/Lobby (HTML übernimmt das)
|
||||
// In Native: Alles zeichnen
|
||||
g.draw(screen)
|
||||
}
|
||||
|
||||
// draw ist die plattform-übergreifende Zeichenfunktion
|
||||
func (g *Game) draw(screen *ebiten.Image) {
|
||||
switch g.appState {
|
||||
case StateMenu:
|
||||
g.DrawMenu(screen)
|
||||
g.drawMenu(screen)
|
||||
case StateLobby:
|
||||
g.DrawLobby(screen)
|
||||
g.drawLobby(screen)
|
||||
case StateGame:
|
||||
g.DrawGame(screen)
|
||||
case StateLeaderboard:
|
||||
g.DrawLeaderboard(screen)
|
||||
g.drawLeaderboard(screen)
|
||||
}
|
||||
}
|
||||
|
||||
// drawMenu, drawLobby, drawLeaderboard sind in draw_wasm.go und draw_native.go definiert
|
||||
|
||||
func (g *Game) DrawMenu(screen *ebiten.Image) {
|
||||
screen.Fill(color.RGBA{20, 20, 30, 255})
|
||||
|
||||
@@ -442,7 +467,19 @@ func (g *Game) DrawMenu(screen *ebiten.Image) {
|
||||
vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false)
|
||||
text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255})
|
||||
|
||||
text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150})
|
||||
// Volume Controls (unten links)
|
||||
volumeX := 20
|
||||
volumeY := ScreenHeight - 100
|
||||
|
||||
// Music Volume
|
||||
text.Draw(screen, "Music Volume:", basicfont.Face7x13, volumeX, volumeY, ColText)
|
||||
g.drawVolumeSlider(screen, volumeX, volumeY+10, 200, g.audio.GetMusicVolume())
|
||||
|
||||
// SFX Volume
|
||||
text.Draw(screen, "SFX Volume:", basicfont.Face7x13, volumeX, volumeY+40, ColText)
|
||||
g.drawVolumeSlider(screen, volumeX, volumeY+50, 200, g.audio.GetSFXVolume())
|
||||
|
||||
text.Draw(screen, "WASD / Arrows - SPACE to Jump - M to Mute", basicfont.Face7x13, ScreenWidth/2-130, ScreenHeight-15, color.Gray{150})
|
||||
}
|
||||
|
||||
func (g *Game) DrawLobby(screen *ebiten.Image) {
|
||||
@@ -580,6 +617,10 @@ func (g *Game) handleMenuInput() {
|
||||
}
|
||||
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
||||
// Namen speichern wenn geändert
|
||||
if g.activeField == "name" && g.playerName != "" {
|
||||
g.savePlayerName(g.playerName)
|
||||
}
|
||||
g.activeField = ""
|
||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
||||
if len(*target) > 0 {
|
||||
@@ -614,7 +655,7 @@ func (g *Game) handleGameOverInput() {
|
||||
|
||||
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
|
||||
if g.teamName != "" {
|
||||
g.submitTeamScore()
|
||||
g.submitScore() // submitScore behandelt jetzt beide Modi
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -623,7 +664,7 @@ func (g *Game) handleGameOverInput() {
|
||||
if g.activeField == "teamname" {
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
|
||||
if g.teamName != "" {
|
||||
g.submitTeamScore()
|
||||
g.submitScore() // submitScore behandelt jetzt beide Modi
|
||||
}
|
||||
g.activeField = ""
|
||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
|
||||
@@ -650,68 +691,6 @@ func generateRoomCode() string {
|
||||
}
|
||||
|
||||
func (g *Game) connectAndStart() {
|
||||
// URL: Wasm -> WS, Desktop -> TCP
|
||||
serverURL := "nats://localhost:4222"
|
||||
if runtime.GOARCH == "wasm" || runtime.GOOS == "js" {
|
||||
serverURL = "ws://localhost:9222"
|
||||
}
|
||||
|
||||
nc, err := nats.Connect(serverURL)
|
||||
if err != nil {
|
||||
log.Println("❌ NATS Connect Fehler:", err)
|
||||
return
|
||||
}
|
||||
ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
|
||||
g.conn = ec
|
||||
|
||||
// Subscribe nur auf Updates für DIESEN Raum
|
||||
roomChannel := fmt.Sprintf("game.update.%s", g.roomID)
|
||||
log.Printf("👂 Lausche auf Channel: %s", roomChannel)
|
||||
|
||||
sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) {
|
||||
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
|
||||
for _, p := range state.Players {
|
||||
if p.Name == g.playerName {
|
||||
// Reconcile mit Server-State (verwendet keinen stateMutex)
|
||||
g.ReconcileWithServer(p)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
g.stateMutex.Lock()
|
||||
oldPlayerCount := len(g.gameState.Players)
|
||||
oldStatus := g.gameState.Status
|
||||
g.gameState = *state
|
||||
g.stateMutex.Unlock()
|
||||
|
||||
// Nur bei Änderungen loggen
|
||||
if len(state.Players) != oldPlayerCount || state.Status != oldStatus {
|
||||
log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, state.Status)
|
||||
}
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Println("❌ Fehler beim Subscribe:", err)
|
||||
return
|
||||
}
|
||||
log.Printf("👂 Subscription aktiv (Valid: %v)", sub.IsValid())
|
||||
|
||||
// Kurze Pause, damit Subscription aktiv ist
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// JOIN MIT ROOM ID SENDEN
|
||||
joinReq := game.JoinRequest{
|
||||
Name: g.playerName,
|
||||
RoomID: g.roomID,
|
||||
}
|
||||
log.Printf("📤 Sende JOIN Request: Name=%s, RoomID=%s", g.playerName, g.roomID)
|
||||
err = g.conn.Publish("game.join", joinReq)
|
||||
if err != nil {
|
||||
log.Println("❌ Fehler beim Publish:", err)
|
||||
return
|
||||
}
|
||||
g.connected = true
|
||||
|
||||
// Initiale predicted Position
|
||||
g.predictedX = 100
|
||||
g.predictedY = 200
|
||||
@@ -719,7 +698,8 @@ func (g *Game) connectAndStart() {
|
||||
g.predictedVY = 0
|
||||
g.predictedGround = false
|
||||
|
||||
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...")
|
||||
// Verbindung über plattformspezifische Implementierung
|
||||
g.connectToServer()
|
||||
}
|
||||
|
||||
func (g *Game) SendCommand(cmdType string) {
|
||||
@@ -727,7 +707,7 @@ func (g *Game) SendCommand(cmdType string) {
|
||||
return
|
||||
}
|
||||
myID := g.getMyPlayerID()
|
||||
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
|
||||
g.publishInput(game.ClientInput{PlayerID: myID, Type: cmdType})
|
||||
}
|
||||
|
||||
func (g *Game) SendInputWithSequence(input InputState) {
|
||||
@@ -739,28 +719,30 @@ func (g *Game) SendInputWithSequence(input InputState) {
|
||||
|
||||
// Inputs als einzelne Commands senden
|
||||
if input.Left {
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "LEFT_DOWN",
|
||||
Sequence: input.Sequence,
|
||||
})
|
||||
}
|
||||
if input.Right {
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "RIGHT_DOWN",
|
||||
Sequence: input.Sequence,
|
||||
})
|
||||
}
|
||||
if input.Jump {
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "JUMP",
|
||||
Sequence: input.Sequence,
|
||||
})
|
||||
// Jump Sound abspielen
|
||||
g.audio.PlayJump()
|
||||
}
|
||||
if input.Down {
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "DOWN",
|
||||
Sequence: input.Sequence,
|
||||
@@ -769,12 +751,12 @@ func (g *Game) SendInputWithSequence(input InputState) {
|
||||
|
||||
// Wenn weder Links noch Rechts, sende STOP
|
||||
if !input.Left && !input.Right {
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "LEFT_UP",
|
||||
Sequence: input.Sequence,
|
||||
})
|
||||
g.conn.Publish("game.input", game.ClientInput{
|
||||
g.publishInput(game.ClientInput{
|
||||
PlayerID: myID,
|
||||
Type: "RIGHT_UP",
|
||||
Sequence: input.Sequence,
|
||||
@@ -794,113 +776,8 @@ func (g *Game) getMyPlayerID() string {
|
||||
return g.playerName
|
||||
}
|
||||
|
||||
// loadOrCreatePlayerCode wird in storage_*.go implementiert (platform-specific)
|
||||
|
||||
// submitScore sendet den individuellen Score an den Server (für Solo-Mode)
|
||||
func (g *Game) submitScore() {
|
||||
if g.scoreSubmitted || !g.connected {
|
||||
return
|
||||
}
|
||||
|
||||
// Finde eigenen Score
|
||||
myScore := 0
|
||||
for _, p := range g.gameState.Players {
|
||||
if p.Name == g.playerName {
|
||||
myScore = p.Score
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
submission := game.ScoreSubmission{
|
||||
PlayerName: g.playerName,
|
||||
PlayerCode: g.playerCode,
|
||||
Score: myScore,
|
||||
}
|
||||
|
||||
g.conn.Publish("score.submit", submission)
|
||||
g.scoreSubmitted = true
|
||||
log.Printf("📊 Score eingereicht: %d Punkte", myScore)
|
||||
|
||||
// Leaderboard abrufen
|
||||
g.requestLeaderboard()
|
||||
}
|
||||
|
||||
// submitTeamScore sendet den Team-Score an den Server (für Coop-Mode)
|
||||
func (g *Game) submitTeamScore() {
|
||||
if g.scoreSubmitted || !g.connected || g.teamName == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Berechne Team-Score (Summe aller Spieler-Scores)
|
||||
teamScore := 0
|
||||
for _, p := range g.gameState.Players {
|
||||
teamScore += p.Score
|
||||
}
|
||||
|
||||
submission := game.ScoreSubmission{
|
||||
PlayerName: g.teamName, // Team-Name statt Spieler-Name
|
||||
PlayerCode: g.playerCode,
|
||||
Score: teamScore,
|
||||
}
|
||||
|
||||
g.conn.Publish("score.submit", submission)
|
||||
g.scoreSubmitted = true
|
||||
g.activeField = ""
|
||||
log.Printf("📊 Team-Score eingereicht: %s - %d Punkte", g.teamName, teamScore)
|
||||
|
||||
// Leaderboard abrufen
|
||||
g.requestLeaderboard()
|
||||
}
|
||||
|
||||
// requestLeaderboard fordert das Leaderboard vom Server an (asynchron)
|
||||
func (g *Game) requestLeaderboard() {
|
||||
if !g.connected {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
inbox := g.conn.Conn.NewRespInbox()
|
||||
sub, err := g.conn.Subscribe(inbox, func(entries *[]game.LeaderboardEntry) {
|
||||
g.leaderboardMutex.Lock()
|
||||
g.leaderboard = *entries
|
||||
g.leaderboardMutex.Unlock()
|
||||
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(*entries))
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Fehler beim Leaderboard-Request: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Request senden
|
||||
g.conn.PublishRequest("leaderboard.get", inbox, &struct{}{})
|
||||
|
||||
// Warte kurz auf Antwort, dann unsubscribe
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
sub.Unsubscribe()
|
||||
}()
|
||||
}
|
||||
|
||||
func (g *Game) connectForLeaderboard() {
|
||||
serverURL := "nats://localhost:4222"
|
||||
nc, err := nats.Connect(serverURL)
|
||||
if err != nil {
|
||||
log.Printf("❌ NATS Verbindung fehlgeschlagen: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
g.conn, err = nats.NewEncodedConn(nc, nats.JSON_ENCODER)
|
||||
if err != nil {
|
||||
log.Printf("❌ EncodedConn Fehler: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
g.connected = true
|
||||
log.Println("✅ Verbunden für Leaderboard")
|
||||
|
||||
// Leaderboard abrufen
|
||||
g.requestLeaderboard()
|
||||
}
|
||||
// submitScore, requestLeaderboard, connectForLeaderboard
|
||||
// sind in connection_native.go und connection_wasm.go definiert
|
||||
|
||||
func (g *Game) updateLeaderboard() {
|
||||
// Back Button (oben links) - Touch Support
|
||||
@@ -977,12 +854,46 @@ func (g *Game) DrawLeaderboard(screen *ebiten.Image) {
|
||||
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
|
||||
}
|
||||
|
||||
func main() {
|
||||
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
|
||||
ebiten.SetWindowTitle("Escape From Teacher")
|
||||
ebiten.SetTPS(60) // Tick Per Second auf 60 setzen
|
||||
ebiten.SetVsyncEnabled(true) // VSync aktivieren
|
||||
if err := ebiten.RunGame(NewGame()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
// main() ist jetzt in main_wasm.go und main_native.go definiert
|
||||
|
||||
// drawVolumeSlider zeichnet einen Volume-Slider
|
||||
func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) {
|
||||
// Hintergrund
|
||||
vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false)
|
||||
vector.StrokeRect(screen, float32(x), float32(y), float32(width), 10, 1, color.White, false)
|
||||
|
||||
// Füllstand
|
||||
fillWidth := int(float64(width) * volume)
|
||||
vector.DrawFilledRect(screen, float32(x), float32(y), float32(fillWidth), 10, color.RGBA{0, 200, 100, 255}, false)
|
||||
|
||||
// Prozent-Anzeige
|
||||
pct := fmt.Sprintf("%.0f%%", volume*100)
|
||||
text.Draw(screen, pct, basicfont.Face7x13, x+width+10, y+10, ColText)
|
||||
}
|
||||
|
||||
// isSliderHit prüft, ob auf einen Slider geklickt wurde
|
||||
func isSliderHit(x, y, width, height int) bool {
|
||||
// Erweitere den Klickbereich vertikal für bessere Touch-Support
|
||||
return isHit(x, y-10, width, height+20)
|
||||
}
|
||||
|
||||
// getSliderValue berechnet den Slider-Wert basierend auf Mausposition
|
||||
func getSliderValue(sliderX, sliderWidth int) float64 {
|
||||
mx, _ := ebiten.CursorPosition()
|
||||
// Bei Touch: Ersten Touch nutzen
|
||||
touches := ebiten.TouchIDs()
|
||||
if len(touches) > 0 {
|
||||
mx, _ = ebiten.TouchPosition(touches[0])
|
||||
}
|
||||
|
||||
// Berechne relative Position im Slider
|
||||
relX := float64(mx - sliderX)
|
||||
if relX < 0 {
|
||||
relX = 0
|
||||
}
|
||||
if relX > float64(sliderWidth) {
|
||||
relX = float64(sliderWidth)
|
||||
}
|
||||
|
||||
return relX / float64(sliderWidth)
|
||||
}
|
||||
|
||||
20
cmd/client/main_native.go
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -63,20 +63,60 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
|
||||
}
|
||||
}
|
||||
|
||||
// Setze vorhergesagte Position auf Server-Position
|
||||
g.predictedX = serverState.X
|
||||
g.predictedY = serverState.Y
|
||||
g.predictedVX = serverState.VX
|
||||
g.predictedVY = serverState.VY
|
||||
g.predictedGround = serverState.OnGround
|
||||
// Temporäre Position für Replay
|
||||
replayX := serverState.X
|
||||
replayY := serverState.Y
|
||||
replayVX := serverState.VX
|
||||
replayVY := serverState.VY
|
||||
replayGround := serverState.OnGround
|
||||
|
||||
// Replay alle noch nicht bestätigten Inputs
|
||||
// (Sortiert nach Sequenz)
|
||||
if len(g.pendingInputs) > 0 {
|
||||
for seq := g.lastServerSeq + 1; seq <= g.inputSequence; seq++ {
|
||||
if input, ok := g.pendingInputs[seq]; ok {
|
||||
// Temporär auf Replay-Position setzen
|
||||
oldX, oldY := g.predictedX, g.predictedY
|
||||
oldVX, oldVY := g.predictedVX, g.predictedVY
|
||||
oldGround := g.predictedGround
|
||||
|
||||
g.predictedX = replayX
|
||||
g.predictedY = replayY
|
||||
g.predictedVX = replayVX
|
||||
g.predictedVY = replayVY
|
||||
g.predictedGround = replayGround
|
||||
|
||||
g.ApplyInput(input)
|
||||
|
||||
replayX = g.predictedX
|
||||
replayY = g.predictedY
|
||||
replayVX = g.predictedVX
|
||||
replayVY = g.predictedVY
|
||||
replayGround = g.predictedGround
|
||||
|
||||
// Zurücksetzen
|
||||
g.predictedX = oldX
|
||||
g.predictedY = oldY
|
||||
g.predictedVX = oldVX
|
||||
g.predictedVY = oldVY
|
||||
g.predictedGround = oldGround
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Berechne Differenz zwischen aktueller Prediction und Server-Replay
|
||||
diffX := replayX - g.predictedX
|
||||
diffY := replayY - g.predictedY
|
||||
|
||||
// Nur korrigieren wenn Differenz signifikant (> 5 Pixel)
|
||||
const threshold = 5.0
|
||||
if diffX*diffX+diffY*diffY > threshold*threshold {
|
||||
// Speichere Korrektur für sanfte Interpolation
|
||||
g.correctionX = diffX
|
||||
g.correctionY = diffY
|
||||
}
|
||||
|
||||
// Velocity und Ground immer sofort übernehmen
|
||||
g.predictedVX = replayVX
|
||||
g.predictedVY = replayVY
|
||||
g.predictedGround = replayGround
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"encoding/hex"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// loadOrCreatePlayerCode lädt oder erstellt einen eindeutigen Spieler-Code (Desktop Version)
|
||||
@@ -35,3 +36,28 @@ func (g *Game) loadOrCreatePlayerCode() {
|
||||
|
||||
log.Printf("🆕 Neuer Player-Code erstellt: %s", g.playerCode)
|
||||
}
|
||||
|
||||
// loadPlayerName lädt gespeicherten Spielernamen (Desktop Version)
|
||||
func (g *Game) loadPlayerName() string {
|
||||
const nameFile = "player_name.txt"
|
||||
|
||||
if data, err := ioutil.ReadFile(nameFile); err == nil {
|
||||
name := strings.TrimSpace(string(data))
|
||||
if name != "" {
|
||||
log.Printf("👤 Spielername geladen: %s", name)
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// savePlayerName speichert Spielernamen (Desktop Version)
|
||||
func (g *Game) savePlayerName(name string) {
|
||||
const nameFile = "player_name.txt"
|
||||
|
||||
if err := ioutil.WriteFile(nameFile, []byte(name), 0644); err != nil {
|
||||
log.Printf("⚠️ Fehler beim Speichern des Spielernamens: %v", err)
|
||||
} else {
|
||||
log.Printf("💾 Spielername gespeichert: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
../assets
|
||||
341
cmd/client/web/game.js
Normal 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
@@ -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
1
cmd/client/web/style.css
Normal file
575
cmd/client/web/wasm_exec.js
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -28,10 +28,11 @@ const (
|
||||
AssetFile = "./cmd/client/assets/assets.json"
|
||||
ChunkDir = "./cmd/client/assets/chunks"
|
||||
|
||||
SidebarWidth = 250
|
||||
TopBarHeight = 40
|
||||
CanvasHeight = 720
|
||||
CanvasWidth = 1280
|
||||
LeftSidebarWidth = 250
|
||||
RightSidebarWidth = 250
|
||||
TopBarHeight = 40
|
||||
CanvasHeight = 720
|
||||
CanvasWidth = 1280
|
||||
|
||||
TileSize = 64
|
||||
RefFloorY = 540
|
||||
@@ -55,12 +56,15 @@ type LevelEditor struct {
|
||||
assetList []string
|
||||
assetsImages map[string]*ebiten.Image
|
||||
|
||||
currentChunk game.Chunk
|
||||
currentChunk game.Chunk
|
||||
currentChunkFile string // Aktuell geladene Datei
|
||||
chunkFiles []string // Liste aller Chunk-Dateien
|
||||
|
||||
scrollX float64
|
||||
zoom float64
|
||||
listScroll float64
|
||||
statusMsg string
|
||||
scrollX float64
|
||||
zoom float64
|
||||
listScroll float64
|
||||
chunkListScroll float64 // Scroll für Chunk-Liste
|
||||
statusMsg string
|
||||
|
||||
showGrid bool
|
||||
enableSnap bool
|
||||
@@ -75,20 +79,31 @@ type LevelEditor struct {
|
||||
dragAssetID string
|
||||
dragTargetIndex int
|
||||
dragOffset game.Vec2
|
||||
|
||||
// Bewegende Plattform-Modus
|
||||
movingPlatformMode bool // Ist Bewegende-Plattform-Modus aktiv?
|
||||
movingPlatformObjIndex int // Index des aktuellen Plattform-Objekts
|
||||
movingPlatformSetStart bool // true = setze Start, false = setze End
|
||||
}
|
||||
|
||||
func NewLevelEditor() *LevelEditor {
|
||||
le := &LevelEditor{
|
||||
assetsImages: make(map[string]*ebiten.Image),
|
||||
currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
|
||||
zoom: 1.0,
|
||||
showGrid: true,
|
||||
enableSnap: true,
|
||||
showHitbox: true,
|
||||
showPlayerRef: true, // Standardmäßig an
|
||||
assetsImages: make(map[string]*ebiten.Image),
|
||||
currentChunk: game.Chunk{ID: "chunk_new", Width: 50, Objects: []game.LevelObject{}},
|
||||
zoom: 1.0,
|
||||
showGrid: true,
|
||||
enableSnap: true,
|
||||
showHitbox: true,
|
||||
showPlayerRef: true, // Standardmäßig an
|
||||
movingPlatformObjIndex: -1,
|
||||
}
|
||||
le.LoadAssets()
|
||||
le.LoadChunk("chunk_01.json")
|
||||
le.RefreshChunkList()
|
||||
if len(le.chunkFiles) > 0 {
|
||||
le.LoadChunk(le.chunkFiles[0])
|
||||
} else {
|
||||
le.currentChunkFile = "chunk_new.json"
|
||||
}
|
||||
return le
|
||||
}
|
||||
|
||||
@@ -117,29 +132,82 @@ func (le *LevelEditor) LoadAssets() {
|
||||
sort.Strings(le.assetList)
|
||||
}
|
||||
|
||||
func (le *LevelEditor) RefreshChunkList() {
|
||||
le.chunkFiles = []string{}
|
||||
files, err := ioutil.ReadDir(ChunkDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, f := range files {
|
||||
if !f.IsDir() && strings.HasSuffix(f.Name(), ".json") {
|
||||
le.chunkFiles = append(le.chunkFiles, f.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(le.chunkFiles)
|
||||
}
|
||||
|
||||
func (le *LevelEditor) LoadChunk(filename string) {
|
||||
path := filepath.Join(ChunkDir, filename)
|
||||
data, err := ioutil.ReadFile(path)
|
||||
if err == nil {
|
||||
json.Unmarshal(data, &le.currentChunk)
|
||||
le.currentChunkFile = filename
|
||||
le.statusMsg = "Geladen: " + filename
|
||||
} else {
|
||||
le.currentChunk.ID = strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
le.currentChunk.Width = 50
|
||||
le.currentChunk.Objects = []game.LevelObject{}
|
||||
le.currentChunkFile = filename
|
||||
le.statusMsg = "Neu erstellt: " + le.currentChunk.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (le *LevelEditor) CreateNewChunk(name string) {
|
||||
if name == "" {
|
||||
name = "chunk_new"
|
||||
}
|
||||
filename := name + ".json"
|
||||
le.currentChunk = game.Chunk{
|
||||
ID: name,
|
||||
Width: 50,
|
||||
Objects: []game.LevelObject{},
|
||||
}
|
||||
le.currentChunkFile = filename
|
||||
le.SaveChunk()
|
||||
le.RefreshChunkList()
|
||||
le.statusMsg = "Neuer Chunk erstellt: " + filename
|
||||
}
|
||||
|
||||
func (le *LevelEditor) DeleteChunk(filename string) {
|
||||
path := filepath.Join(ChunkDir, filename)
|
||||
err := os.Remove(path)
|
||||
if err == nil {
|
||||
le.statusMsg = "Gelöscht: " + filename
|
||||
le.RefreshChunkList()
|
||||
// Lade ersten verfügbaren Chunk oder erstelle neuen
|
||||
if len(le.chunkFiles) > 0 {
|
||||
le.LoadChunk(le.chunkFiles[0])
|
||||
} else {
|
||||
le.CreateNewChunk("chunk_new")
|
||||
}
|
||||
} else {
|
||||
le.statusMsg = "Fehler beim Löschen: " + err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
func (le *LevelEditor) SaveChunk() {
|
||||
os.MkdirAll(ChunkDir, 0755)
|
||||
filename := le.currentChunk.ID + ".json"
|
||||
path := filepath.Join(ChunkDir, filename)
|
||||
data, _ := json.MarshalIndent(le.currentChunk, "", " ")
|
||||
ioutil.WriteFile(path, data, 0644)
|
||||
le.currentChunkFile = filename
|
||||
le.RefreshChunkList()
|
||||
le.statusMsg = "GESPEICHERT als " + filename
|
||||
}
|
||||
|
||||
func (le *LevelEditor) ScreenToWorld(mx, my int) (float64, float64) {
|
||||
screenX := float64(mx - SidebarWidth)
|
||||
screenX := float64(mx - LeftSidebarWidth)
|
||||
screenY := float64(my - TopBarHeight)
|
||||
worldX := (screenX / le.zoom) + le.scrollX
|
||||
worldY := screenY / le.zoom
|
||||
@@ -200,6 +268,17 @@ func (le *LevelEditor) Update() error {
|
||||
le.currentChunk.Width = v
|
||||
}
|
||||
}
|
||||
if le.activeField == "newchunk" {
|
||||
le.CreateNewChunk(le.inputBuffer)
|
||||
}
|
||||
if le.activeField == "mpspeed" {
|
||||
if v, err := strconv.ParseFloat(le.inputBuffer, 64); err == nil && le.movingPlatformObjIndex != -1 {
|
||||
if le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform != nil {
|
||||
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.Speed = v
|
||||
le.statusMsg = fmt.Sprintf("Speed gesetzt: %.0f", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
le.activeField = ""
|
||||
} else if inpututil.IsKeyJustPressed(ebiten.KeyEscape) {
|
||||
le.activeField = ""
|
||||
@@ -214,7 +293,9 @@ func (le *LevelEditor) Update() error {
|
||||
}
|
||||
|
||||
// Hotkeys
|
||||
if mx > SidebarWidth {
|
||||
canvasStartX := LeftSidebarWidth
|
||||
canvasEndX := CanvasWidth - RightSidebarWidth
|
||||
if mx > canvasStartX && mx < canvasEndX {
|
||||
_, wy := ebiten.Wheel()
|
||||
if wy != 0 {
|
||||
le.zoom += wy * 0.1
|
||||
@@ -238,6 +319,15 @@ func (le *LevelEditor) Update() error {
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyP) {
|
||||
le.showPlayerRef = !le.showPlayerRef
|
||||
} // NEU: Toggle Player
|
||||
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
|
||||
le.movingPlatformMode = !le.movingPlatformMode
|
||||
if !le.movingPlatformMode {
|
||||
le.movingPlatformObjIndex = -1
|
||||
le.statusMsg = "Moving Platform Mode deaktiviert"
|
||||
} else {
|
||||
le.statusMsg = "Moving Platform Mode: Plattform auswählen"
|
||||
}
|
||||
}
|
||||
if ebiten.IsKeyPressed(ebiten.KeyRight) {
|
||||
le.scrollX += 10 / le.zoom
|
||||
}
|
||||
@@ -263,8 +353,8 @@ func (le *LevelEditor) Update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Palette
|
||||
if mx < SidebarWidth {
|
||||
// Left Sidebar - Asset Palette
|
||||
if mx < LeftSidebarWidth {
|
||||
_, wy := ebiten.Wheel()
|
||||
le.listScroll -= wy * 20
|
||||
if le.listScroll < 0 {
|
||||
@@ -284,23 +374,119 @@ func (le *LevelEditor) Update() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Canvas Logic
|
||||
// Right Sidebar - Chunk Manager
|
||||
if mx > CanvasWidth-RightSidebarWidth {
|
||||
_, wy := ebiten.Wheel()
|
||||
le.chunkListScroll -= wy * 20
|
||||
if le.chunkListScroll < 0 {
|
||||
le.chunkListScroll = 0
|
||||
}
|
||||
|
||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
|
||||
// "Neuer Chunk" Button (Y: TopBarHeight+30 bis TopBarHeight+60)
|
||||
if mx >= CanvasWidth-RightSidebarWidth+10 && mx < CanvasWidth-RightSidebarWidth+240 &&
|
||||
my >= TopBarHeight+30 && my < TopBarHeight+60 {
|
||||
le.activeField = "newchunk"
|
||||
le.inputBuffer = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// Chunk-Liste (startet bei TopBarHeight+70)
|
||||
if my >= TopBarHeight+70 {
|
||||
clickY := float64(my-TopBarHeight-70) + le.chunkListScroll
|
||||
idx := int(clickY / 30)
|
||||
if idx >= 0 && idx < len(le.chunkFiles) {
|
||||
le.LoadChunk(le.chunkFiles[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rechtsklick zum Löschen in Chunk-Liste
|
||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) && my >= TopBarHeight+70 {
|
||||
clickY := float64(my-TopBarHeight-70) + le.chunkListScroll
|
||||
idx := int(clickY / 30)
|
||||
if idx >= 0 && idx < len(le.chunkFiles) {
|
||||
if len(le.chunkFiles) > 1 || le.chunkFiles[idx] != le.currentChunkFile {
|
||||
le.DeleteChunk(le.chunkFiles[idx])
|
||||
} else {
|
||||
le.statusMsg = "Kann einzigen Chunk nicht löschen!"
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Canvas Logic (nur wenn wir wirklich im Canvas-Bereich sind)
|
||||
if mx < LeftSidebarWidth || mx > CanvasWidth-RightSidebarWidth || my < TopBarHeight {
|
||||
return nil
|
||||
}
|
||||
|
||||
worldX, worldY := le.ScreenToWorld(mx, my)
|
||||
|
||||
// DELETE
|
||||
// MOVING PLATFORM MODE
|
||||
if le.movingPlatformMode && inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) {
|
||||
// Keine Plattform ausgewählt? → Plattform auswählen
|
||||
if le.movingPlatformObjIndex == -1 {
|
||||
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||
obj := le.currentChunk.Objects[i]
|
||||
assetDef, ok := le.assetManifest.Assets[obj.AssetID]
|
||||
if !ok || assetDef.Type != "platform" {
|
||||
continue
|
||||
}
|
||||
w, h := le.GetAssetSize(obj.AssetID)
|
||||
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
|
||||
le.movingPlatformObjIndex = i
|
||||
le.movingPlatformSetStart = true
|
||||
|
||||
// Wenn noch keine MovingPlatform-Daten → initialisiere
|
||||
if obj.MovingPlatform == nil {
|
||||
le.currentChunk.Objects[i].MovingPlatform = &game.MovingPlatformData{
|
||||
StartX: obj.X,
|
||||
StartY: obj.Y,
|
||||
EndX: obj.X + 200, // Default Endpunkt
|
||||
EndY: obj.Y,
|
||||
Speed: 100, // Default Speed
|
||||
}
|
||||
}
|
||||
le.statusMsg = "Plattform gewählt - Klicke Start-Punkt"
|
||||
le.activeField = "mpspeed"
|
||||
le.inputBuffer = fmt.Sprintf("%.0f", le.currentChunk.Objects[i].MovingPlatform.Speed)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Plattform ist ausgewählt → setze Start oder End
|
||||
if le.movingPlatformSetStart {
|
||||
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartX = worldX
|
||||
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.StartY = worldY
|
||||
le.movingPlatformSetStart = false
|
||||
le.statusMsg = "Start gesetzt - Klicke End-Punkt"
|
||||
} else {
|
||||
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndX = worldX
|
||||
le.currentChunk.Objects[le.movingPlatformObjIndex].MovingPlatform.EndY = worldY
|
||||
le.statusMsg = "End gesetzt - Drücke M zum Beenden oder wähle neue Plattform"
|
||||
le.movingPlatformObjIndex = -1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DELETE mit Rechtsklick
|
||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) {
|
||||
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||
obj := le.currentChunk.Objects[i]
|
||||
w, h := le.GetAssetSize(obj.AssetID)
|
||||
if worldX >= obj.X && worldX <= obj.X+w && worldY >= obj.Y && worldY <= obj.Y+h {
|
||||
le.currentChunk.Objects = append(le.currentChunk.Objects[:i], le.currentChunk.Objects[i+1:]...)
|
||||
le.statusMsg = fmt.Sprintf("Objekt gelöscht: %s", obj.AssetID)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MOVE
|
||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging {
|
||||
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !le.isDragging && !le.movingPlatformMode {
|
||||
for i := len(le.currentChunk.Objects) - 1; i >= 0; i-- {
|
||||
obj := le.currentChunk.Objects[i]
|
||||
w, h := le.GetAssetSize(obj.AssetID)
|
||||
@@ -352,11 +538,13 @@ func (le *LevelEditor) Update() error {
|
||||
func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
||||
// UI HINTERGRUND
|
||||
vector.DrawFilledRect(screen, 0, 0, CanvasWidth, TopBarHeight, ColBgTop, false)
|
||||
vector.DrawFilledRect(screen, 0, TopBarHeight, SidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
||||
vector.DrawFilledRect(screen, 0, TopBarHeight, LeftSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
||||
vector.DrawFilledRect(screen, CanvasWidth-RightSidebarWidth, TopBarHeight, RightSidebarWidth, CanvasHeight-TopBarHeight, ColBgSidebar, false)
|
||||
|
||||
text.Draw(screen, "ID: "+le.currentChunk.ID, basicfont.Face7x13, 75, 25, color.White)
|
||||
|
||||
// ASSET LISTE
|
||||
// LEFT SIDEBAR - ASSET LISTE
|
||||
text.Draw(screen, "ASSETS", basicfont.Face7x13, 10, TopBarHeight+20, ColHighlight)
|
||||
startY := float64(TopBarHeight+40) - le.listScroll
|
||||
for i, id := range le.assetList {
|
||||
y := startY + float64(i*25)
|
||||
@@ -370,26 +558,81 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
||||
text.Draw(screen, id, basicfont.Face7x13, 10, int(y+15), col)
|
||||
}
|
||||
|
||||
// RIGHT SIDEBAR - CHUNK MANAGER
|
||||
rightX := CanvasWidth - RightSidebarWidth
|
||||
text.Draw(screen, "CHUNKS", basicfont.Face7x13, rightX+10, TopBarHeight+20, ColHighlight)
|
||||
|
||||
// "Neuer Chunk" Button
|
||||
btnX := float32(rightX + 10)
|
||||
btnY := float32(TopBarHeight + 30)
|
||||
btnW := float32(230)
|
||||
btnH := float32(30)
|
||||
vector.DrawFilledRect(screen, btnX, btnY, btnW, btnH, color.RGBA{60, 120, 80, 255}, false)
|
||||
vector.StrokeRect(screen, btnX, btnY, btnW, btnH, 2, ColHighlight, false)
|
||||
|
||||
if le.activeField == "newchunk" {
|
||||
text.Draw(screen, "Name: "+le.inputBuffer+"_", basicfont.Face7x13, rightX+15, TopBarHeight+50, color.White)
|
||||
} else {
|
||||
text.Draw(screen, "[+] Neuer Chunk", basicfont.Face7x13, rightX+65, TopBarHeight+50, color.White)
|
||||
}
|
||||
|
||||
// Chunk-Liste
|
||||
chunkStartY := float64(TopBarHeight+70) - le.chunkListScroll
|
||||
for i, filename := range le.chunkFiles {
|
||||
y := chunkStartY + float64(i*30)
|
||||
if y < float64(TopBarHeight+70) || y > CanvasHeight {
|
||||
continue
|
||||
}
|
||||
|
||||
col := ColText
|
||||
bgCol := color.RGBA{50, 54, 62, 255}
|
||||
if filename == le.currentChunkFile {
|
||||
col = color.RGBA{100, 255, 100, 255}
|
||||
bgCol = color.RGBA{40, 80, 40, 255}
|
||||
}
|
||||
|
||||
// Hintergrund für aktuellen Chunk
|
||||
vector.DrawFilledRect(screen, float32(rightX+5), float32(y), float32(RightSidebarWidth-10), 28, bgCol, false)
|
||||
|
||||
// Dateiname
|
||||
displayName := strings.TrimSuffix(filename, ".json")
|
||||
if len(displayName) > 20 {
|
||||
displayName = displayName[:20] + "..."
|
||||
}
|
||||
text.Draw(screen, displayName, basicfont.Face7x13, rightX+10, int(y+18), col)
|
||||
}
|
||||
|
||||
// Hinweis
|
||||
text.Draw(screen, "L-Click: Load", basicfont.Face7x13, rightX+10, CanvasHeight-40, color.Gray{100})
|
||||
text.Draw(screen, "R-Click: Delete", basicfont.Face7x13, rightX+10, CanvasHeight-25, color.Gray{100})
|
||||
|
||||
// CANVAS
|
||||
canvasOffX := float64(SidebarWidth)
|
||||
canvasOffX := float64(LeftSidebarWidth)
|
||||
canvasOffY := float64(TopBarHeight)
|
||||
canvasWidth := float32(CanvasWidth - LeftSidebarWidth - RightSidebarWidth)
|
||||
|
||||
// Canvas Hintergrund
|
||||
vector.DrawFilledRect(screen, float32(canvasOffX), float32(canvasOffY), canvasWidth, CanvasHeight-TopBarHeight, ColBgCanvas, false)
|
||||
|
||||
// GRID
|
||||
canvasEndX := float32(CanvasWidth - RightSidebarWidth)
|
||||
if le.showGrid {
|
||||
startGridX := int(le.scrollX/TileSize) * TileSize
|
||||
for x := startGridX; x < startGridX+int(CanvasWidth/le.zoom)+TileSize; x += TileSize {
|
||||
for x := startGridX; x < startGridX+int(float64(canvasWidth)/le.zoom)+TileSize; x += TileSize {
|
||||
drawX := float32((float64(x)-le.scrollX)*le.zoom + canvasOffX)
|
||||
vector.StrokeLine(screen, drawX, float32(canvasOffY), drawX, CanvasHeight, 1, ColGrid, false)
|
||||
if drawX >= float32(canvasOffX) && drawX <= canvasEndX {
|
||||
vector.StrokeLine(screen, drawX, float32(canvasOffY), drawX, CanvasHeight, 1, ColGrid, false)
|
||||
}
|
||||
}
|
||||
for y := 0; y < int(CanvasHeight/le.zoom); y += TileSize {
|
||||
drawY := float32(float64(y)*le.zoom + canvasOffY)
|
||||
vector.StrokeLine(screen, float32(canvasOffX), drawY, CanvasWidth, drawY, 1, ColGrid, false)
|
||||
vector.StrokeLine(screen, float32(canvasOffX), drawY, canvasEndX, drawY, 1, ColGrid, false)
|
||||
}
|
||||
}
|
||||
|
||||
// BODEN LINIE
|
||||
floorScreenY := float32((RefFloorY * le.zoom) + canvasOffY)
|
||||
vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, float32(CanvasWidth), floorScreenY, 2, ColFloor, false)
|
||||
vector.StrokeLine(screen, float32(canvasOffX), floorScreenY, canvasEndX, floorScreenY, 2, ColFloor, false)
|
||||
|
||||
// PLAYER REFERENCE (GHOST)
|
||||
// PLAYER REFERENCE (GHOST)
|
||||
@@ -422,14 +665,54 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
||||
}
|
||||
|
||||
// OBJEKTE
|
||||
for _, obj := range le.currentChunk.Objects {
|
||||
for i, obj := range le.currentChunk.Objects {
|
||||
le.DrawAsset(screen, obj.AssetID, obj.X, obj.Y, canvasOffX, canvasOffY, 1.0)
|
||||
|
||||
// MOVING PLATFORM MARKER
|
||||
if obj.MovingPlatform != nil {
|
||||
mpd := obj.MovingPlatform
|
||||
|
||||
// Start-Punkt (grün)
|
||||
sxStart := float32((mpd.StartX-le.scrollX)*le.zoom + canvasOffX)
|
||||
syStart := float32(mpd.StartY*le.zoom + canvasOffY)
|
||||
|
||||
// End-Punkt (rot)
|
||||
sxEnd := float32((mpd.EndX-le.scrollX)*le.zoom + canvasOffX)
|
||||
syEnd := float32(mpd.EndY*le.zoom + canvasOffY)
|
||||
|
||||
// Linie zwischen Start und End (gelb gestrichelt)
|
||||
vector.StrokeLine(screen, sxStart, syStart, sxEnd, syEnd, 2, color.RGBA{255, 255, 0, 200}, false)
|
||||
|
||||
// Start-Marker (grüner Kreis)
|
||||
vector.DrawFilledCircle(screen, sxStart, syStart, 8, color.RGBA{0, 255, 0, 255}, false)
|
||||
vector.StrokeCircle(screen, sxStart, syStart, 8, 2, color.RGBA{0, 200, 0, 255}, false)
|
||||
|
||||
// End-Marker (roter Kreis)
|
||||
vector.DrawFilledCircle(screen, sxEnd, syEnd, 8, color.RGBA{255, 0, 0, 255}, false)
|
||||
vector.StrokeCircle(screen, sxEnd, syEnd, 8, 2, color.RGBA{200, 0, 0, 255}, false)
|
||||
|
||||
// Speed Label
|
||||
midX := int((sxStart + sxEnd) / 2)
|
||||
midY := int((syStart + syEnd) / 2)
|
||||
speedLabel := fmt.Sprintf("%.0f u/s", mpd.Speed)
|
||||
text.Draw(screen, speedLabel, basicfont.Face7x13, midX-20, midY-10, color.RGBA{255, 255, 0, 255})
|
||||
}
|
||||
|
||||
// Highlight wenn ausgewählt im Moving Platform Mode
|
||||
if le.movingPlatformMode && i == le.movingPlatformObjIndex {
|
||||
w, h := le.GetAssetSize(obj.AssetID)
|
||||
sX := float32((obj.X-le.scrollX)*le.zoom + canvasOffX)
|
||||
sY := float32(obj.Y*le.zoom + canvasOffY)
|
||||
sW := float32(w * le.zoom)
|
||||
sH := float32(h * le.zoom)
|
||||
vector.StrokeRect(screen, sX, sY, sW, sH, 3, color.RGBA{255, 255, 0, 255}, false)
|
||||
}
|
||||
}
|
||||
|
||||
// DRAG GHOST
|
||||
if le.isDragging && le.dragType == "new" {
|
||||
mx, my := ebiten.CursorPosition()
|
||||
if mx > SidebarWidth && my > TopBarHeight {
|
||||
if mx > LeftSidebarWidth && mx < CanvasWidth-RightSidebarWidth && my > TopBarHeight {
|
||||
wRawX, wRawY := le.ScreenToWorld(mx, my)
|
||||
_, h := le.GetAssetSize(le.dragAssetID)
|
||||
snapX, snapY := le.GetSmartSnap(wRawX, wRawY, h)
|
||||
@@ -441,15 +724,33 @@ func (le *LevelEditor) Draw(screen *ebiten.Image) {
|
||||
}
|
||||
|
||||
// STATUS
|
||||
text.Draw(screen, "[S]ave | [G]rid | [H]itbox | [P]layer Ref | R-Click=Del", basicfont.Face7x13, 400, 25, color.Gray{100})
|
||||
text.Draw(screen, le.statusMsg, basicfont.Face7x13, SidebarWidth+10, CanvasHeight-10, ColHighlight)
|
||||
statusText := "[S]ave | [G]rid | [H]itbox | [P]layer | [M]oving Platform | R-Click=Del"
|
||||
text.Draw(screen, statusText, basicfont.Face7x13, 380, 25, color.Gray{100})
|
||||
|
||||
// Moving Platform Mode Indicator
|
||||
if le.movingPlatformMode {
|
||||
modeText := "🟡 MOVING PLATFORM MODE"
|
||||
text.Draw(screen, modeText, basicfont.Face7x13, LeftSidebarWidth+10, TopBarHeight+20, color.RGBA{255, 255, 0, 255})
|
||||
|
||||
// Speed Input Field wenn Plattform ausgewählt
|
||||
if le.movingPlatformObjIndex != -1 && le.activeField == "mpspeed" {
|
||||
speedFieldX := LeftSidebarWidth + 10
|
||||
speedFieldY := TopBarHeight + 40
|
||||
fieldText := "Speed: " + le.inputBuffer + "_"
|
||||
text.Draw(screen, fieldText, basicfont.Face7x13, speedFieldX, speedFieldY, color.RGBA{0, 255, 0, 255})
|
||||
text.Draw(screen, "[Enter] to confirm", basicfont.Face7x13, speedFieldX, speedFieldY+20, color.Gray{150})
|
||||
}
|
||||
}
|
||||
|
||||
text.Draw(screen, le.statusMsg, basicfont.Face7x13, LeftSidebarWidth+10, CanvasHeight-10, ColHighlight)
|
||||
}
|
||||
|
||||
func (le *LevelEditor) DrawAsset(screen *ebiten.Image, id string, wX, wY, offX, offY float64, alpha float32) {
|
||||
sX := (wX-le.scrollX)*le.zoom + offX
|
||||
sY := wY*le.zoom + offY
|
||||
|
||||
if sX < SidebarWidth-100 || sX > CanvasWidth {
|
||||
// Culling: Nicht zeichnen wenn außerhalb Canvas
|
||||
if sX < float64(LeftSidebarWidth)-100 || sX > float64(CanvasWidth-RightSidebarWidth)+100 {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -113,15 +113,33 @@ func main() {
|
||||
}
|
||||
})
|
||||
|
||||
// 6. HANDLER: LEADERBOARD REQUEST
|
||||
// 6. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität)
|
||||
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
|
||||
top10 := server.GlobalLeaderboard.GetTop10()
|
||||
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
|
||||
ec.Publish(reply, top10)
|
||||
})
|
||||
|
||||
// 7. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway)
|
||||
_, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) {
|
||||
top10 := server.GlobalLeaderboard.GetTop10()
|
||||
log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10))
|
||||
|
||||
// Response an den angegebenen Channel senden
|
||||
if req.ResponseChannel != "" {
|
||||
resp := game.LeaderboardResponse{
|
||||
Entries: top10,
|
||||
}
|
||||
ec.Publish(req.ResponseChannel, &resp)
|
||||
log.Printf("📤 Leaderboard-Response gesendet an %s", req.ResponseChannel)
|
||||
}
|
||||
})
|
||||
|
||||
log.Println("✅ Server bereit. Warte auf Spieler...")
|
||||
|
||||
// 5. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients)
|
||||
go StartWebSocketGateway("8080", ec)
|
||||
|
||||
// Block forever
|
||||
select {}
|
||||
}
|
||||
|
||||
228
cmd/server/websocket_gateway.go
Normal 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)
|
||||
}
|
||||
}
|
||||