Private
Public Access
1
0

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

This commit is contained in:
Sebastian Unterschütz
2026-01-04 01:25:04 +01:00
parent 85d697df19
commit 3232ee7c2f
86 changed files with 4931 additions and 486 deletions
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="0@localhost" uuid="5887da40-2735-46aa-8690-99c28ab9eb2e">
<driver-ref>redis</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>jdbc.RedisDriver</jdbc-driver>
<jdbc-url>jdbc:redis://localhost:6379/0</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="5887da40-2735-46aa-8690-99c28ab9eb2e" />
</component>
</project>
View File
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="Redis" />
</component>
</project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 685 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Executable
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Executable
BIN
View File
Binary file not shown.
+95 -7
View File
@@ -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)
+187 -7
View File
@@ -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

+5
View File
@@ -31,6 +31,11 @@
"AssetID": "pc-trash",
"X": 1960,
"Y": 533
},
{
"AssetID": "coin",
"X": 1024,
"Y": 412
}
]
}
+61
View File
@@ -0,0 +1,61 @@
{
"ID": "chunk_02",
"Width": 50,
"Objects": [
{
"AssetID": "coin",
"X": 512,
"Y": 476
},
{
"AssetID": "coin",
"X": 704,
"Y": 476
},
{
"AssetID": "coin",
"X": 1024,
"Y": 476
},
{
"AssetID": "platform_1767135546",
"X": 1152,
"Y": 484
},
{
"AssetID": "platform_1767135546",
"X": 1344,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1472,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1600,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1792,
"Y": 292
},
{
"AssetID": "platform_1767135546",
"X": 1920,
"Y": 292
},
{
"AssetID": "platform_1767135546",
"X": 2048,
"Y": 292
},
{
"AssetID": "coin",
"X": 2112,
"Y": 220
}
]
}
+65
View File
@@ -0,0 +1,65 @@
{
"ID": "chunk_03",
"Width": 50,
"Objects": [
{
"AssetID": "platform_1767135546",
"X": 514,
"Y": 519,
"moving_platform": {
"start_x": 514,
"start_y": 522,
"end_x": 800,
"end_y": 239,
"speed": 100
}
},
{
"AssetID": "coin",
"X": 834,
"Y": 204
},
{
"AssetID": "wall_1767369789",
"X": 1026,
"Y": 539
},
{
"AssetID": "wall_1767369789",
"X": 1026,
"Y": 412
},
{
"AssetID": "platform_1767135546",
"X": 1091,
"Y": 318,
"moving_platform": {
"start_x": 1109,
"start_y": 304,
"end_x": 1898,
"end_y": 307,
"speed": 50
}
},
{
"AssetID": "desk",
"X": 1421,
"Y": 534
},
{
"AssetID": "desk",
"X": 1794,
"Y": 535
},
{
"AssetID": "coin",
"X": 1169,
"Y": 272
},
{
"AssetID": "coin",
"X": 1598,
"Y": 260
}
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"ID": "chunk_04",
"Width": 50,
"Objects": [
{
"AssetID": "godmode",
"X": 569,
"Y": 535
},
{
"AssetID": "jumpboost",
"X": 680,
"Y": 538
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

+55
View File
@@ -0,0 +1,55 @@
//go:build !wasm
// +build !wasm
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
func (g *Game) loadAssets() {
// Pfad anpassen: Wir suchen im relativen Pfad
baseDir := "./cmd/client/assets"
manifestPath := filepath.Join(baseDir, "assets.json")
data, err := ioutil.ReadFile(manifestPath)
if err == nil {
var m game.AssetManifest
json.Unmarshal(data, &m)
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen (Native):", len(m.Assets), "Einträge")
} else {
log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath)
// Fallback: Leeres Manifest, damit das Spiel nicht abstürzt
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
}
// Chunks laden (plattformspezifisch via Build-Tags)
g.loadChunks()
// Bilder vorladen
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder (Native): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}
+53
View File
@@ -0,0 +1,53 @@
//go:build wasm
// +build wasm
package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
//go:embed assets/assets.json
var assetsJSON []byte
func (g *Game) loadAssets() {
// Assets Manifest aus eingebetteter Datei laden
var m game.AssetManifest
if err := json.Unmarshal(assetsJSON, &m); err != nil {
log.Printf("⚠️ Fehler beim Parsen von assets.json: %v", err)
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
} else {
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen (WASM):", len(m.Assets), "Einträge")
}
// Chunks laden (auch per go:embed in chunks_wasm.go)
g.loadChunks()
// Bilder vorladen - In WASM sind die Assets relativ zum HTML
baseDir := "./assets"
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder (WASM): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}
+237
View File
@@ -0,0 +1,237 @@
package main
import (
"bytes"
_ "embed"
"log"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
const (
SampleRate = 44100 // Muss mit der MP3 Sample-Rate übereinstimmen
)
//go:embed assets/game.mp3
var gameMusicData []byte
//go:embed assets/jump.wav
var jumpSoundData []byte
//go:embed assets/pickupCoin.wav
var coinSoundData []byte
//go:embed assets/powerUp.wav
var powerUpSoundData []byte
// AudioSystem verwaltet Musik und Sound-Effekte
type AudioSystem struct {
audioContext *audio.Context
// Musik
musicPlayer *audio.Player
musicVolume float64
// Sound-Effekte
jumpSound []byte
coinSound []byte
powerUpSound []byte
sfxVolume float64
// Mute
muted bool
}
// NewAudioSystem erstellt ein neues Audio-System
func NewAudioSystem() *AudioSystem {
log.Println("🎵 Initialisiere Audio-System...")
ctx := audio.NewContext(SampleRate)
as := &AudioSystem{
audioContext: ctx,
musicVolume: 0.3, // 30% Standard-Lautstärke
sfxVolume: 0.5, // 50% Standard-Lautstärke
muted: false,
}
// Musik laden
log.Printf("📀 Lade Musik (%.2f MB)...", float64(len(gameMusicData))/(1024*1024))
as.loadMusic()
// Sound-Effekte dekodieren
log.Println("🔊 Lade Sound-Effekte...")
as.jumpSound = as.loadWav(jumpSoundData)
as.coinSound = as.loadWav(coinSoundData)
as.powerUpSound = as.loadWav(powerUpSoundData)
log.Println("✅ Audio-System bereit")
return as
}
// loadMusic lädt und startet die Hintergrundmusik
func (as *AudioSystem) loadMusic() {
// MP3 dekodieren
stream, err := mp3.DecodeWithSampleRate(SampleRate, bytes.NewReader(gameMusicData))
if err != nil {
log.Printf("❌ Fehler beim Laden der Musik: %v", err)
return
}
// Infinite Loop
loop := audio.NewInfiniteLoop(stream, stream.Length())
// Player erstellen
player, err := as.audioContext.NewPlayer(loop)
if err != nil {
log.Printf("❌ Fehler beim Erstellen des Music Players: %v", err)
return
}
as.musicPlayer = player
as.updateMusicVolume()
log.Println("🎵 Musik geladen")
}
// loadWav lädt eine WAV-Datei und gibt die dekodierten Bytes zurück
func (as *AudioSystem) loadWav(data []byte) []byte {
stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data))
if err != nil {
log.Printf("❌ Fehler beim Laden von WAV: %v", err)
return nil
}
// Stream in Bytes lesen
decoded := bytes.NewBuffer(nil)
_, err = decoded.ReadFrom(stream)
if err != nil {
log.Printf("❌ Fehler beim Dekodieren von WAV: %v", err)
return nil
}
return decoded.Bytes()
}
// PlayMusic startet die Hintergrundmusik
func (as *AudioSystem) PlayMusic() {
if as.musicPlayer == nil {
log.Println("⚠️ Music Player ist nil - Musik kann nicht gestartet werden")
return
}
if as.musicPlayer.IsPlaying() {
log.Println("⚠️ Musik läuft bereits")
return
}
as.musicPlayer.Play()
log.Printf("▶️ Musik gestartet (Volume: %.2f, Muted: %v)", as.musicVolume, as.muted)
}
// StopMusic stoppt die Hintergrundmusik
func (as *AudioSystem) StopMusic() {
if as.musicPlayer != nil && as.musicPlayer.IsPlaying() {
as.musicPlayer.Pause()
log.Println("⏸️ Musik gestoppt")
}
}
// PlayJump spielt den Jump-Sound ab
func (as *AudioSystem) PlayJump() {
if as.muted {
return
}
as.playSoundEffect(as.jumpSound, as.sfxVolume)
}
// PlayCoin spielt den Coin-Pickup-Sound ab
func (as *AudioSystem) PlayCoin() {
if as.muted {
return
}
as.playSoundEffect(as.coinSound, as.sfxVolume)
}
// PlayPowerUp spielt den PowerUp-Sound ab
func (as *AudioSystem) PlayPowerUp() {
if as.muted {
return
}
as.playSoundEffect(as.powerUpSound, as.sfxVolume)
}
// playSoundEffect spielt einen Sound-Effekt ab
func (as *AudioSystem) playSoundEffect(soundData []byte, volume float64) {
if soundData == nil {
return
}
player := as.audioContext.NewPlayerFromBytes(soundData)
if player == nil {
return
}
player.SetVolume(volume)
player.Play()
}
// SetMusicVolume setzt die Musik-Lautstärke (0.0 - 1.0)
func (as *AudioSystem) SetMusicVolume(volume float64) {
as.musicVolume = clamp(volume, 0.0, 1.0)
as.updateMusicVolume()
}
// SetSFXVolume setzt die Sound-Effekt-Lautstärke (0.0 - 1.0)
func (as *AudioSystem) SetSFXVolume(volume float64) {
as.sfxVolume = clamp(volume, 0.0, 1.0)
}
// ToggleMute schaltet Mute an/aus
func (as *AudioSystem) ToggleMute() {
as.muted = !as.muted
as.updateMusicVolume()
log.Printf("🔇 Mute: %v", as.muted)
}
// SetMuted setzt Mute-Status
func (as *AudioSystem) SetMuted(muted bool) {
as.muted = muted
as.updateMusicVolume()
}
// IsMuted gibt zurück, ob der Sound gemutet ist
func (as *AudioSystem) IsMuted() bool {
return as.muted
}
// GetMusicVolume gibt die aktuelle Musik-Lautstärke zurück
func (as *AudioSystem) GetMusicVolume() float64 {
return as.musicVolume
}
// GetSFXVolume gibt die aktuelle SFX-Lautstärke zurück
func (as *AudioSystem) GetSFXVolume() float64 {
return as.sfxVolume
}
// updateMusicVolume aktualisiert die Musik-Lautstärke
func (as *AudioSystem) updateMusicVolume() {
if as.musicPlayer != nil {
if as.muted {
as.musicPlayer.SetVolume(0)
} else {
as.musicPlayer.SetVolume(as.musicVolume)
}
}
}
// clamp begrenzt einen Wert zwischen min und max
func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}
+25
View File
@@ -0,0 +1,25 @@
//go:build !wasm
// +build !wasm
package main
import (
"log"
"path/filepath"
)
// loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop)
func (g *Game) loadChunks() {
baseDir := "cmd/client/assets"
chunkDir := filepath.Join(baseDir, "chunks")
err := g.world.LoadChunkLibrary(chunkDir)
if err != nil {
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
} else {
log.Printf("✅ Chunks geladen: %d Einträge", len(g.world.ChunkLibrary))
for id, chunk := range g.world.ChunkLibrary {
log.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d", id, chunk.Width, len(chunk.Objects))
}
}
}
+55
View File
@@ -0,0 +1,55 @@
//go:build wasm
// +build wasm
package main
import (
_ "embed"
"encoding/json"
"log"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
//go:embed assets/chunks/start.json
var chunkStartData []byte
//go:embed assets/chunks/chunk_01.json
var chunk01Data []byte
//go:embed assets/chunks/chunk_02.json
var chunk02Data []byte
//go:embed assets/chunks/chunk_03.json
var chunk03Data []byte
//go:embed assets/chunks/chunk_04.json
var chunk04Data []byte
// loadChunks lädt alle Chunks aus eingebetteten Daten (WebAssembly)
func (g *Game) loadChunks() {
chunkDataMap := map[string][]byte{
"start": chunkStartData,
"chunk_01": chunk01Data,
"chunk_02": chunk02Data,
"chunk_03": chunk03Data,
"chunk_04": chunk04Data,
}
count := 0
for id, data := range chunkDataMap {
var c game.Chunk
if err := json.Unmarshal(data, &c); err == nil {
if c.ID == "" {
c.ID = id
}
g.world.ChunkLibrary[c.ID] = c
count++
log.Printf("📦 Chunk geladen: %s (Width=%d, Objects=%d)", c.ID, c.Width, len(c.Objects))
} else {
log.Printf("⚠️ Fehler beim Laden von Chunk %s: %v", id, err)
}
}
log.Printf("✅ %d Chunks für WASM geladen", count)
}
+268
View File
@@ -0,0 +1,268 @@
//go:build !wasm
// +build !wasm
package main
import (
"encoding/json"
"log"
"net/url"
"time"
"github.com/gorilla/websocket"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// wsConn verwaltet die WebSocket-Verbindung (Native Desktop)
type wsConn struct {
conn *websocket.Conn
send chan []byte
stopChan chan struct{}
}
// WebSocketMessage ist das Format für WebSocket-Nachrichten
type WebSocketMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// connectToServer verbindet sich über WebSocket mit dem Gateway (Native Desktop)
func (g *Game) connectToServer() {
serverURL := "ws://localhost:8080/ws"
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
u, err := url.Parse(serverURL)
if err != nil {
log.Printf("❌ URL Parse Fehler: %v", err)
return
}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Printf("❌ WebSocket Connect Fehler: %v", err)
return
}
wsConn := &wsConn{
conn: conn,
send: make(chan []byte, 100),
stopChan: make(chan struct{}),
}
g.wsConn = wsConn
log.Println("✅ WebSocket verbunden!")
g.connected = true
// Goroutines für Lesen und Schreiben starten
go g.wsReadPump()
go g.wsWritePump()
// JOIN senden
g.sendJoinRequest()
}
// wsReadPump liest Nachrichten vom WebSocket
func (g *Game) wsReadPump() {
defer func() {
g.wsConn.conn.Close()
g.connected = false
}()
for {
var msg WebSocketMessage
err := g.wsConn.conn.ReadJSON(&msg)
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("⚠️ WebSocket Fehler: %v", err)
}
break
}
switch msg.Type {
case "game_update":
// GameState Update
payloadBytes, _ := json.Marshal(msg.Payload)
var state game.GameState
if err := json.Unmarshal(payloadBytes, &state); err == nil {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
g.gameState = state
g.stateMutex.Unlock()
}
case "leaderboard_response":
// Leaderboard Response
payloadBytes, _ := json.Marshal(msg.Payload)
var resp game.LeaderboardResponse
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
g.leaderboardMutex.Lock()
g.leaderboard = resp.Entries
g.leaderboardMutex.Unlock()
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
}
}
}
}
// wsWritePump sendet Nachrichten zum WebSocket
func (g *Game) wsWritePump() {
defer g.wsConn.conn.Close()
for {
select {
case message, ok := <-g.wsConn.send:
if !ok {
g.wsConn.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
err := g.wsConn.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("⚠️ Fehler beim Senden: %v", err)
return
}
case <-g.wsConn.stopChan:
return
}
}
}
// sendWebSocketMessage sendet eine Nachricht über WebSocket
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
if g.wsConn == nil || !g.connected {
log.Println("⚠️ WebSocket nicht verbunden")
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("❌ Fehler beim Marshallen: %v", err)
return
}
select {
case g.wsConn.send <- data:
default:
log.Println("⚠️ Send channel voll, Nachricht verworfen")
}
}
// sendJoinRequest sendet Join-Request über WebSocket
func (g *Game) sendJoinRequest() {
msg := WebSocketMessage{
Type: "join",
Payload: game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
GameMode: g.gameMode,
IsHost: g.isHost,
TeamName: g.teamName,
},
}
g.sendWebSocketMessage(msg)
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
}
// sendStartRequest sendet Start-Request über WebSocket
func (g *Game) sendStartRequest() {
msg := WebSocketMessage{
Type: "start",
Payload: game.StartRequest{
RoomID: g.roomID,
},
}
g.sendWebSocketMessage(msg)
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
}
// publishInput sendet Input über WebSocket
func (g *Game) publishInput(input game.ClientInput) {
msg := WebSocketMessage{
Type: "input",
Payload: input,
}
g.sendWebSocketMessage(msg)
}
// connectForLeaderboard verbindet für Leaderboard (Native)
func (g *Game) connectForLeaderboard() {
if g.wsConn != nil && g.connected {
// Bereits verbunden
g.requestLeaderboard()
return
}
// Neue Verbindung aufbauen
g.connectToServer()
// Kurz warten und dann Leaderboard anfragen
time.Sleep(500 * time.Millisecond)
g.requestLeaderboard()
}
// requestLeaderboard fordert Leaderboard an (Native)
func (g *Game) requestLeaderboard() {
mode := "solo"
if g.gameMode == "coop" {
mode = "coop"
}
msg := WebSocketMessage{
Type: "leaderboard_request",
Payload: game.LeaderboardRequest{
Mode: mode,
},
}
g.sendWebSocketMessage(msg)
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
}
// submitScore sendet Score ans Leaderboard (Native)
func (g *Game) submitScore() {
if g.scoreSubmitted {
return
}
g.stateMutex.Lock()
score := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
score = p.Score
break
}
}
g.stateMutex.Unlock()
if score == 0 {
log.Println("⚠️ Score ist 0, überspringe Submission")
return
}
name := g.playerName
if g.gameMode == "coop" && g.teamName != "" {
name = g.teamName
}
msg := WebSocketMessage{
Type: "score_submit",
Payload: game.ScoreSubmission{
PlayerCode: g.playerCode,
Name: name,
Score: score,
Mode: g.gameMode,
},
}
g.sendWebSocketMessage(msg)
g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score)
}
+270
View File
@@ -0,0 +1,270 @@
//go:build wasm
// +build wasm
package main
import (
"encoding/json"
"log"
"syscall/js"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// WebSocketMessage ist das Format für WebSocket-Nachrichten
type WebSocketMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// wsConn verwaltet die WebSocket-Verbindung im Browser
type wsConn struct {
ws js.Value
messagesChan chan []byte
connected bool
}
// connectToServer verbindet sich über WebSocket mit dem Gateway
func (g *Game) connectToServer() {
serverURL := "ws://localhost:8080/ws"
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
ws := js.Global().Get("WebSocket").New(serverURL)
conn := &wsConn{
ws: ws,
messagesChan: make(chan []byte, 100),
connected: false,
}
// OnOpen Handler
ws.Call("addEventListener", "open", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("✅ WebSocket verbunden!")
conn.connected = true
g.connected = true
return nil
}))
// OnMessage Handler
ws.Call("addEventListener", "message", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) == 0 {
return nil
}
data := args[0].Get("data").String()
var msg WebSocketMessage
if err := json.Unmarshal([]byte(data), &msg); err != nil {
log.Printf("❌ Fehler beim Parsen der Nachricht: %v", err)
return nil
}
switch msg.Type {
case "game_update":
// GameState Update
payloadBytes, _ := json.Marshal(msg.Payload)
var state game.GameState
if err := json.Unmarshal(payloadBytes, &state); err == nil {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
g.gameState = state
g.stateMutex.Unlock()
}
case "leaderboard_response":
// Leaderboard Response
payloadBytes, _ := json.Marshal(msg.Payload)
var resp game.LeaderboardResponse
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
g.leaderboardMutex.Lock()
g.leaderboard = resp.Entries
g.leaderboardMutex.Unlock()
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
// An JavaScript senden
g.sendLeaderboardToJS()
}
}
return nil
}))
// OnError Handler
ws.Call("addEventListener", "error", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("❌ WebSocket Fehler!")
conn.connected = false
g.connected = false
return nil
}))
// OnClose Handler
ws.Call("addEventListener", "close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("🔌 WebSocket geschlossen")
conn.connected = false
g.connected = false
return nil
}))
// Warte bis Verbindung hergestellt ist
for i := 0; i < 50; i++ {
if conn.connected {
break
}
time.Sleep(100 * time.Millisecond)
}
if !conn.connected {
log.Println("❌ WebSocket-Verbindung Timeout")
return
}
// WebSocket-Wrapper speichern
g.wsConn = conn
// JOIN senden
joinMsg := WebSocketMessage{
Type: "join",
Payload: game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
GameMode: g.gameMode,
IsHost: g.isHost,
TeamName: g.teamName,
},
}
g.sendWebSocketMessage(joinMsg)
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
}
// sendWebSocketMessage sendet eine Nachricht über WebSocket
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
if g.wsConn == nil || !g.wsConn.connected {
log.Println("⚠️ WebSocket nicht verbunden")
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("❌ Fehler beim Marshallen: %v", err)
return
}
g.wsConn.ws.Call("send", string(data))
}
// sendInput sendet einen Input über WebSocket
func (g *Game) sendInput(input game.ClientInput) {
msg := WebSocketMessage{
Type: "input",
Payload: input,
}
g.sendWebSocketMessage(msg)
}
// startGame sendet den Start-Befehl über WebSocket
func (g *Game) startGame() {
msg := WebSocketMessage{
Type: "start",
Payload: game.StartRequest{
RoomID: g.roomID,
},
}
g.sendWebSocketMessage(msg)
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
}
// connectForLeaderboard verbindet für Leaderboard-Abfrage
func (g *Game) connectForLeaderboard() {
if g.wsConn != nil && g.wsConn.connected {
// Bereits verbunden
g.requestLeaderboard()
return
}
// Neue Verbindung aufbauen
g.connectToServer()
// Kurz warten und dann Leaderboard anfragen
time.Sleep(500 * time.Millisecond)
g.requestLeaderboard()
}
// requestLeaderboard fordert das Leaderboard an
func (g *Game) requestLeaderboard() {
mode := "solo"
if g.gameMode == "coop" {
mode = "coop"
}
msg := WebSocketMessage{
Type: "leaderboard_request",
Payload: game.LeaderboardRequest{
Mode: mode,
},
}
g.sendWebSocketMessage(msg)
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
}
// submitScore sendet den Score ans Leaderboard
func (g *Game) submitScore() {
if g.scoreSubmitted {
return
}
g.stateMutex.Lock()
score := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
score = p.Score
break
}
}
g.stateMutex.Unlock()
if score == 0 {
log.Println("⚠️ Score ist 0, überspringe Submission")
return
}
name := g.playerName
if g.gameMode == "coop" && g.teamName != "" {
name = g.teamName
}
msg := WebSocketMessage{
Type: "score_submit",
Payload: game.ScoreSubmission{
PlayerCode: g.playerCode,
Name: name,
Score: score,
Mode: g.gameMode,
},
}
g.sendWebSocketMessage(msg)
g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score)
}
// Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes
func (g *Game) sendJoinRequest() {
// Wird in connectToServer aufgerufen
}
func (g *Game) sendStartRequest() {
g.startGame()
}
func (g *Game) publishInput(input game.ClientInput) {
g.sendInput(input)
}
+22
View File
@@ -0,0 +1,22 @@
//go:build !wasm
// +build !wasm
package main
import (
"github.com/hajimehoshi/ebiten/v2"
)
// In Native: Nutze die normalen Draw-Funktionen
func (g *Game) drawMenu(screen *ebiten.Image) {
g.DrawMenu(screen)
}
func (g *Game) drawLobby(screen *ebiten.Image) {
g.DrawLobby(screen)
}
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
g.DrawLeaderboard(screen)
}
+28
View File
@@ -0,0 +1,28 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
)
// In WASM: Menü und Leaderboard werden in HTML angezeigt
// Lobby wird aber normal gezeichnet (für Co-op Warteraum)
func (g *Game) drawMenu(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Menü ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255})
}
func (g *Game) drawLobby(screen *ebiten.Image) {
// Lobby wird normal gezeichnet (für Co-op Warteraum)
g.DrawLobby(screen)
}
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Leaderboard ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255})
}
+156 -27
View File
@@ -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}
// 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, 200}
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
}
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, true)
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
// C) Jump Button (Rechts)
// 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}, 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)
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})
}
// 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
View File
@@ -0,0 +1,9 @@
//go:build !wasm
// +build !wasm
package main
// sendGameOverToJS ist ein Stub für Native (kein HTML)
func (g *Game) sendGameOverToJS(score int) {
// Native hat kein HTML-Overlay, nichts zu tun
}
+160
View File
@@ -0,0 +1,160 @@
package main
import (
"image/color"
"math"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// GroundTile repräsentiert ein Boden-Segment
type GroundTile struct {
X float64
DirtVariants []DirtPatch
Stones []Stone
}
// DirtPatch ist ein Dirt-Fleck im Boden
type DirtPatch struct {
OffsetX float64
OffsetY float64
Width float64
Height float64
Color color.RGBA
}
// Stone ist ein Stein auf dem Boden
type Stone struct {
X float64
Y float64
Size float64
Color color.RGBA
Shape int // 0=rund, 1=eckig
}
// GroundCache speichert generierte Tiles
var groundCache = make(map[int]GroundTile)
// ClearGroundCache leert den Cache (z.B. bei Änderungen)
func ClearGroundCache() {
groundCache = make(map[int]GroundTile)
}
// GenerateGroundTile generiert ein prozedurales Boden-Segment (gecacht)
func GenerateGroundTile(tileIdx int) GroundTile {
// Prüfe Cache
if cached, exists := groundCache[tileIdx]; exists {
return cached
}
// Deterministischer Seed basierend auf Tile-Index
rng := rand.New(rand.NewSource(int64(tileIdx * 12345)))
tile := GroundTile{
X: float64(tileIdx) * 128.0,
DirtVariants: make([]DirtPatch, 0),
Stones: make([]Stone, 0),
}
// Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe)
numDirt := 15 + rng.Intn(10)
dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe
for i := 0; i < numDirt; i++ {
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
OffsetX: rng.Float64() * 128,
OffsetY: rng.Float64()*dirtHeight + 20, // Über die ganze Dirt-Schicht verteilt
Width: 10 + rng.Float64()*30,
Height: 10 + rng.Float64()*25,
Color: color.RGBA{darkness, darkness - 10, darkness - 20, 255},
})
}
// Keine Steine mehr auf dem Gras
// In Cache speichern
groundCache[tileIdx] = tile
return tile
}
// RenderGround rendert den Boden mit Bewegung
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
floorY := float32(RefFloorY)
floorH := float32(ScreenHeight - RefFloorY)
// 1. Basis Gras-Schicht
vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false)
// 2. Dirt-Schicht (Basis)
vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false)
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
// Berechne welche Tiles sichtbar sind
tileWidth := 128.0
startTile := int(math.Floor(cameraX / tileWidth))
endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth))
// Tiles rendern
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
tile := GenerateGroundTile(tileIdx)
// Dirt-Patches rendern
for _, dirt := range tile.DirtVariants {
worldX := tile.X + dirt.OffsetX
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
// Nur rendern wenn im sichtbaren Bereich
if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) {
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
}
}
// Steine rendern (auf dem Gras)
for _, stone := range tile.Stones {
worldX := tile.X + stone.X
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(stone.Y)
// Nur rendern wenn im sichtbaren Bereich
if screenX > -20 && screenX < float32(ScreenWidth)+20 {
if stone.Shape == 0 {
// Runder Stein
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)
// Highlight für 3D-Effekt
highlightCol := color.RGBA{
uint8(math.Min(float64(stone.Color.R)+40, 255)),
uint8(math.Min(float64(stone.Color.G)+40, 255)),
uint8(math.Min(float64(stone.Color.B)+40, 255)),
200,
}
vector.DrawFilledCircle(screen, screenX-float32(stone.Size*0.15), screenY-float32(stone.Size*0.15), float32(stone.Size/4), highlightCol, false)
} else {
// Eckiger Stein
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
// Schatten für 3D-Effekt
shadowCol := color.RGBA{
uint8(float64(stone.Color.R) * 0.6),
uint8(float64(stone.Color.G) * 0.6),
uint8(float64(stone.Color.B) * 0.6),
150,
}
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2)+2, screenY-float32(stone.Size/2)+2, float32(stone.Size), float32(stone.Size), shadowCol, false)
// Original drüber
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
}
}
}
}
// Cache aufräumen (nur Tiles außerhalb des Sichtbereichs entfernen)
if len(groundCache) > 100 {
for idx := range groundCache {
if idx < startTile-10 || idx > endTile+10 {
delete(groundCache, idx)
}
}
}
}
+158 -247
View File
@@ -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"
@@ -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
View File
@@ -0,0 +1,20 @@
//go:build !wasm
// +build !wasm
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60)
ebiten.SetVsyncEnabled(true)
if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}
}
+33
View File
@@ -0,0 +1,33 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
log.Println("🚀 WASM Version startet...")
// Spiel initialisieren
game := NewGame()
// JavaScript Bridge registrieren (für HTML-Menü Kommunikation)
game.setupJavaScriptBridge()
// Spiel ohne eigenes Menü starten - HTML übernimmt das Menü
// Das Spiel wartet im Hintergrund bis startGame() von JavaScript aufgerufen wird
log.Println("⏳ Warte auf Start-Signal vom HTML-Menü...")
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60)
ebiten.SetVsyncEnabled(true)
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}
+336
View File
@@ -0,0 +1,336 @@
package main
import (
"fmt"
"image/color"
"math"
"math/rand"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Particle definiert ein einzelnes Partikel
type Particle struct {
X, Y float64
VX, VY float64
Life float64 // 0.0 bis 1.0 (1.0 = neu, 0.0 = tot)
MaxLife float64 // Ursprüngliche Lebensdauer in Sekunden
Size float64
Color color.RGBA
Type string // "coin", "powerup", "landing", "death"
Gravity bool // Soll Gravitation angewendet werden?
FadeOut bool // Soll ausblenden?
}
// SpawnCoinParticles erstellt Partikel für Coin-Einsammlung
func (g *Game) SpawnCoinParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 10-15 goldene Partikel - spawnen in alle Richtungen
count := 10 + rand.Intn(6)
for i := 0; i < count; i++ {
angle := rand.Float64() * 2 * math.Pi
speed := 2.0 + rand.Float64()*3.0
g.particles = append(g.particles, Particle{
X: x,
Y: y,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.5 + rand.Float64()*0.3,
Size: 3.0 + rand.Float64()*2.0,
Color: color.RGBA{255, 215, 0, 255}, // Gold
Type: "coin",
Gravity: true,
FadeOut: true,
})
}
}
// SpawnPowerupAura erstellt Partikel-Aura um Spieler mit aktivem Powerup
func (g *Game) SpawnPowerupAura(x, y float64, powerupType string) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// Farbe je nach Powerup-Typ
var particleColor color.RGBA
switch powerupType {
case "doublejump":
particleColor = color.RGBA{100, 200, 255, 200} // Hellblau
case "godmode":
particleColor = color.RGBA{255, 215, 0, 200} // Gold
default:
particleColor = color.RGBA{200, 100, 255, 200} // Lila
}
// Nur gelegentlich spawnen (nicht jedes Frame) - 1 Partikel alle 3-4 Frames
if rand.Intn(3) != 0 {
return
}
// 1 Partikel für sanfte Aura - spawnen in alle Richtungen
angle := rand.Float64() * 2 * math.Pi
distance := 25.0 + rand.Float64()*20.0
g.particles = append(g.particles, Particle{
X: x + math.Cos(angle)*distance,
Y: y + math.Sin(angle)*distance,
VX: math.Cos(angle) * 0.5,
VY: math.Sin(angle) * 0.5,
Life: 1.0,
MaxLife: 1.0 + rand.Float64()*0.5,
Size: 2.5 + rand.Float64()*1.5,
Color: particleColor,
Type: "powerup",
Gravity: false,
FadeOut: true,
})
}
// SpawnLandingParticles erstellt Staub-Partikel beim Landen
func (g *Game) SpawnLandingParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 5-8 Staub-Partikel
count := 5 + rand.Intn(4)
for i := 0; i < count; i++ {
angle := math.Pi + (rand.Float64()-0.5)*0.8 // Nach unten/seitlich
speed := 1.0 + rand.Float64()*2.0
g.particles = append(g.particles, Particle{
X: x + (rand.Float64()-0.5)*30.0,
Y: y + 10,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.3 + rand.Float64()*0.2,
Size: 2.0 + rand.Float64()*2.0,
Color: color.RGBA{150, 150, 150, 180}, // Grau
Type: "landing",
Gravity: false,
FadeOut: true,
})
}
}
// SpawnDeathParticles erstellt Explosions-Partikel beim Tod
func (g *Game) SpawnDeathParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 20-30 rote Partikel
count := 20 + rand.Intn(11)
for i := 0; i < count; i++ {
angle := rand.Float64() * 2 * math.Pi
speed := 3.0 + rand.Float64()*5.0
g.particles = append(g.particles, Particle{
X: x,
Y: y + 10,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.8 + rand.Float64()*0.4,
Size: 3.0 + rand.Float64()*3.0,
Color: color.RGBA{255, 50, 50, 255}, // Rot
Type: "death",
Gravity: true,
FadeOut: true,
})
}
}
// UpdateParticles aktualisiert alle Partikel
func (g *Game) UpdateParticles(dt float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// Filtern: Nur lebende Partikel behalten
alive := make([]Particle, 0, len(g.particles))
for i := range g.particles {
p := &g.particles[i]
// Position updaten
p.X += p.VX
p.Y += p.VY
// Gravitation
if p.Gravity {
p.VY += 0.3 // Gravitation
}
// Friction
p.VX *= 0.98
p.VY *= 0.98
// Leben verringern
p.Life -= dt / p.MaxLife
// Nur behalten wenn noch am Leben
if p.Life > 0 {
alive = append(alive, *p)
}
}
g.particles = alive
}
// RenderParticles zeichnet alle Partikel
func (g *Game) RenderParticles(screen *ebiten.Image) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
for i := range g.particles {
p := &g.particles[i]
// Alpha-Wert basierend auf Leben
alpha := uint8(255)
if p.FadeOut {
alpha = uint8(float64(p.Color.A) * p.Life)
}
col := color.RGBA{p.Color.R, p.Color.G, p.Color.B, alpha}
// Position relativ zur Kamera
screenX := float32(p.X - g.camX)
screenY := float32(p.Y)
// Partikel als Kreis zeichnen
vector.DrawFilledCircle(screen, screenX, screenY, float32(p.Size), col, false)
}
}
// DetectAndSpawnParticles prüft Game-State-Änderungen und spawnt Partikel
func (g *Game) DetectAndSpawnParticles() {
// Kopiere relevante Daten unter kurzen Lock
g.stateMutex.Lock()
// Kopiere Coins
currentCoins := make(map[string]bool, len(g.gameState.CollectedCoins))
for k, v := range g.gameState.CollectedCoins {
currentCoins[k] = v
}
// Kopiere Powerups
currentPowerups := make(map[string]bool, len(g.gameState.CollectedPowerups))
for k, v := range g.gameState.CollectedPowerups {
currentPowerups[k] = v
}
// Kopiere Spieler
currentPlayers := make(map[string]game.PlayerState, len(g.gameState.Players))
for k, v := range g.gameState.Players {
currentPlayers[k] = v
}
// Kopiere WorldChunks für Position-Lookup
worldChunks := make([]game.ActiveChunk, len(g.gameState.WorldChunks))
copy(worldChunks, g.gameState.WorldChunks)
g.stateMutex.Unlock()
// Ab hier ohne Lock arbeiten
// Alte Coins entfernen, die nicht mehr in currentCoins sind (Chunk wurde entfernt)
for key := range g.lastCollectedCoins {
if !currentCoins[key] {
delete(g.lastCollectedCoins, key)
}
}
// 1. Prüfe neue gesammelte Coins
for coinKey := range currentCoins {
if !g.lastCollectedCoins[coinKey] {
// Neuer Coin gesammelt!
if pos := g.findObjectPosition(coinKey, worldChunks, "coin"); pos != nil {
g.SpawnCoinParticles(pos.X, pos.Y)
g.audio.PlayCoin() // Coin Sound abspielen
}
g.lastCollectedCoins[coinKey] = true
}
}
// Alte Powerups entfernen
for key := range g.lastCollectedPowerups {
if !currentPowerups[key] {
delete(g.lastCollectedPowerups, key)
}
}
// 2. Prüfe neue gesammelte Powerups
for powerupKey := range currentPowerups {
if !g.lastCollectedPowerups[powerupKey] {
// Neues Powerup gesammelt!
if pos := g.findObjectPosition(powerupKey, worldChunks, "powerup"); pos != nil {
g.SpawnCoinParticles(pos.X, pos.Y)
g.audio.PlayPowerUp() // PowerUp Sound abspielen
}
g.lastCollectedPowerups[powerupKey] = true
}
}
// 3. Prüfe Spieler-Status und spawn Aura/Death Partikel
for playerID, player := range currentPlayers {
lastState, existed := g.lastPlayerStates[playerID]
// Death Partikel
if existed && lastState.IsAlive && !player.IsAlive {
// Berechne Spieler-Mitte
centerX := player.X - 56 + 68 + 73/2
centerY := player.Y - 231 + 42 + 184/2
g.SpawnDeathParticles(centerX, centerY)
}
// Powerup Aura (kontinuierlich)
if player.IsAlive && !player.IsSpectator {
// Berechne Spieler-Mitte mit Draw-Offsets
// DrawOffX: -56, DrawOffY: -231
// Hitbox: OffsetX: 68, OffsetY: 42, W: 73, H: 184
centerX := player.X - 56 + 68 + 73/2
centerY := player.Y - 231 + 42 + 184/2
if player.HasDoubleJump {
g.SpawnPowerupAura(centerX, centerY, "doublejump")
}
if player.HasGodMode {
g.SpawnPowerupAura(centerX, centerY, "godmode")
}
}
// State aktualisieren
g.lastPlayerStates[playerID] = player
}
}
// findObjectPosition finiert die Welt-Position eines Objects (Coin/Powerup) basierend auf Key
func (g *Game) findObjectPosition(objectKey string, worldChunks []game.ActiveChunk, objectType string) *struct{ X, Y float64 } {
for _, activeChunk := range worldChunks {
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
if !exists {
continue
}
for objIdx, obj := range chunkDef.Objects {
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
if key == objectKey {
assetDef, ok := g.world.Manifest.Assets[obj.AssetID]
if ok && assetDef.Type == objectType {
// Berechne die Mitte der Hitbox
centerX := activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX + assetDef.Hitbox.W/2
centerY := obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY + assetDef.Hitbox.H/2
return &struct{ X, Y float64 }{
X: centerX,
Y: centerY,
}
}
}
}
}
return nil
}
+47 -7
View File
@@ -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
}
+26
View File
@@ -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)
}
}
+30
View File
@@ -43,3 +43,33 @@ func (g *Game) loadOrCreatePlayerCode() {
}
}
}
// loadPlayerName lädt gespeicherten Spielernamen (WebAssembly Version)
func (g *Game) loadPlayerName() string {
const storageKey = "escape_from_teacher_player_name"
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
localStorage := jsGlobal.Get("localStorage")
if !localStorage.IsUndefined() {
stored := localStorage.Call("getItem", storageKey)
if !stored.IsNull() && stored.String() != "" {
log.Printf("👤 Spielername aus LocalStorage geladen: %s", stored.String())
return stored.String()
}
}
}
return ""
}
// savePlayerName speichert Spielernamen (WebAssembly Version)
func (g *Game) savePlayerName(name string) {
const storageKey = "escape_from_teacher_player_name"
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
localStorage := jsGlobal.Get("localStorage")
if !localStorage.IsUndefined() {
localStorage.Call("setItem", storageKey, name)
log.Printf("💾 Spielername in LocalStorage gespeichert: %s", name)
}
}
}
+119
View File
@@ -0,0 +1,119 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"fmt"
"log"
"syscall/js"
"time"
)
// setupJavaScriptBridge registriert JavaScript-Funktionen für HTML-Menü Interaktion
func (g *Game) setupJavaScriptBridge() {
// startGame(mode, playerName, roomID, teamName, isHost)
startGameFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
log.Println("❌ startGame: Nicht genug Argumente")
return nil
}
mode := args[0].String() // "solo" oder "coop"
playerName := args[1].String() // Spielername
// Spieler-Daten setzen
g.playerName = playerName
g.gameMode = mode
g.savePlayerName(playerName)
if mode == "solo" {
// Solo Mode - direkt ins Spiel
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true
g.appState = StateGame
log.Printf("🎮 Solo-Spiel gestartet: %s", playerName)
} else if mode == "coop" && len(args) >= 5 {
// Co-op Mode - in die Lobby
roomID := args[2].String()
teamName := args[3].String()
isHost := args[4].Bool()
g.roomID = roomID
g.teamName = teamName
g.isHost = isHost
g.appState = StateLobby
log.Printf("🎮 Co-op-Lobby erstellt: %s (Room: %s, Team: %s, Host: %v)", playerName, roomID, teamName, isHost)
}
// Verbindung starten
go g.connectAndStart()
return nil
})
// requestLeaderboard()
requestLeaderboardFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("📊 Leaderboard angefordert")
go g.requestLeaderboard()
return nil
})
// setMusicVolume(volume)
setMusicVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
volume := args[0].Float()
g.audio.SetMusicVolume(volume)
log.Printf("🎵 Musik-Lautstärke: %.0f%%", volume*100)
}
return nil
})
// setSFXVolume(volume)
setSFXVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
volume := args[0].Float()
g.audio.SetSFXVolume(volume)
log.Printf("🔊 SFX-Lautstärke: %.0f%%", volume*100)
}
return nil
})
// Im globalen Scope registrieren
js.Global().Set("startGame", startGameFunc)
js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
js.Global().Set("setMusicVolume", setMusicVolumeFunc)
js.Global().Set("setSFXVolume", setSFXVolumeFunc)
log.Println("✅ JavaScript Bridge registriert")
}
// Leaderboard an JavaScript senden
func (g *Game) sendLeaderboardToJS() {
g.leaderboardMutex.Lock()
entries := make([]interface{}, len(g.leaderboard))
for i, entry := range g.leaderboard {
entries[i] = map[string]interface{}{
"player_name": entry.PlayerName,
"score": entry.Score,
"player_code": entry.PlayerCode,
}
}
g.leaderboardMutex.Unlock()
// JavaScript-Funktion aufrufen
if updateFunc := js.Global().Get("updateLeaderboard"); !updateFunc.IsUndefined() {
jsEntries := js.ValueOf(entries)
updateFunc.Invoke(jsEntries)
log.Printf("📤 Leaderboard an JavaScript gesendet: %d Einträge", len(entries))
}
}
// Game Over Screen an JavaScript senden
func (g *Game) sendGameOverToJS(score int) {
if gameOverFunc := js.Global().Get("showGameOver"); !gameOverFunc.IsUndefined() {
gameOverFunc.Invoke(score)
log.Printf("💀 Game Over an JavaScript gesendet: Score=%d", score)
}
}
+89
View File
@@ -0,0 +1,89 @@
# Escape From Teacher - WASM Web Version
## 🎮 Starten
```bash
# Im web-Verzeichnis einen HTTP-Server starten
cd cmd/client/web
python3 -m http.server 8000
```
Dann im Browser öffnen: **http://localhost:8000**
## ✨ Features
### Modernes HTML/CSS Menü
- **Custom Font**: Nutzt `front.ttf` aus dem assets-Ordner
- **Responsive Design**: Funktioniert auf Desktop, Tablet und Smartphone
- **Glassmorphism**: Moderner durchsichtiger Look mit Blur-Effekt
- **Smooth Animations**: Fade-in, Slide-up, Pulse-Effekte
### Menü-Optionen
1. **Solo spielen**: Einzelspieler-Modus
2. **Co-op spielen**: Multiplayer mit Raum-Code
3. **Leaderboard**: Top 10 Spieler anzeigen
4. **Einstellungen**: Musik & SFX Lautstärke einstellen
### Mobile-Optimiert
- Touch-freundliche Buttons
- Responsive Layout für kleine Bildschirme
- Viewport-Anpassung für Smartphones
## 🎨 Design-Features
- **Gradient Background**: Lila-Blauer Farbverlauf
- **Button Hover-Effekte**: Ripple-Animationen
- **Custom Scrollbars**: Für Leaderboard
- **Loading Screen**: Spinner während WASM lädt
- **Emoji-Icons**: 🏃 👥 🏆 ⚙️
## 📁 Dateien
- `index.html`: HTML-Struktur
- `style.css`: Alle Styles mit Custom Font
- `game.js`: JavaScript-Bridge zwischen HTML und WASM
- `wasm_exec.js`: Go WASM Runtime (von Go kopiert)
- `main.wasm`: Kompiliertes Spiel
## 🔧 Entwicklung
### WASM neu kompilieren
```bash
GOOS=js GOARCH=wasm go build -o cmd/client/web/main.wasm ./cmd/client
```
### Font ändern
Ersetze `cmd/client/assets/front.ttf` und aktualisiere den Pfad in `style.css`:
```css
@font-face {
font-family: 'GameFont';
src: url('../assets/front.ttf') format('truetype');
}
```
## 🎯 Keyboard Shortcuts
- **ESC**: Zurück zum Menü (während des Spiels)
## 📱 Mobile Testing
Für lokales Mobile Testing:
```bash
python3 -m http.server 8000
# Öffne auf Smartphone: http://[DEINE-IP]:8000
```
## 🚀 Production Deployment
Für Production alle Dateien hochladen:
- index.html
- style.css
- game.js
- wasm_exec.js
- main.wasm
- assets/ (Font-Ordner)
Server muss WASM mit korrektem MIME-Type ausliefern:
```
Content-Type: application/wasm
```
+1
View File
@@ -0,0 +1 @@
../assets
+341
View File
@@ -0,0 +1,341 @@
// Game State
let wasmReady = false;
let gameStarted = false;
let audioMuted = false;
// Initialize WASM
async function initWASM() {
const go = new Go();
try {
const result = await WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject);
go.run(result.instance);
wasmReady = true;
// Hide loading screen
document.getElementById('loading').style.display = 'none';
console.log('✅ WASM loaded successfully');
// Load initial leaderboard
setTimeout(() => {
if (window.requestLeaderboard) {
window.requestLeaderboard();
}
}, 500);
} catch (err) {
console.error('❌ Failed to load WASM:', err);
document.getElementById('loading').innerHTML = '<div class="spinner"></div><p>Fehler beim Laden: ' + err.message + '</p>';
}
}
// Menu Navigation
function showMainMenu() {
hideAllScreens();
document.getElementById('menu').classList.remove('hidden');
}
function showCoopMenu() {
hideAllScreens();
document.getElementById('coopMenu').classList.remove('hidden');
}
function showSettings() {
hideAllScreens();
document.getElementById('settingsMenu').classList.remove('hidden');
}
function showLeaderboard() {
hideAllScreens();
document.getElementById('leaderboardMenu').classList.remove('hidden');
loadLeaderboard();
}
function hideAllScreens() {
document.querySelectorAll('.overlay-screen').forEach(screen => {
screen.classList.add('hidden');
});
}
function hideMenu() {
document.getElementById('menu').style.display = 'none';
// Canvas sichtbar machen für Gameplay
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.classList.add('game-active');
}
}
function showMenu() {
document.getElementById('menu').style.display = 'flex';
document.getElementById('menu').classList.remove('hidden');
showMainMenu();
// Canvas verstecken im Menü
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.classList.remove('game-active');
}
}
// Game Functions
function startSoloGame() {
if (!wasmReady) {
alert('Spiel wird noch geladen...');
return;
}
const playerName = document.getElementById('playerName').value || 'ANON';
// Store in localStorage for WASM to read
localStorage.setItem('escape_player_name', playerName);
localStorage.setItem('escape_game_mode', 'solo');
localStorage.setItem('escape_room_id', '');
// Hide menu and show canvas
hideAllScreens();
gameStarted = true;
// Trigger WASM game start
if (window.startGame) {
window.startGame('solo', playerName, '');
}
console.log('🎮 Solo game started:', playerName);
}
function createRoom() {
if (!wasmReady) {
alert('Spiel wird noch geladen...');
return;
}
const playerName = document.getElementById('playerName').value || 'ANON';
const roomID = 'R' + Math.random().toString(36).substr(2, 5).toUpperCase();
const teamName = 'TEAM';
// Store in localStorage
localStorage.setItem('escape_player_name', playerName);
localStorage.setItem('escape_game_mode', 'coop');
localStorage.setItem('escape_room_id', roomID);
localStorage.setItem('escape_team_name', teamName);
localStorage.setItem('escape_is_host', 'true');
// Hide menu and show canvas (Lobby wird vom WASM gezeichnet)
hideAllScreens();
gameStarted = true;
// Canvas sichtbar machen für Lobby
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.classList.add('game-active');
}
// Trigger WASM game start
if (window.startGame) {
window.startGame('coop', playerName, roomID, teamName, true);
}
console.log('🎮 Room created:', roomID);
}
function joinRoom() {
if (!wasmReady) {
alert('Spiel wird noch geladen...');
return;
}
const playerName = document.getElementById('playerName').value || 'ANON';
const roomID = document.getElementById('joinRoomCode').value.toUpperCase();
const teamName = document.getElementById('teamNameJoin').value || 'TEAM';
if (!roomID || roomID.length < 4) {
alert('Bitte gib einen gültigen Raum-Code ein!');
return;
}
// Store in localStorage
localStorage.setItem('escape_player_name', playerName);
localStorage.setItem('escape_game_mode', 'coop');
localStorage.setItem('escape_room_id', roomID);
localStorage.setItem('escape_team_name', teamName);
localStorage.setItem('escape_is_host', 'false');
// Hide menu and show canvas
hideAllScreens();
gameStarted = true;
// Canvas sichtbar machen für Lobby
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.classList.add('game-active');
}
// Trigger WASM game start
if (window.startGame) {
window.startGame('coop', playerName, roomID, teamName, false);
}
console.log('🎮 Joining room:', roomID);
}
function loadLeaderboard() {
const list = document.getElementById('leaderboardList');
list.innerHTML = '<div style="text-align:center; padding:20px;">Lädt Leaderboard...</div>';
// Request leaderboard from WASM
if (window.requestLeaderboard) {
window.requestLeaderboard();
}
// Fallback timeout
setTimeout(() => {
if (list.innerHTML.includes('Lädt')) {
list.innerHTML = '<div style="text-align:center; padding:20px; color:#888;">Keine Daten verfügbar</div>';
}
}, 3000);
}
// Called by WASM to update leaderboard
function updateLeaderboard(entries) {
// Update main leaderboard page
const list = document.getElementById('leaderboardList');
// Update start screen leaderboard
const startList = document.getElementById('startLeaderboardList');
if (!entries || entries.length === 0) {
const emptyMsg = '<div style="text-align:center; padding:20px; color:#888;">Noch keine Einträge</div>';
if (list) list.innerHTML = emptyMsg;
if (startList) startList.innerHTML = emptyMsg;
return;
}
const html = entries.slice(0, 10).map((entry, index) => {
const medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `${index + 1}.`;
return `
<div class="leaderboard-item">
<div class="leaderboard-rank">${medal}</div>
<div class="leaderboard-name">${entry.player_name}</div>
<div class="leaderboard-score">${entry.score}</div>
</div>
`;
}).join('');
if (list) list.innerHTML = html;
if (startList) startList.innerHTML = html;
console.log('📊 Leaderboard updated with', entries.length, 'entries');
}
// Audio Toggle
function toggleAudio() {
audioMuted = !audioMuted;
const btn = document.getElementById('mute-btn');
if (audioMuted) {
btn.textContent = '🔇';
if (window.setMusicVolume) window.setMusicVolume(0);
if (window.setSFXVolume) window.setSFXVolume(0);
} else {
btn.textContent = '🔊';
const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 70) / 100;
const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 70) / 100;
if (window.setMusicVolume) window.setMusicVolume(musicVol);
if (window.setSFXVolume) window.setSFXVolume(sfxVol);
}
}
// Settings Volume Sliders
document.addEventListener('DOMContentLoaded', () => {
const musicSlider = document.getElementById('musicVolume');
const sfxSlider = document.getElementById('sfxVolume');
const musicValue = document.getElementById('musicValue');
const sfxValue = document.getElementById('sfxValue');
if (musicSlider) {
musicSlider.addEventListener('input', (e) => {
const value = e.target.value;
musicValue.textContent = value + '%';
localStorage.setItem('escape_music_volume', value);
if (window.setMusicVolume && !audioMuted) {
window.setMusicVolume(value / 100);
}
});
// Load saved value
const savedMusic = localStorage.getItem('escape_music_volume') || 70;
musicSlider.value = savedMusic;
musicValue.textContent = savedMusic + '%';
}
if (sfxSlider) {
sfxSlider.addEventListener('input', (e) => {
const value = e.target.value;
sfxValue.textContent = value + '%';
localStorage.setItem('escape_sfx_volume', value);
if (window.setSFXVolume && !audioMuted) {
window.setSFXVolume(value / 100);
}
});
// Load saved value
const savedSFX = localStorage.getItem('escape_sfx_volume') || 70;
sfxSlider.value = savedSFX;
sfxValue.textContent = savedSFX + '%';
}
// Load saved player name
const savedName = localStorage.getItem('escape_player_name');
if (savedName) {
document.getElementById('playerName').value = savedName;
}
// Load local highscore
const highscore = localStorage.getItem('escape_local_highscore') || 0;
const hsElement = document.getElementById('localHighscore');
if (hsElement) {
hsElement.textContent = highscore;
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
// ESC to show menu (only if game is started)
if (e.key === 'Escape' && gameStarted) {
showMenu();
gameStarted = false;
}
});
// Show Game Over Screen (called by WASM)
function showGameOver(score) {
hideAllScreens();
document.getElementById('gameOverScreen').classList.remove('hidden');
document.getElementById('finalScore').textContent = score;
// Update local highscore
const currentHS = parseInt(localStorage.getItem('escape_local_highscore') || 0);
if (score > currentHS) {
localStorage.setItem('escape_local_highscore', score);
}
// Request leaderboard
if (window.requestLeaderboard) {
window.requestLeaderboard();
}
console.log('💀 Game Over! Score:', score);
}
// Export functions for WASM to call
window.showMenu = showMenu;
window.hideMenu = hideMenu;
window.updateLeaderboard = updateLeaderboard;
window.showGameOver = showGameOver;
// Initialize on load
initWASM();
console.log('🎮 Game.js loaded - Retro Edition');
+147
View File
@@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Escape From Teacher</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<button id="mute-btn" onclick="toggleAudio()">🔊</button>
<div id="rotate-overlay">
<div class="icon">📱↻</div>
<p>Bitte Gerät drehen!</p>
<small>Querformat benötigt</small>
</div>
<div id="game-container">
<!-- MAIN MENU -->
<div id="menu" class="overlay-screen">
<div id="startScreen">
<div class="start-left">
<h1>ESCAPE FROM<br>TEACHER</h1>
<p style="font-size: 12px; color: #aaa;">Dein Rekord: <span id="localHighscore" style="color:yellow">0</span></p>
<input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;">
<button id="startBtn" onclick="startSoloGame()">SOLO STARTEN</button>
<button id="coopBtn" onclick="showCoopMenu()">CO-OP SPIELEN</button>
<div class="info-box">
<div class="info-title">SCHUL-NEWS</div>
<p>
• Der Lehrer ist hinter dir her!<br>
• Spring über Hindernisse.<br>
• Sammle Power-Ups für Vorteile!
</p>
</div>
<div class="info-box">
<div class="info-title">STEUERUNG</div>
<p>
PC: <strong>Leertaste</strong> (Springen), <strong>WASD/Pfeile</strong> (Bewegen)<br>
Handy: <strong>Bildschirm-Buttons</strong>
</p>
</div>
<div class="legal-bar">
<button class="legal-btn" onclick="showLeaderboard()">🏆 TOP 10</button>
<button class="legal-btn" onclick="showSettings()">⚙️ EINSTELLUNGEN</button>
</div>
</div>
<div class="start-right">
<div class="hall-of-fame-box">
<h3>🏆 TOP SCHÜLER</h3>
<div id="startLeaderboardList">Lade...</div>
</div>
</div>
</div>
</div>
<!-- CO-OP MENU -->
<div id="coopMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>CO-OP MODUS</h1>
<button class="big-btn" onclick="createRoom()">RAUM ERSTELLEN</button>
<div style="margin: 20px 0;">- ODER -</div>
<input type="text" id="joinRoomCode" placeholder="RAUM-CODE" maxlength="6" style="text-transform:uppercase;">
<input type="text" id="teamNameJoin" placeholder="TEAM-NAME" maxlength="15">
<button onclick="joinRoom()">RAUM BEITRETEN</button>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- SETTINGS MENU -->
<div id="settingsMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>EINSTELLUNGEN</h1>
<div class="settings-group">
<div class="setting-item">
<label>MUSIK LAUTSTÄRKE:</label>
<input type="range" id="musicVolume" min="0" max="100" value="70">
<span id="musicValue">70%</span>
</div>
<div class="setting-item">
<label>SFX LAUTSTÄRKE:</label>
<input type="range" id="sfxVolume" min="0" max="100" value="70">
<span id="sfxValue">70%</span>
</div>
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- LEADERBOARD MENU -->
<div id="leaderboardMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>🏆 TOP 10 LEADERBOARD</h1>
<div id="leaderboardList" class="leaderboard-box">
Lade Leaderboard...
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- GAME OVER SCREEN -->
<div id="gameOverScreen" class="overlay-screen hidden">
<div class="center-box">
<h1>ERWISCHT!</h1>
<p style="font-size: 18px; margin: 20px 0;">
Dein Score: <span id="finalScore" style="color:yellow; font-size: 24px;">0</span>
</p>
<div id="leaderboardList" class="leaderboard-box" style="margin: 20px 0;">
Lade Leaderboard...
</div>
<button class="big-btn" onclick="location.reload()" style="background: #ff4444; margin-top: 20px;">NOCHMAL SPIELEN</button>
</div>
</div>
<!-- LOADING SCREEN -->
<div id="loading" class="loading-screen">
<div class="spinner"></div>
<p>Lade Escape From Teacher...</p>
</div>
</div>
<!-- WASM Execution -->
<script src="wasm_exec.js"></script>
<script src="game.js"></script>
</body>
</html>
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
+575
View File
@@ -0,0 +1,575 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();
+321 -20
View File
@@ -28,7 +28,8 @@ const (
AssetFile = "./cmd/client/assets/assets.json"
ChunkDir = "./cmd/client/assets/chunks"
SidebarWidth = 250
LeftSidebarWidth = 250
RightSidebarWidth = 250
TopBarHeight = 40
CanvasHeight = 720
CanvasWidth = 1280
@@ -56,10 +57,13 @@ type LevelEditor struct {
assetsImages map[string]*ebiten.Image
currentChunk game.Chunk
currentChunkFile string // Aktuell geladene Datei
chunkFiles []string // Liste aller Chunk-Dateien
scrollX float64
zoom float64
listScroll float64
chunkListScroll float64 // Scroll für Chunk-Liste
statusMsg string
showGrid bool
@@ -75,6 +79,11 @@ 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 {
@@ -86,9 +95,15 @@ func NewLevelEditor() *LevelEditor {
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)
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
}
+19 -1
View File
@@ -113,15 +113,33 @@ func main() {
}
})
// 6. HANDLER: LEADERBOARD REQUEST
// 6. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität)
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
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
View File
@@ -0,0 +1,228 @@
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
"github.com/nats-io/nats.go"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Erlaube alle Origins für Development
},
}
// WebSocketMessage ist das allgemeine Format für WebSocket-Nachrichten
type WebSocketMessage struct {
Type string `json:"type"` // "join", "input", "start", "leaderboard_request"
Payload json.RawMessage `json:"payload"` // Beliebige JSON-Daten
}
// WebSocketClient repräsentiert einen verbundenen WebSocket-Client
type WebSocketClient struct {
conn *websocket.Conn
natsConn *nats.EncodedConn
playerID string
roomID string
send chan []byte
mutex sync.Mutex
subUpdates *nats.Subscription
}
// handleWebSocket verwaltet eine WebSocket-Verbindung
func handleWebSocket(w http.ResponseWriter, r *http.Request, ec *nats.EncodedConn) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("❌ WebSocket Upgrade Fehler: %v", err)
return
}
client := &WebSocketClient{
conn: conn,
natsConn: ec,
send: make(chan []byte, 256),
}
log.Printf("🔌 Neuer WebSocket-Client verbunden: %s", conn.RemoteAddr())
// Goroutinen für Lesen und Schreiben starten
go client.writePump()
go client.readPump()
}
// readPump liest Nachrichten vom WebSocket-Client
func (c *WebSocketClient) readPump() {
defer func() {
if c.subUpdates != nil {
c.subUpdates.Unsubscribe()
}
c.conn.Close()
log.Printf("🔌 WebSocket-Client getrennt: %s", c.conn.RemoteAddr())
}()
for {
var msg WebSocketMessage
err := c.conn.ReadJSON(&msg)
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("⚠️ WebSocket Fehler: %v", err)
}
break
}
// Nachricht basierend auf Typ verarbeiten
c.handleMessage(msg)
}
}
// writePump sendet Nachrichten zum WebSocket-Client
func (c *WebSocketClient) writePump() {
defer c.conn.Close()
for {
message, ok := <-c.send
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
err := c.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("⚠️ Fehler beim Senden: %v", err)
return
}
}
}
// handleMessage verarbeitet eingehende Nachrichten vom Client
func (c *WebSocketClient) handleMessage(msg WebSocketMessage) {
switch msg.Type {
case "join":
var req game.JoinRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
log.Printf("❌ Join-Payload ungültig: %v", err)
return
}
c.playerID = req.Name
c.roomID = req.RoomID
if c.roomID == "" {
c.roomID = "lobby"
}
log.Printf("📥 WebSocket JOIN: Name=%s, RoomID=%s", req.Name, req.RoomID)
// An NATS weiterleiten
c.natsConn.Publish("game.join", &req)
// Auf Game-Updates für diesen Raum subscriben
roomChannel := "game.update." + c.roomID
sub, err := c.natsConn.Subscribe(roomChannel, func(state *game.GameState) {
// GameState an WebSocket-Client senden
data, _ := json.Marshal(map[string]interface{}{
"type": "game_update",
"payload": state,
})
select {
case c.send <- data:
default:
log.Printf("⚠️ Send channel voll, Nachricht verworfen")
}
})
if err != nil {
log.Printf("❌ Fehler beim Subscribe auf %s: %v", roomChannel, err)
} else {
c.subUpdates = sub
log.Printf("👂 WebSocket-Client lauscht auf %s", roomChannel)
}
case "input":
var input game.ClientInput
if err := json.Unmarshal(msg.Payload, &input); err != nil {
log.Printf("❌ Input-Payload ungültig: %v", err)
return
}
// PlayerID setzen falls nicht vorhanden
if input.PlayerID == "" {
input.PlayerID = c.playerID
}
// An NATS weiterleiten
c.natsConn.Publish("game.input", &input)
case "start":
var req game.StartRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
log.Printf("❌ Start-Payload ungültig: %v", err)
return
}
log.Printf("▶️ WebSocket START: RoomID=%s", req.RoomID)
c.natsConn.Publish("game.start", &req)
case "leaderboard_request":
var req game.LeaderboardRequest
if err := json.Unmarshal(msg.Payload, &req); err != nil {
log.Printf("❌ Leaderboard-Request ungültig: %v", err)
return
}
log.Printf("🏆 WebSocket Leaderboard-Request: Mode=%s", req.Mode)
// Auf Leaderboard-Response subscriben (einmalig)
responseChannel := "leaderboard.response." + c.playerID
sub, _ := c.natsConn.Subscribe(responseChannel, func(resp *game.LeaderboardResponse) {
data, _ := json.Marshal(map[string]interface{}{
"type": "leaderboard_response",
"payload": resp,
})
select {
case c.send <- data:
default:
}
})
// Nach 5 Sekunden unsubscriben
go func() {
<-make(chan struct{})
sub.Unsubscribe()
}()
// Request mit ResponseChannel an NATS senden
req.ResponseChannel = responseChannel
c.natsConn.Publish("leaderboard.request", &req)
case "score_submit":
var submit game.ScoreSubmission
if err := json.Unmarshal(msg.Payload, &submit); err != nil {
log.Printf("❌ Score-Submit ungültig: %v", err)
return
}
log.Printf("📊 WebSocket Score-Submit: Player=%s, Score=%d", submit.PlayerCode, submit.Score)
c.natsConn.Publish("score.submit", &submit)
default:
log.Printf("⚠️ Unbekannter Nachrichtentyp: %s", msg.Type)
}
}
// StartWebSocketGateway startet den WebSocket-Server
func StartWebSocketGateway(port string, ec *nats.EncodedConn) {
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
handleWebSocket(w, r, ec)
})
log.Printf("🌐 WebSocket-Gateway läuft auf http://localhost:%s/ws", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal("❌ WebSocket-Server Fehler: ", err)
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ services:
ports:
- "4222:4222" # Client Port (für unsere Go Apps)
- "8222:8222" # Dashboard / Monitoring
command: "-js" # JetStream aktivieren (optional, aber gut für später)
command: ["-js"] # JetStream aktivieren
volumes:
redis_data:
+1
View File
@@ -12,4 +12,5 @@ require (
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.3 // indirect
)
+2
View File
@@ -2,6 +2,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/ebiten/v2 v2.9.6/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
github.com/nats-io/nats.go v1.48.0/go.mod h1:iRWIPokVIFbVijxuMQq4y9ttaBTMe0SFdlZfMDd+33g=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
+12
View File
@@ -0,0 +1,12 @@
# NATS Server Konfiguration mit WebSocket Support
port: 4222
http_port: 8222
# WebSocket Support für Browser-Clients
websocket {
port: 9222
no_tls: true
}
# JetStream aktivieren
jetstream: enabled
+46
View File
@@ -33,6 +33,17 @@ type AssetManifest struct {
type LevelObject struct {
AssetID string
X, Y float64
// Für bewegende Plattformen
MovingPlatform *MovingPlatformData `json:"moving_platform,omitempty"`
}
type MovingPlatformData struct {
StartX float64 `json:"start_x"` // Start-Position X (relativ zum Chunk)
StartY float64 `json:"start_y"` // Start-Position Y
EndX float64 `json:"end_x"` // End-Position X
EndY float64 `json:"end_y"` // End-Position Y
Speed float64 `json:"speed"` // Geschwindigkeit (Einheiten pro Sekunde)
}
type Chunk struct {
ID string
@@ -64,6 +75,9 @@ type ClientInput struct {
type JoinRequest struct {
Name string `json:"name"`
RoomID string `json:"room_id"`
GameMode string `json:"game_mode"` // "solo" oder "coop"
IsHost bool `json:"is_host"`
TeamName string `json:"team_name"`
}
type PlayerState struct {
@@ -75,10 +89,13 @@ type PlayerState struct {
VY float64 `json:"vy"`
State string `json:"state"`
OnGround bool `json:"on_ground"`
OnWall bool `json:"on_wall"` // Ist an einer Wand
LastInputSeq uint32 `json:"last_input_seq"` // Letzte verarbeitete Input-Sequenz
Score int `json:"score"` // Punkte des Spielers
IsAlive bool `json:"is_alive"` // Lebt der Spieler noch?
IsSpectator bool `json:"is_spectator"` // Ist im Zuschauer-Modus
HasDoubleJump bool `json:"has_double_jump"` // Hat Double Jump Powerup
HasGodMode bool `json:"has_godmode"` // Hat Godmode Powerup
}
type GameState struct {
@@ -90,6 +107,17 @@ type GameState struct {
HostID string `json:"host_id"`
ScrollX float64 `json:"scroll_x"`
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex)
CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt
MovingPlatforms []MovingPlatformSync `json:"moving_platforms"` // Bewegende Plattformen
}
// MovingPlatformSync: Synchronisiert die Position einer bewegenden Plattform
type MovingPlatformSync struct {
ChunkID string `json:"chunk_id"`
ObjectIdx int `json:"object_idx"`
AssetID string `json:"asset_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
// Leaderboard-Eintrag
@@ -105,4 +133,22 @@ type ScoreSubmission struct {
PlayerName string `json:"player_name"`
PlayerCode string `json:"player_code"`
Score int `json:"score"`
Name string `json:"name"` // Alternativer Name-Feld (für Kompatibilität)
Mode string `json:"mode"` // "solo" oder "coop"
}
// Start-Request vom Client
type StartRequest struct {
RoomID string `json:"room_id"`
}
// Leaderboard-Request vom Client
type LeaderboardRequest struct {
Mode string `json:"mode"` // "solo" oder "coop"
ResponseChannel string `json:"response_channel"`
}
// Leaderboard-Response vom Server
type LeaderboardResponse struct {
Entries []LeaderboardEntry `json:"entries"`
}
+9
View File
@@ -11,6 +11,7 @@ type GameStateMessage struct {
Players map[string]*PlayerState `json:"players"` // Alle Spieler (1 bis 16)
Score float64 `json:"score"`
Multiplier int `json:"multiplier"`
MovingPlatforms []*MovingPlatformState `json:"moving_platforms"` // Bewegende Plattformen
}
type PlayerState struct {
@@ -18,3 +19,11 @@ type PlayerState struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type MovingPlatformState struct {
ChunkID string `json:"chunk_id"`
ObjectIdx int `json:"object_idx"`
AssetID string `json:"asset_id"`
X float64 `json:"x"`
Y float64 `json:"y"`
}
+11 -17
View File
@@ -40,48 +40,42 @@ func InitLeaderboard(redisAddr string) error {
}
func (lb *Leaderboard) AddScore(name, code string, score int) bool {
// Prüfe ob Spieler bereits existiert
existingScoreStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result()
if err == nil {
var existingScore int
json.Unmarshal([]byte(existingScoreStr), &existingScore)
if score <= existingScore {
return false // Neuer Score nicht besser
}
}
// Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp
timestamp := time.Now().Unix()
uniqueKey := code + "_" + time.Now().Format("20060102_150405")
// Score speichern
entry := game.LeaderboardEntry{
PlayerName: name,
PlayerCode: code,
Score: score,
Timestamp: time.Now().Unix(),
Timestamp: timestamp,
}
data, _ := json.Marshal(entry)
lb.rdb.HSet(lb.ctx, "leaderboard:players", code, string(data))
lb.rdb.HSet(lb.ctx, "leaderboard:entries", uniqueKey, string(data))
// In Sorted Set mit Score als Wert
// In Sorted Set mit Score als Wert (uniqueKey statt code!)
lb.rdb.ZAdd(lb.ctx, leaderboardKey, redis.Z{
Score: float64(score),
Member: code,
Member: uniqueKey,
})
log.Printf("🏆 Leaderboard Update: %s mit %d Punkten", name, score)
log.Printf("🏆 Leaderboard: %s mit %d Punkten (Entry: %s)", name, score, uniqueKey)
return true
}
func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry {
// Hole Top 10 (höchste Scores zuerst)
codes, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result()
uniqueKeys, err := lb.rdb.ZRevRange(lb.ctx, leaderboardKey, 0, 9).Result()
if err != nil {
log.Printf("⚠️ Fehler beim Abrufen des Leaderboards: %v", err)
return []game.LeaderboardEntry{}
}
entries := make([]game.LeaderboardEntry, 0)
for _, code := range codes {
dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:players", code).Result()
for _, uniqueKey := range uniqueKeys {
dataStr, err := lb.rdb.HGet(lb.ctx, "leaderboard:entries", uniqueKey).Result()
if err != nil {
continue
}
+368 -14
View File
@@ -2,6 +2,7 @@ package server
import (
"log"
"math"
"math/rand"
"sync"
"time"
@@ -17,11 +18,40 @@ type ServerPlayer struct {
X, Y float64
VX, VY float64
OnGround bool
OnWall bool // Ist an einer Wand
OnMovingPlatform *MovingPlatform // Referenz zur Plattform auf der der Spieler steht
InputX float64 // -1 (Links), 0, 1 (Rechts)
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
Score int
IsAlive bool
IsSpectator bool
// Powerups
HasDoubleJump bool // Doppelsprung aktiv?
DoubleJumpUsed bool // Wurde zweiter Sprung schon benutzt?
HasGodMode bool // Godmode aktiv?
GodModeEndTime time.Time // Wann endet Godmode?
}
type MovingPlatform struct {
ChunkID string // Welcher Chunk
ObjectIdx int // Index im Chunk
AssetID string // Asset-ID
CurrentX float64 // Aktuelle Position X (Welt-Koordinaten)
CurrentY float64 // Aktuelle Position Y
StartX float64 // Start-Position X (Welt-Koordinaten)
StartY float64 // Start-Position Y
EndX float64 // End-Position X (Welt-Koordinaten)
EndY float64 // End-Position Y
Speed float64 // Geschwindigkeit
Direction float64 // 1.0 = zu End, -1.0 = zu Start
IsActive bool // Hat die Bewegung bereits begonnen?
HitboxW float64 // Cached Hitbox
HitboxH float64
DrawOffX float64
DrawOffY float64
HitboxOffX float64
HitboxOffY float64
}
type Room struct {
@@ -39,8 +69,15 @@ type Room struct {
NextStart time.Time
HostID string
CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
CollectedPowerups map[string]bool // Key: "chunkID_objectIndex"
ScoreAccum float64 // Akkumulator für Distanz-Score
// Chunk-Pool für fairen Random-Spawn
ChunkPool []string // Verfügbare Chunks für nächsten Spawn
ChunkSpawnedCount map[string]int // Wie oft wurde jeder Chunk gespawnt
MovingPlatforms []*MovingPlatform // Aktive bewegende Plattformen
firstBroadcast bool // Wurde bereits geloggt?
stopChan chan struct{}
// Cache für Spieler-Hitbox aus Assets
@@ -61,9 +98,14 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
Status: "LOBBY",
stopChan: make(chan struct{}),
CollectedCoins: make(map[string]bool),
CollectedPowerups: make(map[string]bool),
ChunkSpawnedCount: make(map[string]int),
pW: 40, pH: 60, // Fallback
}
// Initialisiere Chunk-Pool mit allen verfügbaren Chunks
r.RefillChunkPool()
// Player Werte aus Manifest laden
if def, ok := w.Manifest.Assets["player"]; ok {
r.pW = def.Hitbox.W
@@ -214,6 +256,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
if p.OnGround {
p.VY = -14.0
p.OnGround = false
p.DoubleJumpUsed = false // Reset double jump on ground jump
} else if p.HasDoubleJump && !p.DoubleJumpUsed {
// Double Jump in der Luft
p.VY = -14.0
p.DoubleJumpUsed = true
log.Printf("⚡ %s verwendet Double Jump!", p.Name)
}
case "DOWN":
p.VY = 15.0
@@ -256,6 +304,8 @@ func (r *Room) Update() {
}
} else if r.Status == "RUNNING" {
r.GlobalScrollX += config.RunSpeed
// Bewegende Plattformen updaten
r.UpdateMovingPlatforms()
}
maxX := r.GlobalScrollX
@@ -282,20 +332,38 @@ func (r *Room) Update() {
}
// X Bewegung
currentSpeed := config.RunSpeed + (p.InputX * 4.0)
// Symmetrische Geschwindigkeit: Links = Rechts
// Nach rechts: RunSpeed + 11, Nach links: RunSpeed - 11
// Ergebnis: Rechts = 18, Links = -4 (beide gleich weit vom Scroll)
currentSpeed := config.RunSpeed + (p.InputX * 11.0)
nextX := p.X + currentSpeed
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitX {
if typeX == "obstacle" {
// Nicht blocken, sondern weiterlaufen und töten
if typeX == "wall" {
// Wand getroffen - kann klettern!
p.OnWall = true
// X-Position nicht ändern (bleibt an der Wand)
} else if typeX == "obstacle" {
// Godmode prüfen
if p.HasGodMode && time.Now().Before(p.GodModeEndTime) {
// Mit Godmode - Obstacle wird zerstört, Spieler überlebt
p.X = nextX
// TODO: Obstacle aus colliders entfernen (benötigt Referenz zum Obstacle)
log.Printf("🛡️ %s zerstört Obstacle mit Godmode!", p.Name)
} else {
// Ohne Godmode - Spieler stirbt
p.X = nextX
r.KillPlayer(p)
continue
}
} else {
// Platform blockiert
p.OnWall = false
}
} else {
p.X = nextX
p.OnWall = false
}
// Grenzen
@@ -312,28 +380,85 @@ func (r *Room) Update() {
}
// Y Bewegung
// An der Wand: Reduzierte Gravität + Klettern mit InputX
if p.OnWall {
// Wandrutschen (langsame Fallgeschwindigkeit)
p.VY += config.Gravity * 0.3 // 30% Gravität an der Wand
if p.VY > 3.0 {
p.VY = 3.0 // Maximal 3.0 beim Rutschen
}
// Hochklettern wenn nach oben gedrückt (InputX in Wandrichtung)
if p.InputX != 0 {
p.VY = -5.0 // Kletter-Geschwindigkeit nach oben
}
} else {
// Normal: Volle Gravität
p.VY += config.Gravity
if p.VY > config.MaxFall {
p.VY = config.MaxFall
}
}
nextY := p.Y + p.VY
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitY {
if typeY == "obstacle" {
// Nicht blocken, sondern weiterlaufen und töten
if typeY == "wall" {
// An der Wand: Nicht töten, sondern Position halten
if p.OnWall {
p.VY = 0
} else {
// Von oben/unten gegen Wand - töten (kein Klettern in Y-Richtung)
p.Y = nextY
r.KillPlayer(p)
continue
}
} else if typeY == "obstacle" {
// Obstacle - immer töten
p.Y = nextY
r.KillPlayer(p)
continue
} else {
// Platform blockiert
if p.VY > 0 {
p.OnGround = true
// Prüfe ob auf bewegender Plattform
platform := r.CheckMovingPlatformLanding(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
p.OnMovingPlatform = platform
}
p.VY = 0
}
} else {
p.Y += p.VY
p.OnGround = false
p.OnMovingPlatform = nil
}
// Spieler bewegt sich mit Plattform mit
if p.OnMovingPlatform != nil && p.OnGround {
// Berechne Plattform-Geschwindigkeit
mp := p.OnMovingPlatform
var targetX, targetY float64
if mp.Direction > 0 {
targetX, targetY = mp.EndX, mp.EndY
} else {
targetX, targetY = mp.StartX, mp.StartY
}
dx := targetX - mp.CurrentX
dy := targetY - mp.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
if dist > 0.1 {
movePerTick := mp.Speed / 60.0
platformVelX := (dx / dist) * movePerTick
platformVelY := (dy / dist) * movePerTick
// Übertrage Plattform-Geschwindigkeit auf Spieler
p.X += platformVelX
p.Y += platformVelY
}
}
if p.Y > 1000 {
@@ -342,6 +467,15 @@ func (r *Room) Update() {
// Coin Kollision prüfen
r.CheckCoinCollision(p)
// Powerup Kollision prüfen
r.CheckPowerupCollision(p)
// Godmode Timeout prüfen
if p.HasGodMode && time.Now().After(p.GodModeEndTime) {
p.HasGodMode = false
log.Printf("🛡️ Godmode von %s ist abgelaufen", p.Name)
}
}
// 2b. Distanz-Score updaten
@@ -380,6 +514,7 @@ func (r *Room) Update() {
func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
// 1. Statische Colliders (Chunks)
for _, c := range r.Colliders {
if game.CheckRectCollision(playerRect, c.Rect) {
log.Printf("🔴 COLLISION! Type=%s, Player: (%.1f, %.1f, %.1f x %.1f), Collider: (%.1f, %.1f, %.1f x %.1f)",
@@ -390,9 +525,44 @@ func (r *Room) CheckCollision(x, y, w, h float64) (bool, string) {
}
}
// 2. Bewegende Plattformen (dynamische Colliders)
for _, mp := range r.MovingPlatforms {
// Berechne Plattform-Hitbox an aktueller Position
mpRect := game.Rect{
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
W: mp.HitboxW,
H: mp.HitboxH,
}
if game.CheckRectCollision(playerRect, mpRect) {
return true, "platform"
}
}
return false, ""
}
// CheckMovingPlatformLanding prüft ob Spieler auf einer bewegenden Plattform landet
func (r *Room) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform {
playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
for _, mp := range r.MovingPlatforms {
mpRect := game.Rect{
OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
W: mp.HitboxW,
H: mp.HitboxH,
}
if game.CheckRectCollision(playerRect, mpRect) {
return mp
}
}
return nil
}
func (r *Room) UpdateMapLogic(maxX float64) {
if r.Status != "RUNNING" {
return
@@ -413,26 +583,178 @@ func (r *Room) UpdateMapLogic(maxX float64) {
chunkWidth := float64(chunkDef.Width * config.TileSize)
if firstChunk.X+chunkWidth < r.GlobalScrollX-1000 {
// Lösche alle Coins dieses Chunks aus CollectedCoins
r.ClearChunkCoins(firstChunk.ChunkID)
// Lösche alle Powerups dieses Chunks
r.ClearChunkPowerups(firstChunk.ChunkID)
// Entferne bewegende Plattformen dieses Chunks
r.RemoveMovingPlatforms(firstChunk.ChunkID)
r.ActiveChunks = r.ActiveChunks[1:]
r.Colliders = r.World.GenerateColliders(r.ActiveChunks)
log.Printf("🗑️ Chunk despawned: %s", firstChunk.ChunkID)
}
}
}
func (r *Room) SpawnNextChunk() {
keys := make([]string, 0, len(r.World.ChunkLibrary))
for k := range r.World.ChunkLibrary {
keys = append(keys, k)
// ClearChunkCoins löscht alle eingesammelten Coins eines Chunks
func (r *Room) ClearChunkCoins(chunkID string) {
prefix := chunkID + "_"
coinsCleared := 0
for key := range r.CollectedCoins {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(r.CollectedCoins, key)
coinsCleared++
}
}
if coinsCleared > 0 {
log.Printf("💰 %d Coins von Chunk %s zurückgesetzt", coinsCleared, chunkID)
}
}
// InitMovingPlatforms initialisiert bewegende Plattformen für einen Chunk
func (r *Room) InitMovingPlatforms(chunkID string, chunkWorldX float64) {
chunkDef, exists := r.World.ChunkLibrary[chunkID]
if !exists {
return
}
if len(keys) > 0 {
// Zufälligen Chunk wählen
randomID := keys[rand.Intn(len(keys))]
chunkDef := r.World.ChunkLibrary[randomID]
for objIdx, obj := range chunkDef.Objects {
if obj.MovingPlatform != nil {
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
if !ok || assetDef.Type != "platform" {
continue
}
mpData := obj.MovingPlatform
platform := &MovingPlatform{
ChunkID: chunkID,
ObjectIdx: objIdx,
AssetID: obj.AssetID,
StartX: chunkWorldX + mpData.StartX,
StartY: mpData.StartY,
EndX: chunkWorldX + mpData.EndX,
EndY: mpData.EndY,
Speed: mpData.Speed,
Direction: 1.0, // Start bei StartX, bewege zu EndX
HitboxW: assetDef.Hitbox.W,
HitboxH: assetDef.Hitbox.H,
DrawOffX: assetDef.DrawOffX,
DrawOffY: assetDef.DrawOffY,
HitboxOffX: assetDef.Hitbox.OffsetX,
HitboxOffY: assetDef.Hitbox.OffsetY,
}
platform.CurrentX = platform.StartX
platform.CurrentY = platform.StartY
r.MovingPlatforms = append(r.MovingPlatforms, platform)
log.Printf("🔄 Bewegende Plattform initialisiert: %s in Chunk %s", obj.AssetID, chunkID)
}
}
}
// RemoveMovingPlatforms entfernt alle Plattformen eines Chunks
func (r *Room) RemoveMovingPlatforms(chunkID string) {
newPlatforms := make([]*MovingPlatform, 0)
removedCount := 0
for _, p := range r.MovingPlatforms {
if p.ChunkID != chunkID {
newPlatforms = append(newPlatforms, p)
} else {
removedCount++
}
}
r.MovingPlatforms = newPlatforms
if removedCount > 0 {
log.Printf("🗑️ %d bewegende Plattformen von Chunk %s entfernt", removedCount, chunkID)
}
}
// UpdateMovingPlatforms bewegt alle aktiven Plattformen
func (r *Room) UpdateMovingPlatforms() {
// Sichtbarer Bereich: GlobalScrollX bis GlobalScrollX + 1400
// Aktivierung bei 3/4: GlobalScrollX + (1400 * 3/4) = GlobalScrollX + 1050
activationPoint := r.GlobalScrollX + 1050
for _, p := range r.MovingPlatforms {
// Prüfe ob Plattform den Aktivierungspunkt erreicht hat
if !p.IsActive {
// Aktiviere Plattform, wenn sie bei 3/4 des Bildschirms ist
if p.CurrentX <= activationPoint {
p.IsActive = true
log.Printf("▶️ Plattform aktiviert: %s (X=%.0f)", p.ChunkID, p.CurrentX)
} else {
// Noch nicht weit genug gescrollt, nicht bewegen
continue
}
}
// Bewegung berechnen (Speed pro Sekunde, bei 60 FPS = Speed/60)
movePerTick := p.Speed / 60.0
// Bewegungsvektor von CurrentPos zu Ziel
var targetX, targetY float64
if p.Direction > 0 {
targetX, targetY = p.EndX, p.EndY
} else {
targetX, targetY = p.StartX, p.StartY
}
dx := targetX - p.CurrentX
dy := targetY - p.CurrentY
dist := math.Sqrt(dx*dx + dy*dy)
if dist < movePerTick {
// Ziel erreicht, umkehren
p.CurrentX = targetX
p.CurrentY = targetY
p.Direction *= -1.0
} else {
// Weiterbewegen
p.CurrentX += (dx / dist) * movePerTick
p.CurrentY += (dy / dist) * movePerTick
}
}
}
// RefillChunkPool füllt den Pool mit allen verfügbaren Chunks
func (r *Room) RefillChunkPool() {
r.ChunkPool = make([]string, 0, len(r.World.ChunkLibrary))
for chunkID := range r.World.ChunkLibrary {
if chunkID != "start" { // Start-Chunk nicht in Pool
r.ChunkPool = append(r.ChunkPool, chunkID)
}
}
// Mische Pool für zufällige Reihenfolge
rand.Shuffle(len(r.ChunkPool), func(i, j int) {
r.ChunkPool[i], r.ChunkPool[j] = r.ChunkPool[j], r.ChunkPool[i]
})
log.Printf("🔄 Chunk-Pool neu gefüllt: %d Chunks", len(r.ChunkPool))
}
func (r *Room) SpawnNextChunk() {
// Pool leer? Nachfüllen!
if len(r.ChunkPool) == 0 {
r.RefillChunkPool()
}
if len(r.ChunkPool) > 0 {
// Nimm ersten Chunk aus Pool (bereits gemischt)
randomID := r.ChunkPool[0]
r.ChunkPool = r.ChunkPool[1:] // Entferne aus Pool
chunkDef := r.World.ChunkLibrary[randomID]
newChunk := game.ActiveChunk{ChunkID: randomID, X: r.MapEndX}
r.ActiveChunks = append(r.ActiveChunks, newChunk)
r.MapEndX += float64(chunkDef.Width * config.TileSize)
// Initialisiere bewegende Plattformen für diesen Chunk
r.InitMovingPlatforms(randomID, newChunk.X)
r.ChunkSpawnedCount[randomID]++
log.Printf("🎲 Chunk gespawnt: %s (Total: %d mal, Pool: %d übrig)", randomID, r.ChunkSpawnedCount[randomID], len(r.ChunkPool))
} else {
// Fallback, falls keine Chunks da sind
r.MapEndX += 1280
@@ -454,6 +776,8 @@ func (r *Room) Broadcast() {
HostID: r.HostID,
ScrollX: r.GlobalScrollX,
CollectedCoins: r.CollectedCoins,
CollectedPowerups: r.CollectedPowerups,
MovingPlatforms: make([]game.MovingPlatformSync, 0, len(r.MovingPlatforms)),
}
for id, p := range r.Players {
@@ -465,16 +789,31 @@ func (r *Room) Broadcast() {
VX: p.VX,
VY: p.VY,
OnGround: p.OnGround,
OnWall: p.OnWall,
LastInputSeq: p.LastInputSeq,
Score: p.Score,
IsAlive: p.IsAlive,
IsSpectator: p.IsSpectator,
HasDoubleJump: p.HasDoubleJump,
HasGodMode: p.HasGodMode,
}
}
// Bewegende Plattformen synchronisieren
for _, mp := range r.MovingPlatforms {
state.MovingPlatforms = append(state.MovingPlatforms, game.MovingPlatformSync{
ChunkID: mp.ChunkID,
ObjectIdx: mp.ObjectIdx,
AssetID: mp.AssetID,
X: mp.CurrentX,
Y: mp.CurrentY,
})
}
// DEBUG: Ersten Broadcast loggen (nur beim ersten Mal)
if len(r.Players) > 0 && r.Status == "LOBBY" {
if !r.firstBroadcast && len(r.Players) > 0 && r.Status == "LOBBY" {
log.Printf("📡 Broadcast: Room=%s, Players=%d, Chunks=%d, Status=%s", r.ID, len(state.Players), len(state.WorldChunks), r.Status)
r.firstBroadcast = true
}
// Senden an raum-spezifischen Channel: "game.update.<ROOMID>"
@@ -490,3 +829,18 @@ func (r *Room) RemovePlayer(id string) {
delete(r.Players, id)
log.Printf(" Player %s left room %s", id, r.ID)
}
// ClearChunkPowerups löscht alle eingesammelten Powerups eines Chunks
func (r *Room) ClearChunkPowerups(chunkID string) {
prefix := chunkID + "_"
powerupsCleared := 0
for key := range r.CollectedPowerups {
if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
delete(r.CollectedPowerups, key)
powerupsCleared++
}
}
if powerupsCleared > 0 {
log.Printf("⚡ %d Powerups von Chunk %s zurückgesetzt", powerupsCleared, chunkID)
}
}
+82 -27
View File
@@ -3,6 +3,7 @@ package server
import (
"fmt"
"log"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
@@ -47,10 +48,10 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
continue
}
// Coin-Hitbox
// Coin-Hitbox (muss DrawOffX/Y einbeziehen wie bei Obstacles!)
coinHitbox := game.Rect{
OffsetX: activeChunk.X + obj.X + assetDef.Hitbox.OffsetX,
OffsetY: obj.Y + assetDef.Hitbox.OffsetY,
OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
W: assetDef.Hitbox.W,
H: assetDef.Hitbox.H,
}
@@ -66,39 +67,93 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
}
}
// CheckPowerupCollision prüft ob Spieler Powerups einsammelt
func (r *Room) CheckPowerupCollision(p *ServerPlayer) {
if !p.IsAlive || p.IsSpectator {
return
}
playerHitbox := game.Rect{
OffsetX: p.X + r.pDrawOffX + r.pHitboxOffX,
OffsetY: p.Y + r.pDrawOffY + r.pHitboxOffY,
W: r.pW,
H: r.pH,
}
// Durch alle aktiven Chunks iterieren
for _, activeChunk := range r.ActiveChunks {
chunkDef, exists := r.World.ChunkLibrary[activeChunk.ChunkID]
if !exists {
continue
}
// Durch alle Objekte im Chunk
for objIdx, obj := range chunkDef.Objects {
assetDef, ok := r.World.Manifest.Assets[obj.AssetID]
if !ok {
continue
}
// Nur Powerups prüfen
if assetDef.Type != "powerup" {
continue
}
// Eindeutiger Key für dieses Powerup
powerupKey := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
// Wurde bereits eingesammelt?
if r.CollectedPowerups[powerupKey] {
continue
}
// Powerup-Hitbox
powerupHitbox := game.Rect{
OffsetX: activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX,
OffsetY: obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY,
W: assetDef.Hitbox.W,
H: assetDef.Hitbox.H,
}
// Kollision?
if game.CheckRectCollision(playerHitbox, powerupHitbox) {
// Powerup einsammeln!
r.CollectedPowerups[powerupKey] = true
// Powerup-Effekt anwenden
switch obj.AssetID {
case "jumpboost":
p.HasDoubleJump = true
p.DoubleJumpUsed = false
log.Printf("⚡ %s hat Double Jump erhalten!", p.Name)
case "godmode":
p.HasGodMode = true
p.GodModeEndTime = time.Now().Add(10 * time.Second)
log.Printf("🛡️ %s hat Godmode erhalten! (10 Sekunden)", p.Name)
}
}
}
}
}
// UpdateDistanceScore aktualisiert Distanz-basierte Punkte
func (r *Room) UpdateDistanceScore() {
if r.Status != "RUNNING" {
return
}
// Anzahl lebender Spieler zählen
aliveCount := 0
// Jeder Spieler bekommt Punkte basierend auf seiner eigenen Distanz
// Punkte = (X-Position / TileSize) = Distanz in Tiles
for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator {
aliveCount++
}
}
// Berechne Score basierend auf X-Position
// 1 Punkt pro Tile (64px)
newScore := int(p.X / 64.0)
if aliveCount == 0 {
return
}
// Multiplier = Anzahl lebender Spieler
multiplier := float64(aliveCount)
// Akkumulator erhöhen: multiplier Punkte pro Sekunde
// Bei 60 FPS: multiplier / 60.0 Punkte pro Tick
r.ScoreAccum += multiplier / 60.0
// Wenn Akkumulator >= 1.0, Punkte vergeben
if r.ScoreAccum >= 1.0 {
pointsToAdd := int(r.ScoreAccum)
r.ScoreAccum -= float64(pointsToAdd)
for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator {
p.Score += pointsToAdd
// Nur updaten wenn höher als aktueller Score
if newScore > p.Score {
p.Score = newScore
}
}
}
+1
View File
@@ -0,0 +1 @@
49badef83664a3d83cb4ec6ab0853c9e