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

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": ""
}
}

View File

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

View File

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 184 KiB

View File

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

View File

@@ -0,0 +1,61 @@
{
"ID": "chunk_02",
"Width": 50,
"Objects": [
{
"AssetID": "coin",
"X": 512,
"Y": 476
},
{
"AssetID": "coin",
"X": 704,
"Y": 476
},
{
"AssetID": "coin",
"X": 1024,
"Y": 476
},
{
"AssetID": "platform_1767135546",
"X": 1152,
"Y": 484
},
{
"AssetID": "platform_1767135546",
"X": 1344,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1472,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1600,
"Y": 420
},
{
"AssetID": "platform_1767135546",
"X": 1792,
"Y": 292
},
{
"AssetID": "platform_1767135546",
"X": 1920,
"Y": 292
},
{
"AssetID": "platform_1767135546",
"X": 2048,
"Y": 292
},
{
"AssetID": "coin",
"X": 2112,
"Y": 220
}
]
}

View File

@@ -0,0 +1,65 @@
{
"ID": "chunk_03",
"Width": 50,
"Objects": [
{
"AssetID": "platform_1767135546",
"X": 514,
"Y": 519,
"moving_platform": {
"start_x": 514,
"start_y": 522,
"end_x": 800,
"end_y": 239,
"speed": 100
}
},
{
"AssetID": "coin",
"X": 834,
"Y": 204
},
{
"AssetID": "wall_1767369789",
"X": 1026,
"Y": 539
},
{
"AssetID": "wall_1767369789",
"X": 1026,
"Y": 412
},
{
"AssetID": "platform_1767135546",
"X": 1091,
"Y": 318,
"moving_platform": {
"start_x": 1109,
"start_y": 304,
"end_x": 1898,
"end_y": 307,
"speed": 50
}
},
{
"AssetID": "desk",
"X": 1421,
"Y": 534
},
{
"AssetID": "desk",
"X": 1794,
"Y": 535
},
{
"AssetID": "coin",
"X": 1169,
"Y": 272
},
{
"AssetID": "coin",
"X": 1598,
"Y": 260
}
]
}

View File

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

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 653 KiB

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

Binary file not shown.

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,55 @@
//go:build !wasm
// +build !wasm
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"log"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
func (g *Game) loadAssets() {
// Pfad anpassen: Wir suchen im relativen Pfad
baseDir := "./cmd/client/assets"
manifestPath := filepath.Join(baseDir, "assets.json")
data, err := ioutil.ReadFile(manifestPath)
if err == nil {
var m game.AssetManifest
json.Unmarshal(data, &m)
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen (Native):", len(m.Assets), "Einträge")
} else {
log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath)
// Fallback: Leeres Manifest, damit das Spiel nicht abstürzt
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
}
// Chunks laden (plattformspezifisch via Build-Tags)
g.loadChunks()
// Bilder vorladen
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder (Native): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}

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

@@ -0,0 +1,53 @@
//go:build wasm
// +build wasm
package main
import (
_ "embed"
"encoding/json"
"fmt"
"log"
"path/filepath"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
//go:embed assets/assets.json
var assetsJSON []byte
func (g *Game) loadAssets() {
// Assets Manifest aus eingebetteter Datei laden
var m game.AssetManifest
if err := json.Unmarshal(assetsJSON, &m); err != nil {
log.Printf("⚠️ Fehler beim Parsen von assets.json: %v", err)
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
} else {
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen (WASM):", len(m.Assets), "Einträge")
}
// Chunks laden (auch per go:embed in chunks_wasm.go)
g.loadChunks()
// Bilder vorladen - In WASM sind die Assets relativ zum HTML
baseDir := "./assets"
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder (WASM): %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}

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

@@ -0,0 +1,237 @@
package main
import (
"bytes"
_ "embed"
"log"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
"github.com/hajimehoshi/ebiten/v2/audio/wav"
)
const (
SampleRate = 44100 // Muss mit der MP3 Sample-Rate übereinstimmen
)
//go:embed assets/game.mp3
var gameMusicData []byte
//go:embed assets/jump.wav
var jumpSoundData []byte
//go:embed assets/pickupCoin.wav
var coinSoundData []byte
//go:embed assets/powerUp.wav
var powerUpSoundData []byte
// AudioSystem verwaltet Musik und Sound-Effekte
type AudioSystem struct {
audioContext *audio.Context
// Musik
musicPlayer *audio.Player
musicVolume float64
// Sound-Effekte
jumpSound []byte
coinSound []byte
powerUpSound []byte
sfxVolume float64
// Mute
muted bool
}
// NewAudioSystem erstellt ein neues Audio-System
func NewAudioSystem() *AudioSystem {
log.Println("🎵 Initialisiere Audio-System...")
ctx := audio.NewContext(SampleRate)
as := &AudioSystem{
audioContext: ctx,
musicVolume: 0.3, // 30% Standard-Lautstärke
sfxVolume: 0.5, // 50% Standard-Lautstärke
muted: false,
}
// Musik laden
log.Printf("📀 Lade Musik (%.2f MB)...", float64(len(gameMusicData))/(1024*1024))
as.loadMusic()
// Sound-Effekte dekodieren
log.Println("🔊 Lade Sound-Effekte...")
as.jumpSound = as.loadWav(jumpSoundData)
as.coinSound = as.loadWav(coinSoundData)
as.powerUpSound = as.loadWav(powerUpSoundData)
log.Println("✅ Audio-System bereit")
return as
}
// loadMusic lädt und startet die Hintergrundmusik
func (as *AudioSystem) loadMusic() {
// MP3 dekodieren
stream, err := mp3.DecodeWithSampleRate(SampleRate, bytes.NewReader(gameMusicData))
if err != nil {
log.Printf("❌ Fehler beim Laden der Musik: %v", err)
return
}
// Infinite Loop
loop := audio.NewInfiniteLoop(stream, stream.Length())
// Player erstellen
player, err := as.audioContext.NewPlayer(loop)
if err != nil {
log.Printf("❌ Fehler beim Erstellen des Music Players: %v", err)
return
}
as.musicPlayer = player
as.updateMusicVolume()
log.Println("🎵 Musik geladen")
}
// loadWav lädt eine WAV-Datei und gibt die dekodierten Bytes zurück
func (as *AudioSystem) loadWav(data []byte) []byte {
stream, err := wav.DecodeWithSampleRate(SampleRate, bytes.NewReader(data))
if err != nil {
log.Printf("❌ Fehler beim Laden von WAV: %v", err)
return nil
}
// Stream in Bytes lesen
decoded := bytes.NewBuffer(nil)
_, err = decoded.ReadFrom(stream)
if err != nil {
log.Printf("❌ Fehler beim Dekodieren von WAV: %v", err)
return nil
}
return decoded.Bytes()
}
// PlayMusic startet die Hintergrundmusik
func (as *AudioSystem) PlayMusic() {
if as.musicPlayer == nil {
log.Println("⚠️ Music Player ist nil - Musik kann nicht gestartet werden")
return
}
if as.musicPlayer.IsPlaying() {
log.Println("⚠️ Musik läuft bereits")
return
}
as.musicPlayer.Play()
log.Printf("▶️ Musik gestartet (Volume: %.2f, Muted: %v)", as.musicVolume, as.muted)
}
// StopMusic stoppt die Hintergrundmusik
func (as *AudioSystem) StopMusic() {
if as.musicPlayer != nil && as.musicPlayer.IsPlaying() {
as.musicPlayer.Pause()
log.Println("⏸️ Musik gestoppt")
}
}
// PlayJump spielt den Jump-Sound ab
func (as *AudioSystem) PlayJump() {
if as.muted {
return
}
as.playSoundEffect(as.jumpSound, as.sfxVolume)
}
// PlayCoin spielt den Coin-Pickup-Sound ab
func (as *AudioSystem) PlayCoin() {
if as.muted {
return
}
as.playSoundEffect(as.coinSound, as.sfxVolume)
}
// PlayPowerUp spielt den PowerUp-Sound ab
func (as *AudioSystem) PlayPowerUp() {
if as.muted {
return
}
as.playSoundEffect(as.powerUpSound, as.sfxVolume)
}
// playSoundEffect spielt einen Sound-Effekt ab
func (as *AudioSystem) playSoundEffect(soundData []byte, volume float64) {
if soundData == nil {
return
}
player := as.audioContext.NewPlayerFromBytes(soundData)
if player == nil {
return
}
player.SetVolume(volume)
player.Play()
}
// SetMusicVolume setzt die Musik-Lautstärke (0.0 - 1.0)
func (as *AudioSystem) SetMusicVolume(volume float64) {
as.musicVolume = clamp(volume, 0.0, 1.0)
as.updateMusicVolume()
}
// SetSFXVolume setzt die Sound-Effekt-Lautstärke (0.0 - 1.0)
func (as *AudioSystem) SetSFXVolume(volume float64) {
as.sfxVolume = clamp(volume, 0.0, 1.0)
}
// ToggleMute schaltet Mute an/aus
func (as *AudioSystem) ToggleMute() {
as.muted = !as.muted
as.updateMusicVolume()
log.Printf("🔇 Mute: %v", as.muted)
}
// SetMuted setzt Mute-Status
func (as *AudioSystem) SetMuted(muted bool) {
as.muted = muted
as.updateMusicVolume()
}
// IsMuted gibt zurück, ob der Sound gemutet ist
func (as *AudioSystem) IsMuted() bool {
return as.muted
}
// GetMusicVolume gibt die aktuelle Musik-Lautstärke zurück
func (as *AudioSystem) GetMusicVolume() float64 {
return as.musicVolume
}
// GetSFXVolume gibt die aktuelle SFX-Lautstärke zurück
func (as *AudioSystem) GetSFXVolume() float64 {
return as.sfxVolume
}
// updateMusicVolume aktualisiert die Musik-Lautstärke
func (as *AudioSystem) updateMusicVolume() {
if as.musicPlayer != nil {
if as.muted {
as.musicPlayer.SetVolume(0)
} else {
as.musicPlayer.SetVolume(as.musicVolume)
}
}
}
// clamp begrenzt einen Wert zwischen min und max
func clamp(value, min, max float64) float64 {
if value < min {
return min
}
if value > max {
return max
}
return value
}

View File

@@ -0,0 +1,25 @@
//go:build !wasm
// +build !wasm
package main
import (
"log"
"path/filepath"
)
// loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop)
func (g *Game) loadChunks() {
baseDir := "cmd/client/assets"
chunkDir := filepath.Join(baseDir, "chunks")
err := g.world.LoadChunkLibrary(chunkDir)
if err != nil {
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
} else {
log.Printf("✅ Chunks geladen: %d Einträge", len(g.world.ChunkLibrary))
for id, chunk := range g.world.ChunkLibrary {
log.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d", id, chunk.Width, len(chunk.Objects))
}
}
}

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

@@ -0,0 +1,55 @@
//go:build wasm
// +build wasm
package main
import (
_ "embed"
"encoding/json"
"log"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
//go:embed assets/chunks/start.json
var chunkStartData []byte
//go:embed assets/chunks/chunk_01.json
var chunk01Data []byte
//go:embed assets/chunks/chunk_02.json
var chunk02Data []byte
//go:embed assets/chunks/chunk_03.json
var chunk03Data []byte
//go:embed assets/chunks/chunk_04.json
var chunk04Data []byte
// loadChunks lädt alle Chunks aus eingebetteten Daten (WebAssembly)
func (g *Game) loadChunks() {
chunkDataMap := map[string][]byte{
"start": chunkStartData,
"chunk_01": chunk01Data,
"chunk_02": chunk02Data,
"chunk_03": chunk03Data,
"chunk_04": chunk04Data,
}
count := 0
for id, data := range chunkDataMap {
var c game.Chunk
if err := json.Unmarshal(data, &c); err == nil {
if c.ID == "" {
c.ID = id
}
g.world.ChunkLibrary[c.ID] = c
count++
log.Printf("📦 Chunk geladen: %s (Width=%d, Objects=%d)", c.ID, c.Width, len(c.Objects))
} else {
log.Printf("⚠️ Fehler beim Laden von Chunk %s: %v", id, err)
}
}
log.Printf("✅ %d Chunks für WASM geladen", count)
}

View File

@@ -0,0 +1,268 @@
//go:build !wasm
// +build !wasm
package main
import (
"encoding/json"
"log"
"net/url"
"time"
"github.com/gorilla/websocket"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// wsConn verwaltet die WebSocket-Verbindung (Native Desktop)
type wsConn struct {
conn *websocket.Conn
send chan []byte
stopChan chan struct{}
}
// WebSocketMessage ist das Format für WebSocket-Nachrichten
type WebSocketMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// connectToServer verbindet sich über WebSocket mit dem Gateway (Native Desktop)
func (g *Game) connectToServer() {
serverURL := "ws://localhost:8080/ws"
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
u, err := url.Parse(serverURL)
if err != nil {
log.Printf("❌ URL Parse Fehler: %v", err)
return
}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Printf("❌ WebSocket Connect Fehler: %v", err)
return
}
wsConn := &wsConn{
conn: conn,
send: make(chan []byte, 100),
stopChan: make(chan struct{}),
}
g.wsConn = wsConn
log.Println("✅ WebSocket verbunden!")
g.connected = true
// Goroutines für Lesen und Schreiben starten
go g.wsReadPump()
go g.wsWritePump()
// JOIN senden
g.sendJoinRequest()
}
// wsReadPump liest Nachrichten vom WebSocket
func (g *Game) wsReadPump() {
defer func() {
g.wsConn.conn.Close()
g.connected = false
}()
for {
var msg WebSocketMessage
err := g.wsConn.conn.ReadJSON(&msg)
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("⚠️ WebSocket Fehler: %v", err)
}
break
}
switch msg.Type {
case "game_update":
// GameState Update
payloadBytes, _ := json.Marshal(msg.Payload)
var state game.GameState
if err := json.Unmarshal(payloadBytes, &state); err == nil {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
g.gameState = state
g.stateMutex.Unlock()
}
case "leaderboard_response":
// Leaderboard Response
payloadBytes, _ := json.Marshal(msg.Payload)
var resp game.LeaderboardResponse
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
g.leaderboardMutex.Lock()
g.leaderboard = resp.Entries
g.leaderboardMutex.Unlock()
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
}
}
}
}
// wsWritePump sendet Nachrichten zum WebSocket
func (g *Game) wsWritePump() {
defer g.wsConn.conn.Close()
for {
select {
case message, ok := <-g.wsConn.send:
if !ok {
g.wsConn.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
err := g.wsConn.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
log.Printf("⚠️ Fehler beim Senden: %v", err)
return
}
case <-g.wsConn.stopChan:
return
}
}
}
// sendWebSocketMessage sendet eine Nachricht über WebSocket
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
if g.wsConn == nil || !g.connected {
log.Println("⚠️ WebSocket nicht verbunden")
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("❌ Fehler beim Marshallen: %v", err)
return
}
select {
case g.wsConn.send <- data:
default:
log.Println("⚠️ Send channel voll, Nachricht verworfen")
}
}
// sendJoinRequest sendet Join-Request über WebSocket
func (g *Game) sendJoinRequest() {
msg := WebSocketMessage{
Type: "join",
Payload: game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
GameMode: g.gameMode,
IsHost: g.isHost,
TeamName: g.teamName,
},
}
g.sendWebSocketMessage(msg)
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
}
// sendStartRequest sendet Start-Request über WebSocket
func (g *Game) sendStartRequest() {
msg := WebSocketMessage{
Type: "start",
Payload: game.StartRequest{
RoomID: g.roomID,
},
}
g.sendWebSocketMessage(msg)
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
}
// publishInput sendet Input über WebSocket
func (g *Game) publishInput(input game.ClientInput) {
msg := WebSocketMessage{
Type: "input",
Payload: input,
}
g.sendWebSocketMessage(msg)
}
// connectForLeaderboard verbindet für Leaderboard (Native)
func (g *Game) connectForLeaderboard() {
if g.wsConn != nil && g.connected {
// Bereits verbunden
g.requestLeaderboard()
return
}
// Neue Verbindung aufbauen
g.connectToServer()
// Kurz warten und dann Leaderboard anfragen
time.Sleep(500 * time.Millisecond)
g.requestLeaderboard()
}
// requestLeaderboard fordert Leaderboard an (Native)
func (g *Game) requestLeaderboard() {
mode := "solo"
if g.gameMode == "coop" {
mode = "coop"
}
msg := WebSocketMessage{
Type: "leaderboard_request",
Payload: game.LeaderboardRequest{
Mode: mode,
},
}
g.sendWebSocketMessage(msg)
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
}
// submitScore sendet Score ans Leaderboard (Native)
func (g *Game) submitScore() {
if g.scoreSubmitted {
return
}
g.stateMutex.Lock()
score := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
score = p.Score
break
}
}
g.stateMutex.Unlock()
if score == 0 {
log.Println("⚠️ Score ist 0, überspringe Submission")
return
}
name := g.playerName
if g.gameMode == "coop" && g.teamName != "" {
name = g.teamName
}
msg := WebSocketMessage{
Type: "score_submit",
Payload: game.ScoreSubmission{
PlayerCode: g.playerCode,
Name: name,
Score: score,
Mode: g.gameMode,
},
}
g.sendWebSocketMessage(msg)
g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score)
}

View File

@@ -0,0 +1,270 @@
//go:build wasm
// +build wasm
package main
import (
"encoding/json"
"log"
"syscall/js"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// WebSocketMessage ist das Format für WebSocket-Nachrichten
type WebSocketMessage struct {
Type string `json:"type"`
Payload interface{} `json:"payload"`
}
// wsConn verwaltet die WebSocket-Verbindung im Browser
type wsConn struct {
ws js.Value
messagesChan chan []byte
connected bool
}
// connectToServer verbindet sich über WebSocket mit dem Gateway
func (g *Game) connectToServer() {
serverURL := "ws://localhost:8080/ws"
log.Printf("🔌 Verbinde zu WebSocket-Gateway: %s", serverURL)
ws := js.Global().Get("WebSocket").New(serverURL)
conn := &wsConn{
ws: ws,
messagesChan: make(chan []byte, 100),
connected: false,
}
// OnOpen Handler
ws.Call("addEventListener", "open", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("✅ WebSocket verbunden!")
conn.connected = true
g.connected = true
return nil
}))
// OnMessage Handler
ws.Call("addEventListener", "message", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) == 0 {
return nil
}
data := args[0].Get("data").String()
var msg WebSocketMessage
if err := json.Unmarshal([]byte(data), &msg); err != nil {
log.Printf("❌ Fehler beim Parsen der Nachricht: %v", err)
return nil
}
switch msg.Type {
case "game_update":
// GameState Update
payloadBytes, _ := json.Marshal(msg.Payload)
var state game.GameState
if err := json.Unmarshal(payloadBytes, &state); err == nil {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
g.gameState = state
g.stateMutex.Unlock()
}
case "leaderboard_response":
// Leaderboard Response
payloadBytes, _ := json.Marshal(msg.Payload)
var resp game.LeaderboardResponse
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
g.leaderboardMutex.Lock()
g.leaderboard = resp.Entries
g.leaderboardMutex.Unlock()
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(resp.Entries))
// An JavaScript senden
g.sendLeaderboardToJS()
}
}
return nil
}))
// OnError Handler
ws.Call("addEventListener", "error", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("❌ WebSocket Fehler!")
conn.connected = false
g.connected = false
return nil
}))
// OnClose Handler
ws.Call("addEventListener", "close", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("🔌 WebSocket geschlossen")
conn.connected = false
g.connected = false
return nil
}))
// Warte bis Verbindung hergestellt ist
for i := 0; i < 50; i++ {
if conn.connected {
break
}
time.Sleep(100 * time.Millisecond)
}
if !conn.connected {
log.Println("❌ WebSocket-Verbindung Timeout")
return
}
// WebSocket-Wrapper speichern
g.wsConn = conn
// JOIN senden
joinMsg := WebSocketMessage{
Type: "join",
Payload: game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
GameMode: g.gameMode,
IsHost: g.isHost,
TeamName: g.teamName,
},
}
g.sendWebSocketMessage(joinMsg)
log.Printf("➡️ JOIN gesendet über WebSocket: Name=%s, RoomID=%s", g.playerName, g.roomID)
}
// sendWebSocketMessage sendet eine Nachricht über WebSocket
func (g *Game) sendWebSocketMessage(msg WebSocketMessage) {
if g.wsConn == nil || !g.wsConn.connected {
log.Println("⚠️ WebSocket nicht verbunden")
return
}
data, err := json.Marshal(msg)
if err != nil {
log.Printf("❌ Fehler beim Marshallen: %v", err)
return
}
g.wsConn.ws.Call("send", string(data))
}
// sendInput sendet einen Input über WebSocket
func (g *Game) sendInput(input game.ClientInput) {
msg := WebSocketMessage{
Type: "input",
Payload: input,
}
g.sendWebSocketMessage(msg)
}
// startGame sendet den Start-Befehl über WebSocket
func (g *Game) startGame() {
msg := WebSocketMessage{
Type: "start",
Payload: game.StartRequest{
RoomID: g.roomID,
},
}
g.sendWebSocketMessage(msg)
log.Printf("▶️ START gesendet über WebSocket: RoomID=%s", g.roomID)
}
// connectForLeaderboard verbindet für Leaderboard-Abfrage
func (g *Game) connectForLeaderboard() {
if g.wsConn != nil && g.wsConn.connected {
// Bereits verbunden
g.requestLeaderboard()
return
}
// Neue Verbindung aufbauen
g.connectToServer()
// Kurz warten und dann Leaderboard anfragen
time.Sleep(500 * time.Millisecond)
g.requestLeaderboard()
}
// requestLeaderboard fordert das Leaderboard an
func (g *Game) requestLeaderboard() {
mode := "solo"
if g.gameMode == "coop" {
mode = "coop"
}
msg := WebSocketMessage{
Type: "leaderboard_request",
Payload: game.LeaderboardRequest{
Mode: mode,
},
}
g.sendWebSocketMessage(msg)
log.Printf("🏆 Leaderboard-Request gesendet: Mode=%s", mode)
}
// submitScore sendet den Score ans Leaderboard
func (g *Game) submitScore() {
if g.scoreSubmitted {
return
}
g.stateMutex.Lock()
score := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
score = p.Score
break
}
}
g.stateMutex.Unlock()
if score == 0 {
log.Println("⚠️ Score ist 0, überspringe Submission")
return
}
name := g.playerName
if g.gameMode == "coop" && g.teamName != "" {
name = g.teamName
}
msg := WebSocketMessage{
Type: "score_submit",
Payload: game.ScoreSubmission{
PlayerCode: g.playerCode,
Name: name,
Score: score,
Mode: g.gameMode,
},
}
g.sendWebSocketMessage(msg)
g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score)
}
// Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes
func (g *Game) sendJoinRequest() {
// Wird in connectToServer aufgerufen
}
func (g *Game) sendStartRequest() {
g.startGame()
}
func (g *Game) publishInput(input game.ClientInput) {
g.sendInput(input)
}

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

@@ -0,0 +1,22 @@
//go:build !wasm
// +build !wasm
package main
import (
"github.com/hajimehoshi/ebiten/v2"
)
// In Native: Nutze die normalen Draw-Funktionen
func (g *Game) drawMenu(screen *ebiten.Image) {
g.DrawMenu(screen)
}
func (g *Game) drawLobby(screen *ebiten.Image) {
g.DrawLobby(screen)
}
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
g.DrawLeaderboard(screen)
}

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

@@ -0,0 +1,28 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
)
// In WASM: Menü und Leaderboard werden in HTML angezeigt
// Lobby wird aber normal gezeichnet (für Co-op Warteraum)
func (g *Game) drawMenu(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Menü ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255})
}
func (g *Game) drawLobby(screen *ebiten.Image) {
// Lobby wird normal gezeichnet (für Co-op Warteraum)
g.DrawLobby(screen)
}
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Leaderboard ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255})
}

View File

@@ -19,16 +19,26 @@ import (
// --- INPUT & UPDATE LOGIC ---
func (g *Game) UpdateGame() {
// --- 1. KEYBOARD INPUT ---
// --- 1. MUTE TOGGLE ---
if inpututil.IsKeyJustPressed(ebiten.KeyM) {
g.audio.ToggleMute()
}
// --- 2. KEYBOARD INPUT ---
keyLeft := ebiten.IsKeyPressed(ebiten.KeyA) || ebiten.IsKeyPressed(ebiten.KeyLeft)
keyRight := ebiten.IsKeyPressed(ebiten.KeyD) || ebiten.IsKeyPressed(ebiten.KeyRight)
keyDown := inpututil.IsKeyJustPressed(ebiten.KeyS) || inpututil.IsKeyJustPressed(ebiten.KeyDown)
keyJump := inpututil.IsKeyJustPressed(ebiten.KeySpace) || inpututil.IsKeyJustPressed(ebiten.KeyW) || inpututil.IsKeyJustPressed(ebiten.KeyUp)
// --- 2. TOUCH INPUT HANDLING ---
// Tastatur-Nutzung erkennen (für Mobile Controls ausblenden)
if keyLeft || keyRight || keyDown || keyJump {
g.keyboardUsed = true
}
// --- 3. TOUCH INPUT HANDLING ---
g.handleTouchInput()
// --- 3. INPUT STATE ERSTELLEN ---
// --- 4. INPUT STATE ERSTELLEN ---
joyDir := 0.0
if g.joyActive {
diffX := g.joyStickX - g.joyBaseX
@@ -64,6 +74,34 @@ func (g *Game) UpdateGame() {
// Lokale Physik sofort anwenden (Prediction)
g.ApplyInput(input)
// Sanfte Korrektur anwenden (20% pro Frame)
const smoothingFactor = 0.2
if g.correctionX != 0 || g.correctionY != 0 {
g.predictedX += g.correctionX * smoothingFactor
g.predictedY += g.correctionY * smoothingFactor
g.correctionX *= (1.0 - smoothingFactor)
g.correctionY *= (1.0 - smoothingFactor)
// Korrektur beenden wenn sehr klein
if g.correctionX*g.correctionX+g.correctionY*g.correctionY < 0.01 {
g.correctionX = 0
g.correctionY = 0
}
}
// Landing Detection für Partikel
if !g.lastGroundState && g.predictedGround {
// Gerade gelandet! Partikel direkt unter dem Spieler (an den Füßen)
// Füße sind bei: Y + DrawOffY + Hitbox.OffsetY + Hitbox.H
// = Y - 231 + 42 + 184 = Y - 5
feetY := g.predictedY - 231 + 42 + 184
centerX := g.predictedX - 56 + 68 + 73/2
g.SpawnLandingParticles(centerX, feetY)
}
g.lastGroundState = g.predictedGround
g.predictionMutex.Unlock()
// Input an Server senden
@@ -72,10 +110,8 @@ func (g *Game) UpdateGame() {
// --- 5. KAMERA LOGIK ---
g.stateMutex.Lock()
defer g.stateMutex.Unlock()
// Wir folgen strikt dem Server-Scroll.
targetCam := g.gameState.ScrollX
g.stateMutex.Unlock()
// Negative Kamera verhindern
if targetCam < 0 {
@@ -84,6 +120,12 @@ func (g *Game) UpdateGame() {
// Kamera hart setzen
g.camX = targetCam
// --- 6. PARTIKEL UPDATEN ---
g.UpdateParticles(1.0 / 60.0) // Delta time: ~16ms
// --- 7. PARTIKEL SPAWNEN (State Changes Detection) ---
g.DetectAndSpawnParticles()
}
// Verarbeitet Touch-Eingaben für Joystick und Buttons
@@ -178,6 +220,13 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
}
g.stateMutex.Unlock()
// In WASM: HTML Game Over Screen anzeigen
if !g.scoreSubmitted {
g.scoreSubmitted = true
g.submitScore()
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
}
g.DrawGameOverLeaderboard(screen, myScore)
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
}
@@ -197,12 +246,41 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
}
g.stateMutex.Unlock()
// 1. Hintergrund & Boden
screen.Fill(ColSky)
// 1. Hintergrund (wechselt alle 5000 Punkte)
backgroundID := "background"
if myScore >= 10000 {
backgroundID = "background2"
} else if myScore >= 5000 {
backgroundID = "background1"
}
floorH := float32(ScreenHeight - RefFloorY)
vector.DrawFilledRect(screen, 0, float32(RefFloorY), float32(ScreenWidth), floorH, ColGrass, false)
vector.DrawFilledRect(screen, 0, float32(RefFloorY)+20, float32(ScreenWidth), floorH-20, ColDirt, false)
// Hintergrundbild zeichnen (skaliert auf Bildschirmgröße)
if bgImg, exists := g.assetsImages[backgroundID]; exists && bgImg != nil {
op := &ebiten.DrawImageOptions{}
// Skalierung berechnen, um Bildschirm zu füllen
bgW, bgH := bgImg.Size()
scaleX := float64(ScreenWidth) / float64(bgW)
scaleY := float64(ScreenHeight) / float64(bgH)
scale := math.Max(scaleX, scaleY) // Größere Skalierung verwenden, um zu füllen
op.GeoM.Scale(scale, scale)
// Zentrieren
scaledW := float64(bgW) * scale
scaledH := float64(bgH) * scale
offsetX := (float64(ScreenWidth) - scaledW) / 2
offsetY := (float64(ScreenHeight) - scaledH) / 2
op.GeoM.Translate(offsetX, offsetY)
screen.DrawImage(bgImg, op)
} else {
// Fallback: Einfarbiger Himmel
screen.Fill(ColSky)
}
// Boden zeichnen (prozedural mit Dirt und Steinen, bewegt sich mit Kamera)
g.RenderGround(screen, g.camX)
// State Locken für Datenzugriff
g.stateMutex.Lock()
@@ -218,12 +296,38 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
// Start-Chunk hat absichtlich keine Objekte
for _, obj := range chunkDef.Objects {
for objIdx, obj := range chunkDef.Objects {
// Skip Moving Platforms - die werden separat gerendert
if obj.MovingPlatform != nil {
continue
}
// Prüfe ob Coin/Powerup bereits eingesammelt wurde
assetDef, hasAsset := g.world.Manifest.Assets[obj.AssetID]
if hasAsset {
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
if assetDef.Type == "coin" && g.gameState.CollectedCoins[key] {
// Coin wurde eingesammelt, nicht zeichnen
continue
}
if assetDef.Type == "powerup" && g.gameState.CollectedPowerups[key] {
// Powerup wurde eingesammelt, nicht zeichnen
continue
}
}
// Asset zeichnen
g.DrawAsset(screen, obj.AssetID, activeChunk.X+obj.X, obj.Y)
}
}
// 2.5 Bewegende Plattformen (von Server synchronisiert)
for _, mp := range g.gameState.MovingPlatforms {
g.DrawAsset(screen, mp.AssetID, mp.X, mp.Y)
}
// 3. Spieler
// MyID ohne Lock holen (wir haben bereits den stateMutex)
myID := ""
@@ -237,12 +341,33 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
for id, p := range g.gameState.Players {
// Für lokalen Spieler: Verwende vorhergesagte Position
posX, posY := p.X, p.Y
vy := p.VY
onGround := p.OnGround
if id == myID && g.connected {
posX = g.predictedX
posY = g.predictedY
vy = g.predictedVY
onGround = g.predictedGround
}
g.DrawAsset(screen, "player", posX, posY)
// Wähle Sprite basierend auf Sprung-Status
sprite := "player" // Default: am Boden
// Nur Jump-Animation wenn wirklich in der Luft
// (nicht auf Boden, nicht auf Platform mit VY ~= 0)
isInAir := !onGround && (vy < -1.0 || vy > 1.0)
if isInAir {
if vy < -2.0 {
// Springt nach oben
sprite = "jump0"
} else {
// Fällt oder höchster Punkt
sprite = "jump1"
}
}
g.DrawAsset(screen, sprite, posX, posY)
// Name Tag
name := p.Name
@@ -284,28 +409,32 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
vector.StrokeLine(screen, 0, 0, 0, float32(ScreenHeight), 10, color.RGBA{255, 0, 0, 128}, false)
text.Draw(screen, "! DEATH ZONE !", basicfont.Face7x13, 10, ScreenHeight/2, color.RGBA{255, 0, 0, 255})
// 6. TOUCH CONTROLS OVERLAY
// 6. PARTIKEL RENDERN (vor UI)
g.RenderParticles(screen)
// A) Joystick Base
baseCol := color.RGBA{255, 255, 255, 50}
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, true)
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{255, 255, 255, 100}, true)
// 7. TOUCH CONTROLS OVERLAY (nur wenn Tastatur nicht benutzt wurde)
if !g.keyboardUsed {
// A) Joystick Base (dunkelgrau und durchsichtig)
baseCol := color.RGBA{80, 80, 80, 50} // Dunkelgrau und durchsichtig
vector.DrawFilledCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, baseCol, false)
vector.StrokeCircle(screen, float32(g.joyBaseX), float32(g.joyBaseY), 60, 2, color.RGBA{100, 100, 100, 100}, false)
// B) Joystick Knob
knobCol := color.RGBA{255, 255, 255, 150}
if g.joyActive {
knobCol = color.RGBA{100, 255, 100, 200}
// B) Joystick Knob (dunkelgrau, außer wenn aktiv)
knobCol := color.RGBA{100, 100, 100, 80} // Dunkelgrau und durchsichtig
if g.joyActive {
knobCol = color.RGBA{100, 255, 100, 120} // Grün wenn aktiv, aber auch durchsichtig
}
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, false)
// C) Jump Button (Rechts, ausgeblendet bei Tastatur-Nutzung)
jumpX := float32(ScreenWidth - 150)
jumpY := float32(ScreenHeight - 150)
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, false)
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, false)
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.RGBA{255, 255, 255, 150})
}
vector.DrawFilledCircle(screen, float32(g.joyStickX), float32(g.joyStickY), 30, knobCol, true)
// C) Jump Button (Rechts)
jumpX := float32(ScreenWidth - 150)
jumpY := float32(ScreenHeight - 150)
vector.DrawFilledCircle(screen, jumpX, jumpY, 50, color.RGBA{255, 0, 0, 50}, true)
vector.StrokeCircle(screen, jumpX, jumpY, 50, 2, color.RGBA{255, 0, 0, 100}, true)
text.Draw(screen, "JUMP", basicfont.Face7x13, int(jumpX)-15, int(jumpY)+5, color.White)
// 7. DEBUG INFO (Oben Links)
// 8. DEBUG INFO (Oben Links)
myPosStr := "N/A"
for _, p := range g.gameState.Players {
myPosStr = fmt.Sprintf("X:%.0f Y:%.0f", p.X, p.Y)

View File

@@ -0,0 +1,9 @@
//go:build !wasm
// +build !wasm
package main
// sendGameOverToJS ist ein Stub für Native (kein HTML)
func (g *Game) sendGameOverToJS(score int) {
// Native hat kein HTML-Overlay, nichts zu tun
}

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

@@ -0,0 +1,160 @@
package main
import (
"image/color"
"math"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// GroundTile repräsentiert ein Boden-Segment
type GroundTile struct {
X float64
DirtVariants []DirtPatch
Stones []Stone
}
// DirtPatch ist ein Dirt-Fleck im Boden
type DirtPatch struct {
OffsetX float64
OffsetY float64
Width float64
Height float64
Color color.RGBA
}
// Stone ist ein Stein auf dem Boden
type Stone struct {
X float64
Y float64
Size float64
Color color.RGBA
Shape int // 0=rund, 1=eckig
}
// GroundCache speichert generierte Tiles
var groundCache = make(map[int]GroundTile)
// ClearGroundCache leert den Cache (z.B. bei Änderungen)
func ClearGroundCache() {
groundCache = make(map[int]GroundTile)
}
// GenerateGroundTile generiert ein prozedurales Boden-Segment (gecacht)
func GenerateGroundTile(tileIdx int) GroundTile {
// Prüfe Cache
if cached, exists := groundCache[tileIdx]; exists {
return cached
}
// Deterministischer Seed basierend auf Tile-Index
rng := rand.New(rand.NewSource(int64(tileIdx * 12345)))
tile := GroundTile{
X: float64(tileIdx) * 128.0,
DirtVariants: make([]DirtPatch, 0),
Stones: make([]Stone, 0),
}
// Zufällige Dirt-Patches generieren (15-25 pro Tile, über die ganze Höhe)
numDirt := 15 + rng.Intn(10)
dirtHeight := float64(ScreenHeight - RefFloorY - 20) // Gesamte Dirt-Höhe
for i := 0; i < numDirt; i++ {
darkness := uint8(70 + rng.Intn(40)) // Verschiedene Brauntöne
tile.DirtVariants = append(tile.DirtVariants, DirtPatch{
OffsetX: rng.Float64() * 128,
OffsetY: rng.Float64()*dirtHeight + 20, // Über die ganze Dirt-Schicht verteilt
Width: 10 + rng.Float64()*30,
Height: 10 + rng.Float64()*25,
Color: color.RGBA{darkness, darkness - 10, darkness - 20, 255},
})
}
// Keine Steine mehr auf dem Gras
// In Cache speichern
groundCache[tileIdx] = tile
return tile
}
// RenderGround rendert den Boden mit Bewegung
func (g *Game) RenderGround(screen *ebiten.Image, cameraX float64) {
floorY := float32(RefFloorY)
floorH := float32(ScreenHeight - RefFloorY)
// 1. Basis Gras-Schicht
vector.DrawFilledRect(screen, 0, floorY, float32(ScreenWidth), floorH, ColGrass, false)
// 2. Dirt-Schicht (Basis)
vector.DrawFilledRect(screen, 0, floorY+20, float32(ScreenWidth), floorH-20, ColDirt, false)
// 3. Prozedurale Dirt-Patches und Steine (bewegen sich mit Kamera)
// Berechne welche Tiles sichtbar sind
tileWidth := 128.0
startTile := int(math.Floor(cameraX / tileWidth))
endTile := int(math.Ceil((cameraX + float64(ScreenWidth)) / tileWidth))
// Tiles rendern
for tileIdx := startTile; tileIdx <= endTile; tileIdx++ {
tile := GenerateGroundTile(tileIdx)
// Dirt-Patches rendern
for _, dirt := range tile.DirtVariants {
worldX := tile.X + dirt.OffsetX
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(dirt.OffsetY)
// Nur rendern wenn im sichtbaren Bereich
if screenX+float32(dirt.Width) > 0 && screenX < float32(ScreenWidth) {
vector.DrawFilledRect(screen, screenX, screenY, float32(dirt.Width), float32(dirt.Height), dirt.Color, false)
}
}
// Steine rendern (auf dem Gras)
for _, stone := range tile.Stones {
worldX := tile.X + stone.X
screenX := float32(worldX - cameraX)
screenY := float32(RefFloorY) + float32(stone.Y)
// Nur rendern wenn im sichtbaren Bereich
if screenX > -20 && screenX < float32(ScreenWidth)+20 {
if stone.Shape == 0 {
// Runder Stein
vector.DrawFilledCircle(screen, screenX, screenY, float32(stone.Size/2), stone.Color, false)
// Highlight für 3D-Effekt
highlightCol := color.RGBA{
uint8(math.Min(float64(stone.Color.R)+40, 255)),
uint8(math.Min(float64(stone.Color.G)+40, 255)),
uint8(math.Min(float64(stone.Color.B)+40, 255)),
200,
}
vector.DrawFilledCircle(screen, screenX-float32(stone.Size*0.15), screenY-float32(stone.Size*0.15), float32(stone.Size/4), highlightCol, false)
} else {
// Eckiger Stein
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
// Schatten für 3D-Effekt
shadowCol := color.RGBA{
uint8(float64(stone.Color.R) * 0.6),
uint8(float64(stone.Color.G) * 0.6),
uint8(float64(stone.Color.B) * 0.6),
150,
}
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2)+2, screenY-float32(stone.Size/2)+2, float32(stone.Size), float32(stone.Size), shadowCol, false)
// Original drüber
vector.DrawFilledRect(screen, screenX-float32(stone.Size/2), screenY-float32(stone.Size/2), float32(stone.Size), float32(stone.Size), stone.Color, false)
}
}
}
}
// Cache aufräumen (nur Tiles außerhalb des Sichtbereichs entfernen)
if len(groundCache) > 100 {
for idx := range groundCache {
if idx < startTile-10 || idx > endTile+10 {
delete(groundCache, idx)
}
}
}
}

View File

@@ -1,22 +1,18 @@
package main
import (
"encoding/json"
"fmt"
"image/color"
_ "image/png"
"io/ioutil"
_ "image/jpeg" // JPEG-Decoder
_ "image/png" // PNG-Decoder
"log"
mrand "math/rand"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector"
@@ -28,8 +24,8 @@ import (
// --- KONFIGURATION ---
const (
ScreenWidth = 1280
ScreenHeight = 720
ScreenWidth = 1280
ScreenHeight = 720
StateMenu = 0
StateLobby = 1
StateGame = 2
@@ -59,6 +55,7 @@ type InputState struct {
type Game struct {
appState int
conn *nats.EncodedConn
wsConn *wsConn // WebSocket für WASM
gameState game.GameState
stateMutex sync.Mutex
connected bool
@@ -95,6 +92,21 @@ type Game struct {
lastServerSeq uint32 // Letzte vom Server bestätigte Sequenz
predictionMutex sync.Mutex // Mutex für pendingInputs
// Smooth Correction
correctionX float64 // Verbleibende Korrektur in X
correctionY float64 // Verbleibende Korrektur in Y
// Particle System
particles []Particle
particlesMutex sync.Mutex
lastGroundState bool // Für Landing-Detection
lastCollectedCoins map[string]bool // Für Coin-Partikel
lastCollectedPowerups map[string]bool // Für Powerup-Partikel
lastPlayerStates map[string]game.PlayerState // Für Death-Partikel
// Audio System
audio *AudioSystem
// Kamera
camX float64
@@ -104,6 +116,7 @@ type Game struct {
joyActive bool
joyTouchID ebiten.TouchID
btnJumpActive bool
keyboardUsed bool // Wurde Tastatur benutzt?
}
func NewGame() *Game {
@@ -114,67 +127,35 @@ func NewGame() *Game {
gameState: game.GameState{Players: make(map[string]game.PlayerState)},
playerName: "Student",
activeField: "name",
activeField: "",
gameMode: "",
pendingInputs: make(map[uint32]InputState),
leaderboard: make([]game.LeaderboardEntry, 0),
// Particle tracking
lastCollectedCoins: make(map[string]bool),
lastCollectedPowerups: make(map[string]bool),
lastPlayerStates: make(map[string]game.PlayerState),
// Audio System
audio: NewAudioSystem(),
joyBaseX: 150, joyBaseY: ScreenHeight - 150,
joyStickX: 150, joyStickY: ScreenHeight - 150,
}
g.loadAssets()
g.loadOrCreatePlayerCode()
// Gespeicherten Namen laden
savedName := g.loadPlayerName()
if savedName != "" {
g.playerName = savedName
}
return g
}
func (g *Game) loadAssets() {
// Pfad anpassen: Wir suchen im relativen Pfad
baseDir := "./cmd/client/assets"
manifestPath := filepath.Join(baseDir, "assets.json")
data, err := ioutil.ReadFile(manifestPath)
if err == nil {
var m game.AssetManifest
json.Unmarshal(data, &m)
g.world.Manifest = m
fmt.Println("✅ Assets Manifest geladen:", len(m.Assets), "Einträge")
} else {
log.Println("⚠️ assets.json NICHT gefunden! Pfad:", manifestPath)
// Fallback: Leeres Manifest, damit das Spiel nicht abstürzt
g.world.Manifest = game.AssetManifest{Assets: make(map[string]game.AssetDefinition)}
}
// Chunks laden
chunkDir := filepath.Join(baseDir, "chunks")
err = g.world.LoadChunkLibrary(chunkDir)
if err != nil {
log.Println("⚠️ Chunks konnten nicht geladen werden:", err)
} else {
fmt.Println("✅ Chunks geladen:", len(g.world.ChunkLibrary), "Einträge")
// DEBUG: Details der geladenen Chunks
for id, chunk := range g.world.ChunkLibrary {
fmt.Printf(" 📦 Chunk '%s': Width=%d, Objects=%d\n", id, chunk.Width, len(chunk.Objects))
}
}
// Bilder vorladen
loadedImages := 0
failedImages := 0
for id, def := range g.world.Manifest.Assets {
if def.Filename != "" {
path := filepath.Join(baseDir, def.Filename)
img, _, err := ebitenutil.NewImageFromFile(path)
if err == nil {
g.assetsImages[id] = img
loadedImages++
} else {
log.Printf("⚠️ Bild nicht geladen: %s (%s) - Fehler: %v", id, def.Filename, err)
failedImages++
}
}
}
fmt.Printf("🖼️ Bilder: %d geladen, %d fehlgeschlagen\n", loadedImages, failedImages)
}
// loadAssets() ist jetzt in assets_wasm.go und assets_native.go definiert
// --- UPDATE ---
func (g *Game) Update() error {
@@ -220,6 +201,17 @@ func (g *Game) Update() error {
}
}
// Musik-Start-Check (unabhängig vom State)
if g.gameState.Status == "RUNNING" && g.lastStatus != "RUNNING" {
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, g.gameState.Status)
g.audio.PlayMusic()
}
// Musik stoppen wenn Game Over
if g.gameState.Status == "GAMEOVER" && g.lastStatus == "RUNNING" {
g.audio.StopMusic()
}
g.lastStatus = g.gameState.Status
switch g.appState {
case StateMenu:
g.updateMenu()
@@ -236,6 +228,30 @@ func (g *Game) Update() error {
func (g *Game) updateMenu() {
g.handleMenuInput()
// Volume Sliders (unten links)
volumeX := 20
volumeY := ScreenHeight - 100
sliderWidth := 200
sliderHeight := 10
// Music Volume Slider
musicSliderY := volumeY + 10
if isSliderHit(volumeX, musicSliderY, sliderWidth, sliderHeight) {
newVolume := getSliderValue(volumeX, sliderWidth)
g.audio.SetMusicVolume(newVolume)
return
}
// SFX Volume Slider
sfxSliderY := volumeY + 50
if isSliderHit(volumeX, sfxSliderY, sliderWidth, sliderHeight) {
newVolume := getSliderValue(volumeX, sliderWidth)
g.audio.SetSFXVolume(newVolume)
// Test-Sound abspielen
g.audio.PlayCoin()
return
}
// Leaderboard Button
lbBtnW, lbBtnH := 200, 50
lbBtnX := ScreenWidth - lbBtnW - 20
@@ -324,7 +340,7 @@ func (g *Game) updateLobby() {
if isHit(btnX, btnY, btnW, btnH) {
// START GAME
g.SendCommand("START")
g.sendStartRequest()
}
}
@@ -347,18 +363,27 @@ func (g *Game) updateLobby() {
// --- DRAW ---
func (g *Game) Draw(screen *ebiten.Image) {
// In WASM: Nur das Spiel zeichnen, kein Menü/Lobby (HTML übernimmt das)
// In Native: Alles zeichnen
g.draw(screen)
}
// draw ist die plattform-übergreifende Zeichenfunktion
func (g *Game) draw(screen *ebiten.Image) {
switch g.appState {
case StateMenu:
g.DrawMenu(screen)
g.drawMenu(screen)
case StateLobby:
g.DrawLobby(screen)
g.drawLobby(screen)
case StateGame:
g.DrawGame(screen)
case StateLeaderboard:
g.DrawLeaderboard(screen)
g.drawLeaderboard(screen)
}
}
// drawMenu, drawLobby, drawLeaderboard sind in draw_wasm.go und draw_native.go definiert
func (g *Game) DrawMenu(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
@@ -442,7 +467,19 @@ func (g *Game) DrawMenu(screen *ebiten.Image) {
vector.StrokeRect(screen, float32(lbBtnX), float32(lbBtnY), float32(lbBtnW), 50, 2, color.RGBA{255, 215, 0, 255}, false)
text.Draw(screen, "🏆 LEADERBOARD", basicfont.Face7x13, lbBtnX+35, lbBtnY+30, color.RGBA{255, 215, 0, 255})
text.Draw(screen, "WASD / Arrows - SPACE to Jump", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-30, color.Gray{150})
// Volume Controls (unten links)
volumeX := 20
volumeY := ScreenHeight - 100
// Music Volume
text.Draw(screen, "Music Volume:", basicfont.Face7x13, volumeX, volumeY, ColText)
g.drawVolumeSlider(screen, volumeX, volumeY+10, 200, g.audio.GetMusicVolume())
// SFX Volume
text.Draw(screen, "SFX Volume:", basicfont.Face7x13, volumeX, volumeY+40, ColText)
g.drawVolumeSlider(screen, volumeX, volumeY+50, 200, g.audio.GetSFXVolume())
text.Draw(screen, "WASD / Arrows - SPACE to Jump - M to Mute", basicfont.Face7x13, ScreenWidth/2-130, ScreenHeight-15, color.Gray{150})
}
func (g *Game) DrawLobby(screen *ebiten.Image) {
@@ -580,6 +617,10 @@ func (g *Game) handleMenuInput() {
}
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
// Namen speichern wenn geändert
if g.activeField == "name" && g.playerName != "" {
g.savePlayerName(g.playerName)
}
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
if len(*target) > 0 {
@@ -614,7 +655,7 @@ func (g *Game) handleGameOverInput() {
if isHit(submitBtnX, submitBtnY, submitBtnW, 40) {
if g.teamName != "" {
g.submitTeamScore()
g.submitScore() // submitScore behandelt jetzt beide Modi
}
return
}
@@ -623,7 +664,7 @@ func (g *Game) handleGameOverInput() {
if g.activeField == "teamname" {
if inpututil.IsKeyJustPressed(ebiten.KeyEnter) {
if g.teamName != "" {
g.submitTeamScore()
g.submitScore() // submitScore behandelt jetzt beide Modi
}
g.activeField = ""
} else if inpututil.IsKeyJustPressed(ebiten.KeyBackspace) {
@@ -650,68 +691,6 @@ func generateRoomCode() string {
}
func (g *Game) connectAndStart() {
// URL: Wasm -> WS, Desktop -> TCP
serverURL := "nats://localhost:4222"
if runtime.GOARCH == "wasm" || runtime.GOOS == "js" {
serverURL = "ws://localhost:9222"
}
nc, err := nats.Connect(serverURL)
if err != nil {
log.Println("❌ NATS Connect Fehler:", err)
return
}
ec, _ := nats.NewEncodedConn(nc, nats.JSON_ENCODER)
g.conn = ec
// Subscribe nur auf Updates für DIESEN Raum
roomChannel := fmt.Sprintf("game.update.%s", g.roomID)
log.Printf("👂 Lausche auf Channel: %s", roomChannel)
sub, err := g.conn.Subscribe(roomChannel, func(state *game.GameState) {
// Server Reconciliation für lokalen Spieler (VOR dem Lock)
for _, p := range state.Players {
if p.Name == g.playerName {
// Reconcile mit Server-State (verwendet keinen stateMutex)
g.ReconcileWithServer(p)
break
}
}
g.stateMutex.Lock()
oldPlayerCount := len(g.gameState.Players)
oldStatus := g.gameState.Status
g.gameState = *state
g.stateMutex.Unlock()
// Nur bei Änderungen loggen
if len(state.Players) != oldPlayerCount || state.Status != oldStatus {
log.Printf("📦 State Update: RoomID=%s, Players=%d, HostID=%s, Status=%s", state.RoomID, len(state.Players), state.HostID, state.Status)
}
})
if err != nil {
log.Println("❌ Fehler beim Subscribe:", err)
return
}
log.Printf("👂 Subscription aktiv (Valid: %v)", sub.IsValid())
// Kurze Pause, damit Subscription aktiv ist
time.Sleep(100 * time.Millisecond)
// JOIN MIT ROOM ID SENDEN
joinReq := game.JoinRequest{
Name: g.playerName,
RoomID: g.roomID,
}
log.Printf("📤 Sende JOIN Request: Name=%s, RoomID=%s", g.playerName, g.roomID)
err = g.conn.Publish("game.join", joinReq)
if err != nil {
log.Println("❌ Fehler beim Publish:", err)
return
}
g.connected = true
// Initiale predicted Position
g.predictedX = 100
g.predictedY = 200
@@ -719,7 +698,8 @@ func (g *Game) connectAndStart() {
g.predictedVY = 0
g.predictedGround = false
log.Printf("✅ JOIN gesendet. Warte auf Server-Antwort...")
// Verbindung über plattformspezifische Implementierung
g.connectToServer()
}
func (g *Game) SendCommand(cmdType string) {
@@ -727,7 +707,7 @@ func (g *Game) SendCommand(cmdType string) {
return
}
myID := g.getMyPlayerID()
g.conn.Publish("game.input", game.ClientInput{PlayerID: myID, Type: cmdType})
g.publishInput(game.ClientInput{PlayerID: myID, Type: cmdType})
}
func (g *Game) SendInputWithSequence(input InputState) {
@@ -739,28 +719,30 @@ func (g *Game) SendInputWithSequence(input InputState) {
// Inputs als einzelne Commands senden
if input.Left {
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "LEFT_DOWN",
Sequence: input.Sequence,
})
}
if input.Right {
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "RIGHT_DOWN",
Sequence: input.Sequence,
})
}
if input.Jump {
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "JUMP",
Sequence: input.Sequence,
})
// Jump Sound abspielen
g.audio.PlayJump()
}
if input.Down {
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "DOWN",
Sequence: input.Sequence,
@@ -769,12 +751,12 @@ func (g *Game) SendInputWithSequence(input InputState) {
// Wenn weder Links noch Rechts, sende STOP
if !input.Left && !input.Right {
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "LEFT_UP",
Sequence: input.Sequence,
})
g.conn.Publish("game.input", game.ClientInput{
g.publishInput(game.ClientInput{
PlayerID: myID,
Type: "RIGHT_UP",
Sequence: input.Sequence,
@@ -794,113 +776,8 @@ func (g *Game) getMyPlayerID() string {
return g.playerName
}
// loadOrCreatePlayerCode wird in storage_*.go implementiert (platform-specific)
// submitScore sendet den individuellen Score an den Server (für Solo-Mode)
func (g *Game) submitScore() {
if g.scoreSubmitted || !g.connected {
return
}
// Finde eigenen Score
myScore := 0
for _, p := range g.gameState.Players {
if p.Name == g.playerName {
myScore = p.Score
break
}
}
submission := game.ScoreSubmission{
PlayerName: g.playerName,
PlayerCode: g.playerCode,
Score: myScore,
}
g.conn.Publish("score.submit", submission)
g.scoreSubmitted = true
log.Printf("📊 Score eingereicht: %d Punkte", myScore)
// Leaderboard abrufen
g.requestLeaderboard()
}
// submitTeamScore sendet den Team-Score an den Server (für Coop-Mode)
func (g *Game) submitTeamScore() {
if g.scoreSubmitted || !g.connected || g.teamName == "" {
return
}
// Berechne Team-Score (Summe aller Spieler-Scores)
teamScore := 0
for _, p := range g.gameState.Players {
teamScore += p.Score
}
submission := game.ScoreSubmission{
PlayerName: g.teamName, // Team-Name statt Spieler-Name
PlayerCode: g.playerCode,
Score: teamScore,
}
g.conn.Publish("score.submit", submission)
g.scoreSubmitted = true
g.activeField = ""
log.Printf("📊 Team-Score eingereicht: %s - %d Punkte", g.teamName, teamScore)
// Leaderboard abrufen
g.requestLeaderboard()
}
// requestLeaderboard fordert das Leaderboard vom Server an (asynchron)
func (g *Game) requestLeaderboard() {
if !g.connected {
return
}
go func() {
inbox := g.conn.Conn.NewRespInbox()
sub, err := g.conn.Subscribe(inbox, func(entries *[]game.LeaderboardEntry) {
g.leaderboardMutex.Lock()
g.leaderboard = *entries
g.leaderboardMutex.Unlock()
log.Printf("📊 Leaderboard empfangen: %d Einträge", len(*entries))
})
if err != nil {
log.Printf("⚠️ Fehler beim Leaderboard-Request: %v", err)
return
}
// Request senden
g.conn.PublishRequest("leaderboard.get", inbox, &struct{}{})
// Warte kurz auf Antwort, dann unsubscribe
time.Sleep(100 * time.Millisecond)
sub.Unsubscribe()
}()
}
func (g *Game) connectForLeaderboard() {
serverURL := "nats://localhost:4222"
nc, err := nats.Connect(serverURL)
if err != nil {
log.Printf("❌ NATS Verbindung fehlgeschlagen: %v", err)
return
}
g.conn, err = nats.NewEncodedConn(nc, nats.JSON_ENCODER)
if err != nil {
log.Printf("❌ EncodedConn Fehler: %v", err)
return
}
g.connected = true
log.Println("✅ Verbunden für Leaderboard")
// Leaderboard abrufen
g.requestLeaderboard()
}
// submitScore, requestLeaderboard, connectForLeaderboard
// sind in connection_native.go und connection_wasm.go definiert
func (g *Game) updateLeaderboard() {
// Back Button (oben links) - Touch Support
@@ -977,12 +854,46 @@ func (g *Game) DrawLeaderboard(screen *ebiten.Image) {
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-40, color.Gray{150})
}
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60) // Tick Per Second auf 60 setzen
ebiten.SetVsyncEnabled(true) // VSync aktivieren
if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}
// main() ist jetzt in main_wasm.go und main_native.go definiert
// drawVolumeSlider zeichnet einen Volume-Slider
func (g *Game) drawVolumeSlider(screen *ebiten.Image, x, y, width int, volume float64) {
// Hintergrund
vector.DrawFilledRect(screen, float32(x), float32(y), float32(width), 10, color.RGBA{40, 40, 50, 255}, false)
vector.StrokeRect(screen, float32(x), float32(y), float32(width), 10, 1, color.White, false)
// Füllstand
fillWidth := int(float64(width) * volume)
vector.DrawFilledRect(screen, float32(x), float32(y), float32(fillWidth), 10, color.RGBA{0, 200, 100, 255}, false)
// Prozent-Anzeige
pct := fmt.Sprintf("%.0f%%", volume*100)
text.Draw(screen, pct, basicfont.Face7x13, x+width+10, y+10, ColText)
}
// isSliderHit prüft, ob auf einen Slider geklickt wurde
func isSliderHit(x, y, width, height int) bool {
// Erweitere den Klickbereich vertikal für bessere Touch-Support
return isHit(x, y-10, width, height+20)
}
// getSliderValue berechnet den Slider-Wert basierend auf Mausposition
func getSliderValue(sliderX, sliderWidth int) float64 {
mx, _ := ebiten.CursorPosition()
// Bei Touch: Ersten Touch nutzen
touches := ebiten.TouchIDs()
if len(touches) > 0 {
mx, _ = ebiten.TouchPosition(touches[0])
}
// Berechne relative Position im Slider
relX := float64(mx - sliderX)
if relX < 0 {
relX = 0
}
if relX > float64(sliderWidth) {
relX = float64(sliderWidth)
}
return relX / float64(sliderWidth)
}

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

@@ -0,0 +1,20 @@
//go:build !wasm
// +build !wasm
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60)
ebiten.SetVsyncEnabled(true)
if err := ebiten.RunGame(NewGame()); err != nil {
log.Fatal(err)
}
}

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

@@ -0,0 +1,33 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
log.Println("🚀 WASM Version startet...")
// Spiel initialisieren
game := NewGame()
// JavaScript Bridge registrieren (für HTML-Menü Kommunikation)
game.setupJavaScriptBridge()
// Spiel ohne eigenes Menü starten - HTML übernimmt das Menü
// Das Spiel wartet im Hintergrund bis startGame() von JavaScript aufgerufen wird
log.Println("⏳ Warte auf Start-Signal vom HTML-Menü...")
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
ebiten.SetTPS(60)
ebiten.SetVsyncEnabled(true)
if err := ebiten.RunGame(game); err != nil {
log.Fatal(err)
}
}

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

@@ -0,0 +1,336 @@
package main
import (
"fmt"
"image/color"
"math"
"math/rand"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Particle definiert ein einzelnes Partikel
type Particle struct {
X, Y float64
VX, VY float64
Life float64 // 0.0 bis 1.0 (1.0 = neu, 0.0 = tot)
MaxLife float64 // Ursprüngliche Lebensdauer in Sekunden
Size float64
Color color.RGBA
Type string // "coin", "powerup", "landing", "death"
Gravity bool // Soll Gravitation angewendet werden?
FadeOut bool // Soll ausblenden?
}
// SpawnCoinParticles erstellt Partikel für Coin-Einsammlung
func (g *Game) SpawnCoinParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 10-15 goldene Partikel - spawnen in alle Richtungen
count := 10 + rand.Intn(6)
for i := 0; i < count; i++ {
angle := rand.Float64() * 2 * math.Pi
speed := 2.0 + rand.Float64()*3.0
g.particles = append(g.particles, Particle{
X: x,
Y: y,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.5 + rand.Float64()*0.3,
Size: 3.0 + rand.Float64()*2.0,
Color: color.RGBA{255, 215, 0, 255}, // Gold
Type: "coin",
Gravity: true,
FadeOut: true,
})
}
}
// SpawnPowerupAura erstellt Partikel-Aura um Spieler mit aktivem Powerup
func (g *Game) SpawnPowerupAura(x, y float64, powerupType string) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// Farbe je nach Powerup-Typ
var particleColor color.RGBA
switch powerupType {
case "doublejump":
particleColor = color.RGBA{100, 200, 255, 200} // Hellblau
case "godmode":
particleColor = color.RGBA{255, 215, 0, 200} // Gold
default:
particleColor = color.RGBA{200, 100, 255, 200} // Lila
}
// Nur gelegentlich spawnen (nicht jedes Frame) - 1 Partikel alle 3-4 Frames
if rand.Intn(3) != 0 {
return
}
// 1 Partikel für sanfte Aura - spawnen in alle Richtungen
angle := rand.Float64() * 2 * math.Pi
distance := 25.0 + rand.Float64()*20.0
g.particles = append(g.particles, Particle{
X: x + math.Cos(angle)*distance,
Y: y + math.Sin(angle)*distance,
VX: math.Cos(angle) * 0.5,
VY: math.Sin(angle) * 0.5,
Life: 1.0,
MaxLife: 1.0 + rand.Float64()*0.5,
Size: 2.5 + rand.Float64()*1.5,
Color: particleColor,
Type: "powerup",
Gravity: false,
FadeOut: true,
})
}
// SpawnLandingParticles erstellt Staub-Partikel beim Landen
func (g *Game) SpawnLandingParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 5-8 Staub-Partikel
count := 5 + rand.Intn(4)
for i := 0; i < count; i++ {
angle := math.Pi + (rand.Float64()-0.5)*0.8 // Nach unten/seitlich
speed := 1.0 + rand.Float64()*2.0
g.particles = append(g.particles, Particle{
X: x + (rand.Float64()-0.5)*30.0,
Y: y + 10,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.3 + rand.Float64()*0.2,
Size: 2.0 + rand.Float64()*2.0,
Color: color.RGBA{150, 150, 150, 180}, // Grau
Type: "landing",
Gravity: false,
FadeOut: true,
})
}
}
// SpawnDeathParticles erstellt Explosions-Partikel beim Tod
func (g *Game) SpawnDeathParticles(x, y float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// 20-30 rote Partikel
count := 20 + rand.Intn(11)
for i := 0; i < count; i++ {
angle := rand.Float64() * 2 * math.Pi
speed := 3.0 + rand.Float64()*5.0
g.particles = append(g.particles, Particle{
X: x,
Y: y + 10,
VX: math.Cos(angle) * speed,
VY: math.Sin(angle) * speed,
Life: 1.0,
MaxLife: 0.8 + rand.Float64()*0.4,
Size: 3.0 + rand.Float64()*3.0,
Color: color.RGBA{255, 50, 50, 255}, // Rot
Type: "death",
Gravity: true,
FadeOut: true,
})
}
}
// UpdateParticles aktualisiert alle Partikel
func (g *Game) UpdateParticles(dt float64) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
// Filtern: Nur lebende Partikel behalten
alive := make([]Particle, 0, len(g.particles))
for i := range g.particles {
p := &g.particles[i]
// Position updaten
p.X += p.VX
p.Y += p.VY
// Gravitation
if p.Gravity {
p.VY += 0.3 // Gravitation
}
// Friction
p.VX *= 0.98
p.VY *= 0.98
// Leben verringern
p.Life -= dt / p.MaxLife
// Nur behalten wenn noch am Leben
if p.Life > 0 {
alive = append(alive, *p)
}
}
g.particles = alive
}
// RenderParticles zeichnet alle Partikel
func (g *Game) RenderParticles(screen *ebiten.Image) {
g.particlesMutex.Lock()
defer g.particlesMutex.Unlock()
for i := range g.particles {
p := &g.particles[i]
// Alpha-Wert basierend auf Leben
alpha := uint8(255)
if p.FadeOut {
alpha = uint8(float64(p.Color.A) * p.Life)
}
col := color.RGBA{p.Color.R, p.Color.G, p.Color.B, alpha}
// Position relativ zur Kamera
screenX := float32(p.X - g.camX)
screenY := float32(p.Y)
// Partikel als Kreis zeichnen
vector.DrawFilledCircle(screen, screenX, screenY, float32(p.Size), col, false)
}
}
// DetectAndSpawnParticles prüft Game-State-Änderungen und spawnt Partikel
func (g *Game) DetectAndSpawnParticles() {
// Kopiere relevante Daten unter kurzen Lock
g.stateMutex.Lock()
// Kopiere Coins
currentCoins := make(map[string]bool, len(g.gameState.CollectedCoins))
for k, v := range g.gameState.CollectedCoins {
currentCoins[k] = v
}
// Kopiere Powerups
currentPowerups := make(map[string]bool, len(g.gameState.CollectedPowerups))
for k, v := range g.gameState.CollectedPowerups {
currentPowerups[k] = v
}
// Kopiere Spieler
currentPlayers := make(map[string]game.PlayerState, len(g.gameState.Players))
for k, v := range g.gameState.Players {
currentPlayers[k] = v
}
// Kopiere WorldChunks für Position-Lookup
worldChunks := make([]game.ActiveChunk, len(g.gameState.WorldChunks))
copy(worldChunks, g.gameState.WorldChunks)
g.stateMutex.Unlock()
// Ab hier ohne Lock arbeiten
// Alte Coins entfernen, die nicht mehr in currentCoins sind (Chunk wurde entfernt)
for key := range g.lastCollectedCoins {
if !currentCoins[key] {
delete(g.lastCollectedCoins, key)
}
}
// 1. Prüfe neue gesammelte Coins
for coinKey := range currentCoins {
if !g.lastCollectedCoins[coinKey] {
// Neuer Coin gesammelt!
if pos := g.findObjectPosition(coinKey, worldChunks, "coin"); pos != nil {
g.SpawnCoinParticles(pos.X, pos.Y)
g.audio.PlayCoin() // Coin Sound abspielen
}
g.lastCollectedCoins[coinKey] = true
}
}
// Alte Powerups entfernen
for key := range g.lastCollectedPowerups {
if !currentPowerups[key] {
delete(g.lastCollectedPowerups, key)
}
}
// 2. Prüfe neue gesammelte Powerups
for powerupKey := range currentPowerups {
if !g.lastCollectedPowerups[powerupKey] {
// Neues Powerup gesammelt!
if pos := g.findObjectPosition(powerupKey, worldChunks, "powerup"); pos != nil {
g.SpawnCoinParticles(pos.X, pos.Y)
g.audio.PlayPowerUp() // PowerUp Sound abspielen
}
g.lastCollectedPowerups[powerupKey] = true
}
}
// 3. Prüfe Spieler-Status und spawn Aura/Death Partikel
for playerID, player := range currentPlayers {
lastState, existed := g.lastPlayerStates[playerID]
// Death Partikel
if existed && lastState.IsAlive && !player.IsAlive {
// Berechne Spieler-Mitte
centerX := player.X - 56 + 68 + 73/2
centerY := player.Y - 231 + 42 + 184/2
g.SpawnDeathParticles(centerX, centerY)
}
// Powerup Aura (kontinuierlich)
if player.IsAlive && !player.IsSpectator {
// Berechne Spieler-Mitte mit Draw-Offsets
// DrawOffX: -56, DrawOffY: -231
// Hitbox: OffsetX: 68, OffsetY: 42, W: 73, H: 184
centerX := player.X - 56 + 68 + 73/2
centerY := player.Y - 231 + 42 + 184/2
if player.HasDoubleJump {
g.SpawnPowerupAura(centerX, centerY, "doublejump")
}
if player.HasGodMode {
g.SpawnPowerupAura(centerX, centerY, "godmode")
}
}
// State aktualisieren
g.lastPlayerStates[playerID] = player
}
}
// findObjectPosition finiert die Welt-Position eines Objects (Coin/Powerup) basierend auf Key
func (g *Game) findObjectPosition(objectKey string, worldChunks []game.ActiveChunk, objectType string) *struct{ X, Y float64 } {
for _, activeChunk := range worldChunks {
chunkDef, exists := g.world.ChunkLibrary[activeChunk.ChunkID]
if !exists {
continue
}
for objIdx, obj := range chunkDef.Objects {
key := fmt.Sprintf("%s_%d", activeChunk.ChunkID, objIdx)
if key == objectKey {
assetDef, ok := g.world.Manifest.Assets[obj.AssetID]
if ok && assetDef.Type == objectType {
// Berechne die Mitte der Hitbox
centerX := activeChunk.X + obj.X + assetDef.DrawOffX + assetDef.Hitbox.OffsetX + assetDef.Hitbox.W/2
centerY := obj.Y + assetDef.DrawOffY + assetDef.Hitbox.OffsetY + assetDef.Hitbox.H/2
return &struct{ X, Y float64 }{
X: centerX,
Y: centerY,
}
}
}
}
}
return nil
}

View File

@@ -63,20 +63,60 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
}
}
// Setze vorhergesagte Position auf Server-Position
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
}

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

View File

@@ -43,3 +43,33 @@ func (g *Game) loadOrCreatePlayerCode() {
}
}
}
// loadPlayerName lädt gespeicherten Spielernamen (WebAssembly Version)
func (g *Game) loadPlayerName() string {
const storageKey = "escape_from_teacher_player_name"
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
localStorage := jsGlobal.Get("localStorage")
if !localStorage.IsUndefined() {
stored := localStorage.Call("getItem", storageKey)
if !stored.IsNull() && stored.String() != "" {
log.Printf("👤 Spielername aus LocalStorage geladen: %s", stored.String())
return stored.String()
}
}
}
return ""
}
// savePlayerName speichert Spielernamen (WebAssembly Version)
func (g *Game) savePlayerName(name string) {
const storageKey = "escape_from_teacher_player_name"
if jsGlobal := js.Global(); !jsGlobal.IsUndefined() {
localStorage := jsGlobal.Get("localStorage")
if !localStorage.IsUndefined() {
localStorage.Call("setItem", storageKey, name)
log.Printf("💾 Spielername in LocalStorage gespeichert: %s", name)
}
}
}

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

@@ -0,0 +1,119 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"fmt"
"log"
"syscall/js"
"time"
)
// setupJavaScriptBridge registriert JavaScript-Funktionen für HTML-Menü Interaktion
func (g *Game) setupJavaScriptBridge() {
// startGame(mode, playerName, roomID, teamName, isHost)
startGameFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) < 2 {
log.Println("❌ startGame: Nicht genug Argumente")
return nil
}
mode := args[0].String() // "solo" oder "coop"
playerName := args[1].String() // Spielername
// Spieler-Daten setzen
g.playerName = playerName
g.gameMode = mode
g.savePlayerName(playerName)
if mode == "solo" {
// Solo Mode - direkt ins Spiel
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true
g.appState = StateGame
log.Printf("🎮 Solo-Spiel gestartet: %s", playerName)
} else if mode == "coop" && len(args) >= 5 {
// Co-op Mode - in die Lobby
roomID := args[2].String()
teamName := args[3].String()
isHost := args[4].Bool()
g.roomID = roomID
g.teamName = teamName
g.isHost = isHost
g.appState = StateLobby
log.Printf("🎮 Co-op-Lobby erstellt: %s (Room: %s, Team: %s, Host: %v)", playerName, roomID, teamName, isHost)
}
// Verbindung starten
go g.connectAndStart()
return nil
})
// requestLeaderboard()
requestLeaderboardFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
log.Println("📊 Leaderboard angefordert")
go g.requestLeaderboard()
return nil
})
// setMusicVolume(volume)
setMusicVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
volume := args[0].Float()
g.audio.SetMusicVolume(volume)
log.Printf("🎵 Musik-Lautstärke: %.0f%%", volume*100)
}
return nil
})
// setSFXVolume(volume)
setSFXVolumeFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
volume := args[0].Float()
g.audio.SetSFXVolume(volume)
log.Printf("🔊 SFX-Lautstärke: %.0f%%", volume*100)
}
return nil
})
// Im globalen Scope registrieren
js.Global().Set("startGame", startGameFunc)
js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
js.Global().Set("setMusicVolume", setMusicVolumeFunc)
js.Global().Set("setSFXVolume", setSFXVolumeFunc)
log.Println("✅ JavaScript Bridge registriert")
}
// Leaderboard an JavaScript senden
func (g *Game) sendLeaderboardToJS() {
g.leaderboardMutex.Lock()
entries := make([]interface{}, len(g.leaderboard))
for i, entry := range g.leaderboard {
entries[i] = map[string]interface{}{
"player_name": entry.PlayerName,
"score": entry.Score,
"player_code": entry.PlayerCode,
}
}
g.leaderboardMutex.Unlock()
// JavaScript-Funktion aufrufen
if updateFunc := js.Global().Get("updateLeaderboard"); !updateFunc.IsUndefined() {
jsEntries := js.ValueOf(entries)
updateFunc.Invoke(jsEntries)
log.Printf("📤 Leaderboard an JavaScript gesendet: %d Einträge", len(entries))
}
}
// Game Over Screen an JavaScript senden
func (g *Game) sendGameOverToJS(score int) {
if gameOverFunc := js.Global().Get("showGameOver"); !gameOverFunc.IsUndefined() {
gameOverFunc.Invoke(score)
log.Printf("💀 Game Over an JavaScript gesendet: Score=%d", score)
}
}

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

@@ -0,0 +1,89 @@
# Escape From Teacher - WASM Web Version
## 🎮 Starten
```bash
# Im web-Verzeichnis einen HTTP-Server starten
cd cmd/client/web
python3 -m http.server 8000
```
Dann im Browser öffnen: **http://localhost:8000**
## ✨ Features
### Modernes HTML/CSS Menü
- **Custom Font**: Nutzt `front.ttf` aus dem assets-Ordner
- **Responsive Design**: Funktioniert auf Desktop, Tablet und Smartphone
- **Glassmorphism**: Moderner durchsichtiger Look mit Blur-Effekt
- **Smooth Animations**: Fade-in, Slide-up, Pulse-Effekte
### Menü-Optionen
1. **Solo spielen**: Einzelspieler-Modus
2. **Co-op spielen**: Multiplayer mit Raum-Code
3. **Leaderboard**: Top 10 Spieler anzeigen
4. **Einstellungen**: Musik & SFX Lautstärke einstellen
### Mobile-Optimiert
- Touch-freundliche Buttons
- Responsive Layout für kleine Bildschirme
- Viewport-Anpassung für Smartphones
## 🎨 Design-Features
- **Gradient Background**: Lila-Blauer Farbverlauf
- **Button Hover-Effekte**: Ripple-Animationen
- **Custom Scrollbars**: Für Leaderboard
- **Loading Screen**: Spinner während WASM lädt
- **Emoji-Icons**: 🏃 👥 🏆 ⚙️
## 📁 Dateien
- `index.html`: HTML-Struktur
- `style.css`: Alle Styles mit Custom Font
- `game.js`: JavaScript-Bridge zwischen HTML und WASM
- `wasm_exec.js`: Go WASM Runtime (von Go kopiert)
- `main.wasm`: Kompiliertes Spiel
## 🔧 Entwicklung
### WASM neu kompilieren
```bash
GOOS=js GOARCH=wasm go build -o cmd/client/web/main.wasm ./cmd/client
```
### Font ändern
Ersetze `cmd/client/assets/front.ttf` und aktualisiere den Pfad in `style.css`:
```css
@font-face {
font-family: 'GameFont';
src: url('../assets/front.ttf') format('truetype');
}
```
## 🎯 Keyboard Shortcuts
- **ESC**: Zurück zum Menü (während des Spiels)
## 📱 Mobile Testing
Für lokales Mobile Testing:
```bash
python3 -m http.server 8000
# Öffne auf Smartphone: http://[DEINE-IP]:8000
```
## 🚀 Production Deployment
Für Production alle Dateien hochladen:
- index.html
- style.css
- game.js
- wasm_exec.js
- main.wasm
- assets/ (Font-Ordner)
Server muss WASM mit korrektem MIME-Type ausliefern:
```
Content-Type: application/wasm
```

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

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

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

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

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

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

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

Binary file not shown.

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

File diff suppressed because one or more lines are too long

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

@@ -0,0 +1,575 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();