Private
Public Access
1
0

Add WebAssembly support for assets and chunks, implement gameover screen rendering, and enhance server gameplay logic with dynamic speeds, team naming, and score components.

This commit is contained in:
Sebastian Unterschütz
2026-01-04 14:30:31 +01:00
parent ce51a2ba4f
commit 95d61bf66e
68 changed files with 913 additions and 424 deletions

View File

@@ -30,7 +30,7 @@ import (
// --- CONFIG --- // --- CONFIG ---
const ( const (
RawDir = "./assets_raw" RawDir = "./assets_raw"
OutFile = "./cmd/client/assets/assets.json" OutFile = "cmd/client/web/assets/assets.json"
WidthList = 280 // Etwas breiter für die Bilder WidthList = 280 // Etwas breiter für die Bilder
WidthInspect = 300 WidthInspect = 300

View File

@@ -17,7 +17,7 @@ import (
func (g *Game) loadAssets() { func (g *Game) loadAssets() {
// Pfad anpassen: Wir suchen im relativen Pfad // Pfad anpassen: Wir suchen im relativen Pfad
baseDir := "./cmd/client/assets" baseDir := "./cmd/client/web/assets"
manifestPath := filepath.Join(baseDir, "assets.json") manifestPath := filepath.Join(baseDir, "assets.json")
data, err := ioutil.ReadFile(manifestPath) data, err := ioutil.ReadFile(manifestPath)

View File

@@ -10,7 +10,7 @@ import (
// loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop) // loadChunks lädt alle Chunks aus dem Verzeichnis (Native Desktop)
func (g *Game) loadChunks() { func (g *Game) loadChunks() {
baseDir := "cmd/client/assets" baseDir := "cmd/client/web/assets"
chunkDir := filepath.Join(baseDir, "chunks") chunkDir := filepath.Join(baseDir, "chunks")
err := g.world.LoadChunkLibrary(chunkDir) err := g.world.LoadChunkLibrary(chunkDir)

View File

@@ -252,17 +252,32 @@ func (g *Game) submitScore() {
name = g.teamName name = g.teamName
} }
// Verwende Team-Name für Coop-Mode, sonst Player-Name
displayName := name
teamName := ""
if g.gameMode == "coop" {
g.stateMutex.Lock()
teamName = g.gameState.TeamName
g.stateMutex.Unlock()
if teamName != "" {
displayName = teamName
}
}
msg := WebSocketMessage{ msg := WebSocketMessage{
Type: "score_submit", Type: "score_submit",
Payload: game.ScoreSubmission{ Payload: game.ScoreSubmission{
PlayerName: displayName,
PlayerCode: g.playerCode, PlayerCode: g.playerCode,
Name: name, Name: displayName, // Für Kompatibilität
Score: score, Score: score,
Mode: g.gameMode, Mode: g.gameMode,
TeamName: teamName, // Team-Name für Coop
}, },
} }
g.sendWebSocketMessage(msg) g.sendWebSocketMessage(msg)
g.scoreSubmitted = true g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score) log.Printf("📊 Score submitted: %s = %d (TeamName: %s)", displayName, score, teamName)
} }

View File

@@ -92,6 +92,19 @@ func (g *Game) connectToServer() {
// An JavaScript senden // An JavaScript senden
g.sendLeaderboardToJS() g.sendLeaderboardToJS()
} }
case "score_response":
// Score Submission Response mit Proof-Code
payloadBytes, _ := json.Marshal(msg.Payload)
var resp game.ScoreSubmissionResponse
if err := json.Unmarshal(payloadBytes, &resp); err == nil {
log.Printf("🎯 Proof-Code empfangen: %s für Score: %d", resp.ProofCode, resp.Score)
// Proof-Code an JavaScript senden
if saveFunc := js.Global().Get("saveHighscoreCode"); !saveFunc.IsUndefined() {
saveFunc.Invoke(resp.Score, resp.ProofCode, g.playerName)
}
}
} }
return nil return nil
@@ -259,19 +272,34 @@ func (g *Game) submitScore() {
name = g.teamName name = g.teamName
} }
// Verwende Team-Name für Coop-Mode, sonst Player-Name
displayName := name
teamName := ""
if g.gameMode == "coop" {
g.stateMutex.Lock()
teamName = g.gameState.TeamName
g.stateMutex.Unlock()
if teamName != "" {
displayName = teamName
}
}
msg := WebSocketMessage{ msg := WebSocketMessage{
Type: "score_submit", Type: "score_submit",
Payload: game.ScoreSubmission{ Payload: game.ScoreSubmission{
PlayerName: displayName,
PlayerCode: g.playerCode, PlayerCode: g.playerCode,
Name: name, Name: displayName, // Für Kompatibilität
Score: score, Score: score,
Mode: g.gameMode, Mode: g.gameMode,
TeamName: teamName, // Team-Name für Coop
}, },
} }
g.sendWebSocketMessage(msg) g.sendWebSocketMessage(msg)
g.scoreSubmitted = true g.scoreSubmitted = true
log.Printf("📊 Score submitted: %s = %d", name, score) log.Printf("📊 Score submitted: %s = %d (TeamName: %s)", displayName, score, teamName)
} }
// Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes // Dummy-Funktionen für Kompatibilität mit anderen Teilen des Codes
@@ -280,9 +308,41 @@ func (g *Game) sendJoinRequest() {
} }
func (g *Game) sendStartRequest() { func (g *Game) sendStartRequest() {
// Warte bis WebSocket verbunden ist
for i := 0; i < 30; i++ {
if g.connected && g.wsConn != nil && g.wsConn.connected {
break
}
time.Sleep(100 * time.Millisecond)
}
if !g.connected {
log.Println("❌ Kann START nicht senden - keine Verbindung")
return
}
g.startGame() g.startGame()
} }
func (g *Game) publishInput(input game.ClientInput) { func (g *Game) publishInput(input game.ClientInput) {
g.sendInput(input) g.sendInput(input)
} }
// sendSetTeamNameInput sendet SET_TEAM_NAME Input über WebSocket
func (g *Game) sendSetTeamNameInput(teamName string) {
if !g.connected {
log.Println("❌ Kann Team-Name nicht senden - keine Verbindung")
return
}
myID := g.getMyPlayerID()
input := game.ClientInput{
Type: "SET_TEAM_NAME",
RoomID: g.roomID,
PlayerID: myID,
TeamName: teamName,
}
g.sendInput(input)
log.Printf("🏷️ SET_TEAM_NAME gesendet: '%s'", teamName)
}

View File

@@ -5,10 +5,7 @@ import (
"image/color" "image/color"
"log" "log"
"math" "math"
"sort"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil" "github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text" "github.com/hajimehoshi/ebiten/v2/text"
@@ -222,12 +219,11 @@ func (g *Game) DrawGame(screen *ebiten.Image) {
// In WASM: HTML Game Over Screen anzeigen // In WASM: HTML Game Over Screen anzeigen
if !g.scoreSubmitted { if !g.scoreSubmitted {
g.scoreSubmitted = true g.submitScore() // submitScore() setzt g.scoreSubmitted intern
g.submitScore()
g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen g.sendGameOverToJS(myScore) // Zeigt HTML Game Over Screen
} }
g.DrawGameOverLeaderboard(screen, myScore) g.drawGameOverScreen(screen, myScore)
return // Früher Return, damit Game-UI nicht mehr gezeichnet wird return // Früher Return, damit Game-UI nicht mehr gezeichnet wird
} }
@@ -510,153 +506,3 @@ func (g *Game) DrawAsset(screen *ebiten.Image, assetID string, worldX, worldY fl
} }
} }
// DrawGameOverLeaderboard zeigt Leaderboard mit Team-Name-Eingabe
func (g *Game) DrawGameOverLeaderboard(screen *ebiten.Image, myScore int) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Leaderboard immer beim ersten Mal anfordern (ohne Lock hier!)
if !g.scoreSubmitted && g.gameMode == "solo" {
g.submitScore() // submitScore() ruft requestLeaderboard() auf
} else {
// Für Coop: Nur Leaderboard anfordern, nicht submitten
g.leaderboardMutex.Lock()
needsLeaderboard := len(g.leaderboard) == 0 && g.connected
g.leaderboardMutex.Unlock()
if needsLeaderboard {
g.requestLeaderboard()
}
}
// Großes GAME OVER
text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 60, color.RGBA{255, 0, 0, 255})
// Linke Seite: Raum-Ergebnisse - Daten KOPIEREN mit Lock, dann außerhalb zeichnen
text.Draw(screen, "=== RAUM ERGEBNISSE ===", basicfont.Face7x13, 50, 120, color.RGBA{255, 255, 0, 255})
type playerScore struct {
name string
score int
}
// Lock NUR für Datenkopie
g.stateMutex.Lock()
players := make([]playerScore, 0, len(g.gameState.Players))
for _, p := range g.gameState.Players {
players = append(players, playerScore{name: p.Name, score: p.Score})
}
g.stateMutex.Unlock()
// Sortieren und Zeichnen OHNE Lock
sort.Slice(players, func(i, j int) bool {
return players[i].score > players[j].score
})
y := 150
for i, p := range players {
medal := ""
if i == 0 {
medal = "🥇 "
} else if i == 1 {
medal = "🥈 "
} else if i == 2 {
medal = "🥉 "
}
scoreMsg := fmt.Sprintf("%d. %s%s: %d pts", i+1, medal, p.name, p.score)
text.Draw(screen, scoreMsg, basicfont.Face7x13, 50, y, color.White)
y += 20
}
// Rechte Seite: Global Leaderboard - Daten KOPIEREN mit Lock, dann außerhalb zeichnen
text.Draw(screen, "=== TOP 10 BESTENLISTE ===", basicfont.Face7x13, 650, 120, color.RGBA{255, 215, 0, 255})
// Lock NUR für Datenkopie
g.leaderboardMutex.Lock()
leaderboardCopy := make([]game.LeaderboardEntry, len(g.leaderboard))
copy(leaderboardCopy, g.leaderboard)
g.leaderboardMutex.Unlock()
// Zeichnen OHNE Lock
ly := 150
if len(leaderboardCopy) == 0 {
text.Draw(screen, "Laden...", basicfont.Face7x13, 700, ly, color.Gray{150})
} else {
for i, entry := range leaderboardCopy {
if i >= 10 {
break
}
var col color.Color = color.White
marker := ""
if entry.PlayerCode == g.playerCode {
col = color.RGBA{0, 255, 0, 255}
marker = " ← DU"
}
medal := ""
if i == 0 {
medal = "🥇 "
} else if i == 1 {
medal = "🥈 "
} else if i == 2 {
medal = "🥉 "
}
leaderMsg := fmt.Sprintf("%d. %s%s: %d%s", i+1, medal, entry.PlayerName, entry.Score, marker)
text.Draw(screen, leaderMsg, basicfont.Face7x13, 650, ly, col)
ly += 20
}
}
// Team-Name-Eingabe nur für Coop-Host (in der Mitte unten)
if g.gameMode == "coop" && g.isHost {
text.Draw(screen, "Host: Gib Team-Namen ein", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-180, color.RGBA{255, 215, 0, 255})
// Team-Name Feld
fieldW := 300
fieldX := ScreenWidth/2 - fieldW/2
fieldY := ScreenHeight - 140
col := color.RGBA{70, 70, 80, 255}
if g.activeField == "teamname" {
col = color.RGBA{90, 90, 100, 255}
}
vector.DrawFilledRect(screen, float32(fieldX), float32(fieldY), float32(fieldW), 40, col, false)
vector.StrokeRect(screen, float32(fieldX), float32(fieldY), float32(fieldW), 40, 2, color.RGBA{255, 215, 0, 255}, false)
display := g.teamName
if g.activeField == "teamname" && (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
if display == "" {
display = "Team Name..."
}
text.Draw(screen, display, basicfont.Face7x13, fieldX+10, fieldY+25, color.White)
// Submit Button
submitBtnY := ScreenHeight - 85
submitBtnW := 200
submitBtnX := ScreenWidth/2 - submitBtnW/2
btnCol := color.RGBA{0, 150, 0, 255}
if g.teamName == "" {
btnCol = color.RGBA{100, 100, 100, 255} // Grau wenn kein Name
}
vector.DrawFilledRect(screen, float32(submitBtnX), float32(submitBtnY), float32(submitBtnW), 40, btnCol, false)
vector.StrokeRect(screen, float32(submitBtnX), float32(submitBtnY), float32(submitBtnW), 40, 2, color.White, false)
text.Draw(screen, "SUBMIT SCORE", basicfont.Face7x13, submitBtnX+50, submitBtnY+25, color.White)
} else if g.gameMode == "solo" && g.scoreSubmitted {
// Solo: Zeige Bestätigungsmeldung
text.Draw(screen, "Score eingereicht!", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.RGBA{0, 255, 0, 255})
} else if g.gameMode == "coop" && !g.isHost {
// Coop Non-Host: Warten auf Host
text.Draw(screen, "Warte auf Host...", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.Gray{180})
}
// Back Button (oben links)
backBtnW, backBtnH := 120, 40
backBtnX, backBtnY := 20, 20
vector.DrawFilledRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), color.RGBA{150, 0, 0, 255}, false)
vector.StrokeRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), 2, color.White, false)
text.Draw(screen, "< ZURÜCK", basicfont.Face7x13, backBtnX+20, backBtnY+25, color.White)
// Unten: Anleitung
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-30, color.Gray{180})
}

View File

@@ -3,7 +3,172 @@
package main package main
import (
"fmt"
"image/color"
"sort"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/text"
"github.com/hajimehoshi/ebiten/v2/vector"
"golang.org/x/image/font/basicfont"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
)
// sendGameOverToJS ist ein Stub für Native (kein HTML) // sendGameOverToJS ist ein Stub für Native (kein HTML)
func (g *Game) sendGameOverToJS(score int) { func (g *Game) sendGameOverToJS(score int) {
// Native hat kein HTML-Overlay, nichts zu tun // Native hat kein HTML-Overlay, nichts zu tun
} }
// drawGameOverScreen zeigt Leaderboard mit Team-Name-Eingabe
func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Leaderboard immer beim ersten Mal anfordern (ohne Lock hier!)
if !g.scoreSubmitted && g.gameMode == "solo" {
g.submitScore() // submitScore() ruft requestLeaderboard() auf
} else {
// Für Coop: Nur Leaderboard anfordern, nicht submitten
g.leaderboardMutex.Lock()
needsLeaderboard := len(g.leaderboard) == 0 && g.connected
g.leaderboardMutex.Unlock()
if needsLeaderboard {
g.requestLeaderboard()
}
}
// Großes GAME OVER
text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 60, color.RGBA{255, 0, 0, 255})
// Linke Seite: Raum-Ergebnisse - Daten KOPIEREN mit Lock, dann außerhalb zeichnen
text.Draw(screen, "=== RAUM ERGEBNISSE ===", basicfont.Face7x13, 50, 120, color.RGBA{255, 255, 0, 255})
type playerScore struct {
name string
score int
}
// Lock NUR für Datenkopie
g.stateMutex.Lock()
players := make([]playerScore, 0, len(g.gameState.Players))
for _, p := range g.gameState.Players {
players = append(players, playerScore{name: p.Name, score: p.Score})
}
g.stateMutex.Unlock()
// Sortieren und Zeichnen OHNE Lock
sort.Slice(players, func(i, j int) bool {
return players[i].score > players[j].score
})
y := 150
for i, p := range players {
medal := ""
if i == 0 {
medal = "🥇 "
} else if i == 1 {
medal = "🥈 "
} else if i == 2 {
medal = "🥉 "
}
scoreMsg := fmt.Sprintf("%d. %s%s: %d pts", i+1, medal, p.name, p.score)
text.Draw(screen, scoreMsg, basicfont.Face7x13, 50, y, color.White)
y += 20
}
// Rechte Seite: Global Leaderboard - Daten KOPIEREN mit Lock, dann außerhalb zeichnen
text.Draw(screen, "=== TOP 10 BESTENLISTE ===", basicfont.Face7x13, 650, 120, color.RGBA{255, 215, 0, 255})
// Lock NUR für Datenkopie
g.leaderboardMutex.Lock()
leaderboardCopy := make([]game.LeaderboardEntry, len(g.leaderboard))
copy(leaderboardCopy, g.leaderboard)
g.leaderboardMutex.Unlock()
// Zeichnen OHNE Lock
ly := 150
if len(leaderboardCopy) == 0 {
text.Draw(screen, "Laden...", basicfont.Face7x13, 700, ly, color.Gray{150})
} else {
for i, entry := range leaderboardCopy {
if i >= 10 {
break
}
var col color.Color = color.White
marker := ""
if entry.PlayerCode == g.playerCode {
col = color.RGBA{0, 255, 0, 255}
marker = " ← DU"
}
medal := ""
if i == 0 {
medal = "🥇 "
} else if i == 1 {
medal = "🥈 "
} else if i == 2 {
medal = "🥉 "
}
leaderMsg := fmt.Sprintf("%d. %s%s: %d%s", i+1, medal, entry.PlayerName, entry.Score, marker)
text.Draw(screen, leaderMsg, basicfont.Face7x13, 650, ly, col)
ly += 20
}
}
// Team-Name-Eingabe nur für Coop-Host (in der Mitte unten)
if g.gameMode == "coop" && g.isHost {
text.Draw(screen, "Host: Gib Team-Namen ein", basicfont.Face7x13, ScreenWidth/2-100, ScreenHeight-180, color.RGBA{255, 215, 0, 255})
// Team-Name Feld
fieldW := 300
fieldX := ScreenWidth/2 - fieldW/2
fieldY := ScreenHeight - 140
col := color.RGBA{70, 70, 80, 255}
if g.activeField == "teamname" {
col = color.RGBA{90, 90, 100, 255}
}
vector.DrawFilledRect(screen, float32(fieldX), float32(fieldY), float32(fieldW), 40, col, false)
vector.StrokeRect(screen, float32(fieldX), float32(fieldY), float32(fieldW), 40, 2, color.RGBA{255, 215, 0, 255}, false)
display := g.teamName
if g.activeField == "teamname" && (time.Now().UnixMilli()/500)%2 == 0 {
display += "|"
}
if display == "" {
display = "Team Name..."
}
text.Draw(screen, display, basicfont.Face7x13, fieldX+10, fieldY+25, color.White)
// Submit Button
submitBtnY := ScreenHeight - 85
submitBtnW := 200
submitBtnX := ScreenWidth/2 - submitBtnW/2
btnCol := color.RGBA{0, 150, 0, 255}
if g.teamName == "" {
btnCol = color.RGBA{100, 100, 100, 255} // Grau wenn kein Name
}
vector.DrawFilledRect(screen, float32(submitBtnX), float32(submitBtnY), float32(submitBtnW), 40, btnCol, false)
vector.StrokeRect(screen, float32(submitBtnX), float32(submitBtnY), float32(submitBtnW), 40, 2, color.White, false)
text.Draw(screen, "SUBMIT SCORE", basicfont.Face7x13, submitBtnX+50, submitBtnY+25, color.White)
} else if g.gameMode == "solo" && g.scoreSubmitted {
// Solo: Zeige Bestätigungsmeldung
text.Draw(screen, "Score eingereicht!", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.RGBA{0, 255, 0, 255})
} else if g.gameMode == "coop" && !g.isHost {
// Coop Non-Host: Warten auf Host
text.Draw(screen, "Warte auf Host...", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.Gray{180})
}
// Back Button (oben links)
backBtnW, backBtnH := 120, 40
backBtnX, backBtnY := 20, 20
vector.DrawFilledRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), color.RGBA{150, 0, 0, 255}, false)
vector.StrokeRect(screen, float32(backBtnX), float32(backBtnY), float32(backBtnW), float32(backBtnH), 2, color.White, false)
text.Draw(screen, "< ZURÜCK", basicfont.Face7x13, backBtnX+20, backBtnY+25, color.White)
// Unten: Anleitung
text.Draw(screen, "ESC oder ZURÜCK-Button = Menü", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-30, color.Gray{180})
}

View File

@@ -0,0 +1,16 @@
//go:build js && wasm
// +build js,wasm
package main
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
)
// drawGameOverScreen zeichnet NICHTS in WASM - HTML übernimmt
func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
// In WASM: HTML Game Over Screen ist aktiv, zeichne nur schwarzen Hintergrund
screen.Fill(color.RGBA{0, 0, 0, 255})
}

View File

@@ -28,11 +28,11 @@ func (g *Game) setupJavaScriptBridge() {
g.savePlayerName(playerName) g.savePlayerName(playerName)
if mode == "solo" { if mode == "solo" {
// Solo Mode - direkt ins Spiel // Solo Mode - Auto-Start wartet auf Server
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano()) g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true g.isHost = true
g.appState = StateGame g.appState = StateLobby // Warte auf Server Auto-Start
log.Printf("🎮 Solo-Spiel gestartet: %s", playerName) log.Printf("🎮 Solo-Spiel gestartet: %s (warte auf Server)", playerName)
} else if mode == "coop" && len(args) >= 5 { } else if mode == "coop" && len(args) >= 5 {
// Co-op Mode - in die Lobby // Co-op Mode - in die Lobby
roomID := args[2].String() roomID := args[2].String()
@@ -87,12 +87,24 @@ func (g *Game) setupJavaScriptBridge() {
return nil return nil
}) })
// setTeamName_WASM(teamName) - Host setzt Team-Namen
setTeamNameFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
if len(args) > 0 {
teamName := args[0].String()
g.teamName = teamName
log.Printf("🏷️ Team-Name gesetzt: '%s'", teamName)
go g.sendSetTeamNameInput(teamName)
}
return nil
})
// Im globalen Scope registrieren // Im globalen Scope registrieren
js.Global().Set("startGame", startGameFunc) js.Global().Set("startGame", startGameFunc)
js.Global().Set("requestLeaderboard", requestLeaderboardFunc) js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
js.Global().Set("setMusicVolume", setMusicVolumeFunc) js.Global().Set("setMusicVolume", setMusicVolumeFunc)
js.Global().Set("setSFXVolume", setSFXVolumeFunc) js.Global().Set("setSFXVolume", setSFXVolumeFunc)
js.Global().Set("startGameFromLobby_WASM", startGameFromLobbyFunc) js.Global().Set("startGameFromLobby_WASM", startGameFromLobbyFunc)
js.Global().Set("setTeamName_WASM", setTeamNameFunc)
log.Println("✅ JavaScript Bridge registriert") log.Println("✅ JavaScript Bridge registriert")
} }
@@ -131,6 +143,7 @@ func (g *Game) sendLobbyPlayersToJS() {
g.stateMutex.Lock() g.stateMutex.Lock()
players := make([]interface{}, 0, len(g.gameState.Players)) players := make([]interface{}, 0, len(g.gameState.Players))
hostID := g.gameState.HostID hostID := g.gameState.HostID
teamName := g.gameState.TeamName
for id, p := range g.gameState.Players { for id, p := range g.gameState.Players {
name := p.Name name := p.Name
@@ -151,4 +164,12 @@ func (g *Game) sendLobbyPlayersToJS() {
updateFunc.Invoke(jsPlayers) updateFunc.Invoke(jsPlayers)
log.Printf("👥 Lobby-Spieler an JavaScript gesendet: %d Spieler", len(players)) log.Printf("👥 Lobby-Spieler an JavaScript gesendet: %d Spieler", len(players))
} }
// Team-Name an JavaScript senden
if updateTeamFunc := js.Global().Get("updateLobbyTeamName"); !updateTeamFunc.IsUndefined() {
myID := g.getMyPlayerID()
isHost := (myID == hostID)
updateTeamFunc.Invoke(teamName, isHost)
log.Printf("🏷️ Team-Name an JavaScript gesendet: '%s' (isHost: %v)", teamName, isHost)
}
} }

View File

@@ -1,89 +0,0 @@
# 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
```

View File

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

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

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

Before

Width:  |  Height:  |  Size: 4.1 MiB

After

Width:  |  Height:  |  Size: 4.1 MiB

View File

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

Before

Width:  |  Height:  |  Size: 366 KiB

After

Width:  |  Height:  |  Size: 366 KiB

View File

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View File

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View File

Before

Width:  |  Height:  |  Size: 307 B

After

Width:  |  Height:  |  Size: 307 B

View File

Before

Width:  |  Height:  |  Size: 653 KiB

After

Width:  |  Height:  |  Size: 653 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 MiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

Before

Width:  |  Height:  |  Size: 559 KiB

After

Width:  |  Height:  |  Size: 559 KiB

View File

Before

Width:  |  Height:  |  Size: 545 KiB

After

Width:  |  Height:  |  Size: 545 KiB

View File

Before

Width:  |  Height:  |  Size: 696 KiB

After

Width:  |  Height:  |  Size: 696 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 316 KiB

After

Width:  |  Height:  |  Size: 316 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

Before

Width:  |  Height:  |  Size: 4.2 MiB

After

Width:  |  Height:  |  Size: 4.2 MiB

View File

Before

Width:  |  Height:  |  Size: 222 B

After

Width:  |  Height:  |  Size: 222 B

View File

Before

Width:  |  Height:  |  Size: 384 B

After

Width:  |  Height:  |  Size: 384 B

View File

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -2,6 +2,130 @@
let wasmReady = false; let wasmReady = false;
let gameStarted = false; let gameStarted = false;
let audioMuted = false; let audioMuted = false;
let currentLeaderboard = []; // Store full leaderboard data with proof codes
// UI State Management - Single Source of Truth
const UIState = {
LOADING: 'loading',
MENU: 'menu',
LOBBY: 'lobby',
PLAYING: 'playing',
GAME_OVER: 'gameover',
LEADERBOARD: 'leaderboard',
SETTINGS: 'settings',
COOP_MENU: 'coop_menu',
MY_CODES: 'mycodes',
IMPRESSUM: 'impressum',
DATENSCHUTZ: 'datenschutz'
};
let currentUIState = UIState.LOADING;
// Central UI State Manager
function setUIState(newState) {
console.log('🎨 UI State:', currentUIState, '->', newState);
currentUIState = newState;
const canvas = document.querySelector('canvas');
const loadingScreen = document.getElementById('loading');
// Hide all overlays first
document.querySelectorAll('.overlay-screen').forEach(screen => {
screen.classList.add('hidden');
});
if (loadingScreen) loadingScreen.style.display = 'none';
// Manage Canvas and Overlays based on state
switch(newState) {
case UIState.LOADING:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
if (loadingScreen) loadingScreen.style.display = 'flex';
break;
case UIState.MENU:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('menu').classList.remove('hidden');
break;
case UIState.LOBBY:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('lobbyScreen').classList.remove('hidden');
break;
case UIState.PLAYING:
if (canvas) {
canvas.classList.add('game-active');
canvas.style.visibility = 'visible';
}
// No overlays shown during gameplay
break;
case UIState.GAME_OVER:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('gameOverScreen').classList.remove('hidden');
break;
case UIState.LEADERBOARD:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('leaderboardMenu').classList.remove('hidden');
break;
case UIState.SETTINGS:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('settingsMenu').classList.remove('hidden');
break;
case UIState.COOP_MENU:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('coopMenu').classList.remove('hidden');
break;
case UIState.MY_CODES:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('myCodesMenu').classList.remove('hidden');
break;
case UIState.IMPRESSUM:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('impressumMenu').classList.remove('hidden');
break;
case UIState.DATENSCHUTZ:
if (canvas) {
canvas.classList.remove('game-active');
canvas.style.visibility = 'hidden';
}
document.getElementById('datenschutzMenu').classList.remove('hidden');
break;
}
}
// WebSocket for Leaderboard (direct JS connection) // WebSocket for Leaderboard (direct JS connection)
let leaderboardWS = null; let leaderboardWS = null;
@@ -89,8 +213,11 @@ async function initWASM() {
go.run(result.instance); go.run(result.instance);
wasmReady = true; wasmReady = true;
// Hide loading screen // Switch to menu state
document.getElementById('loading').style.display = 'none'; setUIState(UIState.MENU);
// Enable all start buttons
enableStartButtons();
console.log('✅ WASM loaded successfully'); console.log('✅ WASM loaded successfully');
@@ -104,53 +231,32 @@ async function initWASM() {
} }
} }
// Menu Navigation // Enable start buttons after WASM is ready
function showMainMenu() { function enableStartButtons() {
hideAllScreens(); const buttons = ['startBtn', 'coopBtn', 'createRoomBtn', 'joinRoomBtn'];
document.getElementById('menu').classList.remove('hidden'); buttons.forEach(btnId => {
} const btn = document.getElementById(btnId);
if (btn) {
function showCoopMenu() { btn.disabled = false;
hideAllScreens(); btn.style.opacity = '1';
document.getElementById('coopMenu').classList.remove('hidden'); btn.style.cursor = 'pointer';
} }
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');
}); });
console.log('✅ Start-Buttons aktiviert (Solo + Coop)');
} }
function hideMenu() { // Menu Navigation
document.getElementById('menu').style.display = 'none'; // Legacy function wrappers - use setUIState instead
// Canvas sichtbar machen für Gameplay function showMainMenu() { setUIState(UIState.MENU); }
const canvas = document.querySelector('canvas'); function showCoopMenu() { setUIState(UIState.COOP_MENU); }
if (canvas) { function showSettings() { setUIState(UIState.SETTINGS); }
canvas.classList.add('game-active'); function showLeaderboard() { setUIState(UIState.LEADERBOARD); loadLeaderboard(); }
} function showMyCodes() { setUIState(UIState.MY_CODES); loadMyCodes(); }
} function showImpressum() { setUIState(UIState.IMPRESSUM); }
function showDatenschutz() { setUIState(UIState.DATENSCHUTZ); }
function showMenu() { function hideAllScreens() { /* Handled by setUIState */ }
document.getElementById('menu').style.display = 'flex'; function hideMenu() { /* Handled by setUIState */ }
document.getElementById('menu').classList.remove('hidden'); function showMenu() { setUIState(UIState.MENU); }
showMainMenu();
// Canvas verstecken im Menü
const canvas = document.querySelector('canvas');
if (canvas) {
canvas.classList.remove('game-active');
}
}
// Game Functions // Game Functions
function startSoloGame() { function startSoloGame() {
@@ -166,23 +272,17 @@ function startSoloGame() {
localStorage.setItem('escape_game_mode', 'solo'); localStorage.setItem('escape_game_mode', 'solo');
localStorage.setItem('escape_room_id', ''); localStorage.setItem('escape_room_id', '');
// Hide ALL screens including main menu
hideAllScreens();
document.getElementById('menu').style.display = 'none';
gameStarted = true; gameStarted = true;
// Canvas sichtbar machen // Don't switch UI state yet - wait for WASM callback onGameStarted()
const canvas = document.querySelector('canvas'); // The server will auto-start solo games after 2 seconds
if (canvas) {
canvas.classList.add('game-active');
}
// Trigger WASM game start // Trigger WASM game start
if (window.startGame) { if (window.startGame) {
window.startGame('solo', playerName, ''); window.startGame('solo', playerName, '');
} }
console.log('🎮 Solo game started:', playerName); console.log('🎮 Solo game starting - waiting for server auto-start...');
} }
function createRoom() { function createRoom() {
@@ -202,12 +302,8 @@ function createRoom() {
localStorage.setItem('escape_team_name', teamName); localStorage.setItem('escape_team_name', teamName);
localStorage.setItem('escape_is_host', 'true'); localStorage.setItem('escape_is_host', 'true');
// Verstecke ALLE Screens inkl. Hauptmenü // Show Lobby
hideAllScreens(); setUIState(UIState.LOBBY);
document.getElementById('menu').style.display = 'none';
// Zeige HTML Lobby Screen
document.getElementById('lobbyScreen').classList.remove('hidden');
document.getElementById('lobbyRoomCode').textContent = roomID; document.getElementById('lobbyRoomCode').textContent = roomID;
document.getElementById('lobbyHostControls').classList.remove('hidden'); document.getElementById('lobbyHostControls').classList.remove('hidden');
document.getElementById('lobbyStatus').textContent = 'Du bist Host - starte wenn bereit!'; document.getElementById('lobbyStatus').textContent = 'Du bist Host - starte wenn bereit!';
@@ -242,12 +338,8 @@ function joinRoom() {
localStorage.setItem('escape_team_name', teamName); localStorage.setItem('escape_team_name', teamName);
localStorage.setItem('escape_is_host', 'false'); localStorage.setItem('escape_is_host', 'false');
// Verstecke ALLE Screens inkl. Hauptmenü // Show Lobby
hideAllScreens(); setUIState(UIState.LOBBY);
document.getElementById('menu').style.display = 'none';
// Zeige HTML Lobby Screen
document.getElementById('lobbyScreen').classList.remove('hidden');
document.getElementById('lobbyRoomCode').textContent = roomID; document.getElementById('lobbyRoomCode').textContent = roomID;
document.getElementById('lobbyHostControls').classList.add('hidden'); document.getElementById('lobbyHostControls').classList.add('hidden');
document.getElementById('lobbyStatus').textContent = 'Warte auf Host...'; document.getElementById('lobbyStatus').textContent = 'Warte auf Host...';
@@ -291,6 +383,42 @@ function updateLobbyPlayers(players) {
console.log('👥 Lobby players updated:', players.length); console.log('👥 Lobby players updated:', players.length);
} }
// Update Lobby Team Name (called by WASM)
function updateLobbyTeamName(teamName, isHost) {
const teamNameBox = document.getElementById('lobbyTeamNameBox');
const teamNameDisplay = document.getElementById('teamNameDisplay');
// Zeige Team-Name Box nur für Host
if (isHost) {
teamNameBox.classList.remove('hidden');
// Setup Event Listener für Input-Feld (nur einmal)
const input = document.getElementById('lobbyTeamName');
if (!input.dataset.listenerAdded) {
input.addEventListener('input', function() {
const newTeamName = this.value.toUpperCase().trim();
if (newTeamName && window.setTeamName_WASM) {
window.setTeamName_WASM(newTeamName);
}
});
input.dataset.listenerAdded = 'true';
}
} else {
teamNameBox.classList.add('hidden');
}
// Aktualisiere Team-Name Anzeige
if (teamName && teamName !== '') {
teamNameDisplay.textContent = teamName;
teamNameDisplay.style.color = '#00ff00';
} else {
teamNameDisplay.textContent = 'Nicht gesetzt';
teamNameDisplay.style.color = '#888';
}
console.log('🏷️ Team name updated:', teamName, 'isHost:', isHost);
}
function loadLeaderboard() { function loadLeaderboard() {
const list = document.getElementById('leaderboardList'); const list = document.getElementById('leaderboardList');
list.innerHTML = '<div style="text-align:center; padding:20px;">Lädt Leaderboard...</div>'; list.innerHTML = '<div style="text-align:center; padding:20px;">Lädt Leaderboard...</div>';
@@ -308,6 +436,9 @@ function loadLeaderboard() {
// Called by WASM to update leaderboard // Called by WASM to update leaderboard
function updateLeaderboard(entries) { function updateLeaderboard(entries) {
// Store full leaderboard data globally
currentLeaderboard = entries || [];
// Update ALL leaderboard displays // Update ALL leaderboard displays
const list = document.getElementById('leaderboardList'); const list = document.getElementById('leaderboardList');
const startList = document.getElementById('startLeaderboardList'); const startList = document.getElementById('startLeaderboardList');
@@ -423,8 +554,7 @@ document.addEventListener('keydown', (e) => {
// Show Game Over Screen (called by WASM) // Show Game Over Screen (called by WASM)
function showGameOver(score) { function showGameOver(score) {
hideAllScreens(); setUIState(UIState.GAME_OVER);
document.getElementById('gameOverScreen').classList.remove('hidden');
document.getElementById('finalScore').textContent = score; document.getElementById('finalScore').textContent = score;
// Update local highscore // Update local highscore
@@ -436,29 +566,137 @@ function showGameOver(score) {
// Request leaderboard via direct WebSocket // Request leaderboard via direct WebSocket
requestLeaderboardDirect(); requestLeaderboardDirect();
console.log('💀 Game Over! Score:', score); // Note: Proof-Code wird jetzt direkt vom Server über score_response WebSocket Nachricht gesendet
// und von WASM (connection_wasm.go) automatisch an saveHighscoreCode() weitergeleitet
console.log('💀 Game Over! Score:', score, '- Warte auf Proof-Code vom Server...');
} }
// Called by WASM when game actually starts // Called by WASM when game actually starts
function onGameStarted() { function onGameStarted() {
console.log('🎮 Game Started - Making canvas visible'); console.log('🎮 Game Started - Making canvas visible');
hideAllScreens();
document.getElementById('menu').style.display = 'none';
gameStarted = true; gameStarted = true;
setUIState(UIState.PLAYING);
}
// Canvas sichtbar machen // ===== MY CODES MANAGEMENT =====
const canvas = document.querySelector('canvas');
if (canvas) { // Save highscore code to localStorage
canvas.classList.add('game-active'); function saveHighscoreCode(score, proofCode, playerName) {
const codes = getMySavedCodes();
const newCode = {
score: score,
proof: proofCode,
player_name: playerName,
timestamp: Date.now(),
date: new Date().toLocaleString('de-DE')
};
codes.push(newCode);
localStorage.setItem('escape_highscore_codes', JSON.stringify(codes));
console.log('💾 Highscore-Code gespeichert:', proofCode, 'für Score:', score);
}
// Get all saved codes from localStorage
function getMySavedCodes() {
const stored = localStorage.getItem('escape_highscore_codes');
if (!stored) return [];
try {
return JSON.parse(stored);
} catch (e) {
return [];
} }
} }
// Load and display my codes
function loadMyCodes() {
const codes = getMySavedCodes();
const list = document.getElementById('myCodesList');
if (codes.length === 0) {
list.innerHTML = '<div style="color: #888; text-align: center; padding: 20px;">Noch keine Highscores erreicht!</div>';
return;
}
// Sort by score descending
codes.sort((a, b) => b.score - a.score);
// Request current leaderboard to check positions
requestLeaderboardForCodes(codes);
}
// Request leaderboard and then display codes with positions
function requestLeaderboardForCodes(codes) {
const list = document.getElementById('myCodesList');
list.innerHTML = '<div style="color: #888; text-align: center; padding: 20px;">Lade Positionen...</div>';
// Use the direct leaderboard WebSocket
requestLeaderboardDirect();
// Wait a bit for leaderboard to arrive, then display
setTimeout(() => {
displayMyCodesWithPositions(codes);
}, 1000);
}
// Display codes with their leaderboard positions
function displayMyCodesWithPositions(codes) {
const list = document.getElementById('myCodesList');
let html = '';
codes.forEach((code, index) => {
// Try to find position in current leaderboard
const position = findPositionInLeaderboard(code.proof);
const positionText = position > 0 ? `#${position}` : 'Nicht in Top 10';
const positionColor = position > 0 ? '#fc0' : '#888';
html += `
<div style="background: rgba(0,0,0,0.4); border: 2px solid #fc0; padding: 12px; margin: 8px 0; border-radius: 4px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<div>
<span style="color: #fc0; font-size: 20px; font-weight: bold;">${code.score} Punkte</span>
<span style="color: ${positionColor}; font-size: 14px; margin-left: 10px;">${positionText}</span>
</div>
<button onclick="deleteHighscoreCode(${index})" style="background: #ff4444; border: none; color: white; padding: 5px 10px; font-size: 10px; cursor: pointer; border-radius: 3px;">LÖSCHEN</button>
</div>
<div style="font-family: sans-serif; font-size: 12px; color: #ccc;">
<div><strong>Name:</strong> ${code.player_name}</div>
<div><strong>Code:</strong> <span style="color: #fc0; font-family: monospace;">${code.proof}</span></div>
<div><strong>Datum:</strong> ${code.date}</div>
</div>
</div>
`;
});
list.innerHTML = html;
}
// Find position of a proof code in the current leaderboard
function findPositionInLeaderboard(proofCode) {
if (!currentLeaderboard || currentLeaderboard.length === 0) return -1;
// Find the entry with matching proof code
const index = currentLeaderboard.findIndex(entry => entry.proof_code === proofCode);
return index >= 0 ? index + 1 : -1; // Return 1-based position
}
// Delete a highscore code
function deleteHighscoreCode(index) {
if (!confirm('Diesen Highscore-Code wirklich löschen?')) return;
const codes = getMySavedCodes();
codes.splice(index, 1);
localStorage.setItem('escape_highscore_codes', JSON.stringify(codes));
console.log('🗑️ Highscore-Code gelöscht');
loadMyCodes(); // Reload display
}
// Export functions for WASM to call // Export functions for WASM to call
window.showMenu = showMenu; window.showMenu = showMenu;
window.hideMenu = hideMenu; window.hideMenu = hideMenu;
window.updateLeaderboard = updateLeaderboard; window.updateLeaderboard = updateLeaderboard;
window.showGameOver = showGameOver; window.showGameOver = showGameOver;
window.onGameStarted = onGameStarted; window.onGameStarted = onGameStarted;
window.saveHighscoreCode = saveHighscoreCode;
// Initialize on load // Initialize on load
initWASM(); initWASM();

View File

@@ -27,8 +27,8 @@
<input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;"> <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="startBtn" onclick="startSoloGame()" disabled style="opacity: 0.5; cursor: not-allowed;">SOLO STARTEN</button>
<button id="coopBtn" onclick="showCoopMenu()">CO-OP SPIELEN</button> <button id="coopBtn" onclick="showCoopMenu()" disabled style="opacity: 0.5; cursor: not-allowed;">CO-OP SPIELEN</button>
<div class="info-box"> <div class="info-box">
<div class="info-title">SCHUL-NEWS</div> <div class="info-title">SCHUL-NEWS</div>
@@ -49,8 +49,14 @@
<div class="legal-bar"> <div class="legal-bar">
<button class="legal-btn" onclick="showLeaderboard()">🏆 TOP 10</button> <button class="legal-btn" onclick="showLeaderboard()">🏆 TOP 10</button>
<button class="legal-btn" onclick="showMyCodes()">🔑 MEINE CODES</button>
<button class="legal-btn" onclick="showSettings()">⚙️ EINSTELLUNGEN</button> <button class="legal-btn" onclick="showSettings()">⚙️ EINSTELLUNGEN</button>
</div> </div>
<div class="legal-bar" style="margin-top: 10px;">
<button class="legal-btn" onclick="showImpressum()">📄 IMPRESSUM</button>
<button class="legal-btn" onclick="showDatenschutz()">🔒 DATENSCHUTZ</button>
</div>
</div> </div>
<div class="start-right"> <div class="start-right">
@@ -67,13 +73,13 @@
<div class="center-box"> <div class="center-box">
<h1>CO-OP MODUS</h1> <h1>CO-OP MODUS</h1>
<button class="big-btn" onclick="createRoom()">RAUM ERSTELLEN</button> <button id="createRoomBtn" class="big-btn" onclick="createRoom()" disabled style="opacity: 0.5; cursor: not-allowed;">RAUM ERSTELLEN</button>
<div style="margin: 20px 0;">- ODER -</div> <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="joinRoomCode" placeholder="RAUM-CODE" maxlength="6" style="text-transform:uppercase;">
<input type="text" id="teamNameJoin" placeholder="TEAM-NAME" maxlength="15"> <input type="text" id="teamNameJoin" placeholder="TEAM-NAME" maxlength="15">
<button onclick="joinRoom()">RAUM BEITRETEN</button> <button id="joinRoomBtn" onclick="joinRoom()" disabled style="opacity: 0.5; cursor: not-allowed;">RAUM BEITRETEN</button>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button> <button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div> </div>
@@ -115,6 +121,112 @@
</div> </div>
</div> </div>
<!-- MY CODES MENU -->
<div id="myCodesMenu" class="overlay-screen hidden">
<div class="center-box">
<h1>🔑 MEINE HIGHSCORE-CODES</h1>
<div id="myCodesList" class="leaderboard-box" style="max-height: 500px;">
Keine Codes gespeichert.
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- IMPRESSUM MENU -->
<div id="impressumMenu" class="overlay-screen hidden">
<div class="center-box" style="max-width: 800px;">
<h1>📄 IMPRESSUM & CREDITS</h1>
<div class="leaderboard-box" style="max-height: 500px; overflow-y: auto; text-align: left; font-family: sans-serif; font-size: 13px; line-height: 1.6; background: #333; color: #fff;">
<h3 style="color: #fc0; margin-top: 0;">Projektleitung & Code:</h3>
<p>Sebastian Unterschütz<br>
Göltzschtalblick 16<br>
08236 Ellefeld<br>
<strong>Kontakt:</strong> sebastian@unterschuetz.de</p>
<h3 style="color: #fc0; margin-top: 20px;">🎵 Musik & Sound Design:</h3>
<p>Max E.</p>
<h3 style="color: #fc0; margin-top: 20px;">💻 Quellcode:</h3>
<p><a href="https://git.zb-server.de/ZB-Server/it232Abschied" target="_blank" style="color: #fc0;">git.zb-server.de/ZB-Server/it232Abschied</a></p>
<h3 style="color: #fc0; margin-top: 20px;">⚖️ Lizenzhinweis:</h3>
<p style="background: rgba(255,204,0,0.1); padding: 10px; border-left: 3px solid #fc0;">
Dies ist ein <strong>Schulprojekt</strong>.<br>
Kommerzielle Nutzung und Veränderung des Quellcodes sind ausdrücklich untersagt.<br>
Alle Rechte liegen bei den Urhebern.
</p>
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- DATENSCHUTZ MENU -->
<div id="datenschutzMenu" class="overlay-screen hidden">
<div class="center-box" style="max-width: 800px;">
<h1>🔒 DATENSCHUTZERKLÄRUNG</h1>
<div class="leaderboard-box" style="max-height: 500px; overflow-y: auto; text-align: left; font-family: sans-serif; font-size: 13px; line-height: 1.6; background: #333; color: #fff;">
<h3 style="color: #fc0; margin-top: 0;">1. Datenschutz auf einen Blick</h3>
<p><strong>Allgemeine Hinweise:</strong> Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen.</p>
<h3 style="color: #fc0; margin-top: 20px;">2. Verantwortlicher</h3>
<p>Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br>
<strong>Sebastian Unterschütz</strong><br>
Göltzschtalblick 16, 08236 Ellefeld<br>
E-Mail: sebastian@unterschuetz.de<br>
(Schulprojekt im Rahmen der IT232)</p>
<h3 style="color: #fc0; margin-top: 20px;">3. Hosting (Hetzner)</h3>
<p>Wir hosten die Inhalte unserer Website bei folgendem Anbieter:<br>
<strong>Hetzner Online GmbH</strong><br>
Industriestr. 25, 91710 Gunzenhausen, Deutschland</p>
<p><strong>Serverstandort:</strong> Deutschland (ausschließlich).<br>
Wir haben mit dem Anbieter einen Vertrag zur Auftragsverarbeitung (AVV) geschlossen, der die Einhaltung der DSGVO gewährleistet.</p>
<h3 style="color: #fc0; margin-top: 20px;">4. Datenerfassung auf dieser Website</h3>
<h4 style="color: #fc0; margin-top: 15px;">Server-Log-Dateien</h4>
<p>Der Provider der Seiten (Hetzner) erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien (Browser, OS, Referrer, Hostname, Uhrzeit, IP-Adresse).<br>
<strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO (Berechtigtes Interesse an technischer Fehlerfreiheit und Sicherheit). Die Daten werden nach spätestens 14 Tagen gelöscht.</p>
<h4 style="color: #fc0; margin-top: 15px;">Spielstände & Highscores</h4>
<p>Wenn Sie einen Highscore eintragen, speichern wir:</p>
<ul style="margin-left: 20px;">
<li>Gewählter Name (Pseudonym empfohlen!)</li>
<li>Punktestand und Zeitstempel</li>
<li>Eindeutiger Player-Code (generiert)</li>
<li>Proof-Code (kryptografischer Hash zur Verifizierung des Scores)</li>
</ul>
<p>Diese Daten dienen der Darstellung der Bestenliste und der Verifikation Ihrer Highscores.</p>
<h4 style="color: #fc0; margin-top: 15px;">Lokale Speicherung (LocalStorage)</h4>
<p>Das Spiel speichert folgende Daten lokal in Ihrem Browser:</p>
<ul style="margin-left: 20px;">
<li>Einstellungen (Audio-Lautstärke)</li>
<li>Ihr Spielername</li>
<li>Ihr Player-Code</li>
<li>Ihre erreichten Highscore-Codes mit Proof-Codes</li>
<li>Lokaler Highscore-Rekord</li>
</ul>
<p>Diese Daten verbleiben <strong>ausschließlich auf Ihrem Gerät</strong> und werden nicht an uns übertragen. Sie können diese Daten jederzeit über die Browser-Einstellungen löschen.</p>
<p style="background: rgba(255,204,0,0.1); padding: 10px; border-left: 3px solid #fc0; margin-top: 15px;">
<strong>Wichtig:</strong> Wir setzen <strong>keine Tracking-Cookies</strong> oder Analyse-Tools ein. Es erfolgt keine Weitergabe Ihrer Daten an Dritte (außer technisch notwendig über Hetzner als Hosting-Provider).
</p>
<h3 style="color: #fc0; margin-top: 20px;">5. Ihre Rechte</h3>
<p>Sie haben jederzeit das Recht auf Auskunft, Berichtigung und Löschung Ihrer Daten. Wenden Sie sich dazu an den Verantwortlichen im Impressum.<br>
Um Ihre lokal gespeicherten Highscore-Codes zu löschen, nutzen Sie die Funktion im Menü <strong>"🔑 MEINE CODES"</strong> oder löschen Sie den Browser-LocalStorage.</p>
</div>
<button class="back-btn" onclick="showMainMenu()">← ZURÜCK</button>
</div>
</div>
<!-- LOBBY SCREEN (CO-OP WAITING ROOM) --> <!-- LOBBY SCREEN (CO-OP WAITING ROOM) -->
<div id="lobbyScreen" class="overlay-screen hidden"> <div id="lobbyScreen" class="overlay-screen hidden">
<div class="center-box"> <div class="center-box">
@@ -134,6 +246,12 @@
</div> </div>
</div> </div>
<div id="lobbyTeamNameBox" class="hidden" style="margin: 20px 0; width: 100%; max-width: 400px;">
<p style="font-size: 14px; color: #aaa; margin-bottom: 10px;">Team-Name (nur Host):</p>
<input type="text" id="lobbyTeamName" placeholder="TEAM-NAME EINGEBEN" maxlength="15" style="text-transform:uppercase; width: 100%; padding: 10px; font-size: 16px;">
<p id="currentTeamName" style="font-size: 12px; color: #ffcc00; margin-top: 5px;">Aktuell: <span id="teamNameDisplay">Nicht gesetzt</span></p>
</div>
<div id="lobbyHostControls" class="hidden" style="margin: 20px 0;"> <div id="lobbyHostControls" class="hidden" style="margin: 20px 0;">
<button class="big-btn" onclick="startGameFromLobby()" style="background: #00cc00;">SPIEL STARTEN</button> <button class="big-btn" onclick="startGameFromLobby()" style="background: #00cc00;">SPIEL STARTEN</button>
</div> </div>

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

View File

@@ -1,9 +1,9 @@
@font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')} @font-face{font-display:swap;font-family:'Press Start 2P';font-style:normal;font-weight:400;src:url('../assets/fonts/press-start-2p-v16-latin-regular.woff2') format('woff2')}
body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;font-family:'Press Start 2P',cursive;font-size:14px} body,html{margin:0;padding:0;width:100%;height:100%;background-color:#1a1a1a;color:#fff;overflow:hidden;font-family:'Press Start 2P',cursive;font-size:14px}
#game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000} #game-container{position:relative;width:100%;height:100%;box-shadow:0 0 50px rgba(0,0,0,.8);border:4px solid #444;background:#000}
canvas{position:fixed!important;top:0!important;left:0!important;width:100%!important;height:100%!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s} canvas{position:fixed!important;top:0!important;left:0!important;width:100%!important;height:100%!important;z-index:1!important;background:#000;image-rendering:pixelated;opacity:0;pointer-events:none;transition:opacity .3s;visibility:hidden}
canvas.game-active{opacity:1;pointer-events:auto;z-index:2000!important} canvas.game-active{opacity:1;pointer-events:auto;z-index:2000!important;visibility:visible}
.overlay-screen{position:fixed!important;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.95);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px} .overlay-screen{position:fixed!important;top:0;left:0;width:100%;height:100%;background:url('background.jpg') center/cover no-repeat,rgba(0,0,0,.85);display:flex;justify-content:center;align-items:center;z-index:1000;box-sizing:border-box;padding:20px}
.overlay-screen.hidden{display:none!important} .overlay-screen.hidden{display:none!important}
#startScreen{display:flex;flex-direction:row;gap:40px;width:100%;height:100%;align-items:center;justify-content:center} #startScreen{display:flex;flex-direction:row;gap:40px;width:100%;height:100%;align-items:center;justify-content:center}
.start-left{flex:2;display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:60%} .start-left{flex:2;display:flex;flex-direction:column;align-items:center;justify-content:center;max-width:60%}
@@ -14,15 +14,15 @@ button{font-family:'Press Start 2P',cursive;background:#fc0;border:4px solid #ff
button:hover{background:#ffd700} button:hover{background:#ffd700}
button:active{transform:translateY(4px);box-shadow:0 1px 0 #997a00} button:active{transform:translateY(4px);box-shadow:0 1px 0 #997a00}
.big-btn{font-size:22px;padding:20px 40px} .big-btn{font-size:22px;padding:20px 40px}
.back-btn{background:0 0;border:2px solid #666;color:#888;box-shadow:none;font-size:12px;padding:10px 20px;margin-top:30px} .back-btn{background:#fc0;border:3px solid #fff;color:#000;box-shadow:0 4px 0 #997a00;font-size:12px;padding:10px 20px;margin-top:30px;font-weight:700}
.back-btn:hover{background:#333;color:#fff;border-color:#fff} .back-btn:hover{background:#ffd700;color:#000;transform:translateY(2px);box-shadow:0 2px 0 #997a00}
.legal-btn{font-size:10px;padding:8px 12px;margin:5px;background:0 0;border:1px solid #666;color:#888;box-shadow:none} .legal-btn{font-size:10px;padding:8px 12px;margin:5px;background:rgba(0,0,0,.6);border:2px solid #fc0;color:#fc0;box-shadow:none}
.legal-btn:hover{background:#333;color:#fff;border-color:#fff} .legal-btn:hover{background:rgba(255,204,0,.2);color:#fff;border-color:#fff}
input[type=text]{font-family:'Press Start 2P',cursive;padding:12px;font-size:16px;border:3px solid #fff;background:#222;color:#fff;text-align:center;margin-bottom:15px;width:100%;max-width:350px;outline:0;box-sizing:border-box} input[type=text]{font-family:'Press Start 2P',cursive;padding:12px;font-size:16px;border:3px solid #fff;background:#222;color:#fff;text-align:center;margin-bottom:15px;width:100%;max-width:350px;outline:0;box-sizing:border-box}
input[type=text]::placeholder{color:#666} input[type=text]::placeholder{color:#666}
input[type=text]:focus{border-color:#fc0;box-shadow:0 0 10px rgba(255,204,0,.5)} input[type=text]:focus{border-color:#fc0;box-shadow:0 0 10px rgba(255,204,0,.5)}
input[type=range]{width:100%;max-width:300px} input[type=range]{width:100%;max-width:300px}
.info-box{background:rgba(255,255,255,.1);border:2px solid #555;padding:12px;margin:8px 0;width:100%;max-width:320px;text-align:left;box-sizing:border-box} .info-box{background:rgba(0,0,0,.6);border:4px solid #fc0;padding:15px;margin:8px 0;width:100%;max-width:320px;text-align:left;box-sizing:border-box}
.info-box p{font-family:sans-serif;font-size:14px;color:#ccc;line-height:1.4;margin:0} .info-box p{font-family:sans-serif;font-size:14px;color:#ccc;line-height:1.4;margin:0}
.info-title{color:#fc0;font-size:12px;margin-bottom:6px;text-align:center;text-decoration:underline} .info-title{color:#fc0;font-size:12px;margin-bottom:6px;text-align:center;text-decoration:underline}
.legal-bar{margin-top:20px;display:flex;gap:15px;flex-wrap:wrap;justify-content:center} .legal-bar{margin-top:20px;display:flex;gap:15px;flex-wrap:wrap;justify-content:center}

View File

@@ -1,25 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; background: red; }
.test-menu {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.9);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 48px;
}
</style>
</head>
<body>
<div class="test-menu">TEST - SIEHST DU MICH?</div>
</body>
</html>

View File

@@ -25,8 +25,8 @@ import (
// --- CONFIG --- // --- CONFIG ---
const ( const (
AssetFile = "./cmd/client/assets/assets.json" AssetFile = "cmd/client/web/assets/assets.json"
ChunkDir = "./cmd/client/assets/chunks" ChunkDir = "cmd/client/web/assets/chunks"
LeftSidebarWidth = 250 LeftSidebarWidth = 250
RightSidebarWidth = 250 RightSidebarWidth = 250
@@ -116,7 +116,7 @@ func (le *LevelEditor) LoadAssets() {
json.Unmarshal(data, &le.assetManifest) json.Unmarshal(data, &le.assetManifest)
} }
baseDir := "./cmd/client/assets" baseDir := "./cmd/client/web/assets"
le.assetList = []string{} le.assetList = []string{}
for id, def := range le.assetManifest.Assets { for id, def := range le.assetManifest.Assets {

View File

@@ -93,7 +93,25 @@ func main() {
log.Printf("🔍 RAW NATS: Nachricht empfangen auf game.join: %s", string(m.Data)) log.Printf("🔍 RAW NATS: Nachricht empfangen auf game.join: %s", string(m.Data))
}) })
// 4. HANDLER: INPUT // 4. HANDLER: GAME START
_, _ = ec.Subscribe("game.start", func(req *game.StartRequest) {
log.Printf("▶️ START empfangen: RoomID=%s", req.RoomID)
mu.RLock()
room, exists := rooms[req.RoomID]
mu.RUnlock()
if exists {
room.Mutex.Lock()
room.StartCountdown()
room.Mutex.Unlock()
log.Printf("🎮 Raum '%s' Countdown gestartet", req.RoomID)
} else {
log.Printf("❌ Raum '%s' nicht gefunden", req.RoomID)
}
})
// 5. HANDLER: INPUT
_, _ = ec.Subscribe("game.input", func(input *game.ClientInput) { _, _ = ec.Subscribe("game.input", func(input *game.ClientInput) {
mu.RLock() mu.RLock()
room, ok := playerSessions[input.PlayerID] room, ok := playerSessions[input.PlayerID]
@@ -104,23 +122,40 @@ func main() {
} }
}) })
// 5. HANDLER: SCORE SUBMISSION // 6. HANDLER: SCORE SUBMISSION
_, _ = ec.Subscribe("score.submit", func(submission *game.ScoreSubmission) { _, _ = ec.Subscribe("score.submit", func(submission *game.ScoreSubmission) {
log.Printf("📊 Score-Submission: %s (%s) mit %d Punkten", submission.PlayerName, submission.PlayerCode, submission.Score) // Verwende Team-Name wenn vorhanden (Coop-Mode), sonst Player-Name (Solo-Mode)
added := server.GlobalLeaderboard.AddScore(submission.PlayerName, submission.PlayerCode, submission.Score) displayName := submission.PlayerName
if submission.TeamName != "" {
displayName = submission.TeamName
}
log.Printf("📊 Score-Submission: %s (%s) mit %d Punkten [Mode: %s]", displayName, submission.PlayerCode, submission.Score, submission.Mode)
added, proofCode := server.GlobalLeaderboard.AddScore(displayName, submission.PlayerCode, submission.Score)
if added { if added {
log.Printf("✅ Score akzeptiert für %s", submission.PlayerName) log.Printf("✅ Score akzeptiert für %s (Proof: %s)", displayName, proofCode)
// Sende Response zurück über NATS
response := game.ScoreSubmissionResponse{
Success: true,
ProofCode: proofCode,
Score: submission.Score,
}
// Sende an player-spezifischen Channel
channel := "score.response." + submission.PlayerCode
ec.Publish(channel, &response)
log.Printf("📤 Proof-Code gesendet an Channel: %s", channel)
} }
}) })
// 6. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität) // 7. HANDLER: LEADERBOARD REQUEST (alt, für Kompatibilität)
_, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) { _, _ = ec.Subscribe("leaderboard.get", func(subject, reply string, _ *struct{}) {
top10 := server.GlobalLeaderboard.GetTop10() top10 := server.GlobalLeaderboard.GetTop10()
log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10)) log.Printf("📊 Leaderboard-Request beantwortet: %d Einträge", len(top10))
ec.Publish(reply, top10) ec.Publish(reply, top10)
}) })
// 7. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway) // 8. HANDLER: LEADERBOARD REQUEST (neu, für WebSocket-Gateway)
_, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) { _, _ = ec.Subscribe("leaderboard.request", func(req *game.LeaderboardRequest) {
top10 := server.GlobalLeaderboard.GetTop10() top10 := server.GlobalLeaderboard.GetTop10()
log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10)) log.Printf("📊 Leaderboard-Request (Mode=%s): %d Einträge", req.Mode, len(top10))
@@ -137,7 +172,7 @@ func main() {
log.Println("✅ Server bereit. Warte auf Spieler...") log.Println("✅ Server bereit. Warte auf Spieler...")
// 5. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients) // 9. WEBSOCKET-GATEWAY STARTEN (für Browser-Clients)
go StartWebSocketGateway("8080", ec) go StartWebSocketGateway("8080", ec)
// Block forever // Block forever
@@ -145,7 +180,7 @@ func main() {
} }
func loadServerAssets(w *game.World) { func loadServerAssets(w *game.World) {
assetDir := "./cmd/client/assets" assetDir := "./cmd/client/web/assets"
chunkDir := filepath.Join(assetDir, "chunks") chunkDir := filepath.Join(assetDir, "chunks")
// Manifest laden // Manifest laden

View File

@@ -26,13 +26,15 @@ type WebSocketMessage struct {
// WebSocketClient repräsentiert einen verbundenen WebSocket-Client // WebSocketClient repräsentiert einen verbundenen WebSocket-Client
type WebSocketClient struct { type WebSocketClient struct {
conn *websocket.Conn conn *websocket.Conn
natsConn *nats.EncodedConn natsConn *nats.EncodedConn
playerID string playerID string
roomID string playerCode string
send chan []byte roomID string
mutex sync.Mutex send chan []byte
subUpdates *nats.Subscription mutex sync.Mutex
subUpdates *nats.Subscription
subScoreResp *nats.Subscription
} }
// handleWebSocket verwaltet eine WebSocket-Verbindung // handleWebSocket verwaltet eine WebSocket-Verbindung
@@ -62,6 +64,9 @@ func (c *WebSocketClient) readPump() {
if c.subUpdates != nil { if c.subUpdates != nil {
c.subUpdates.Unsubscribe() c.subUpdates.Unsubscribe()
} }
if c.subScoreResp != nil {
c.subScoreResp.Unsubscribe()
}
c.conn.Close() c.conn.Close()
log.Printf("🔌 WebSocket-Client getrennt: %s", c.conn.RemoteAddr()) log.Printf("🔌 WebSocket-Client getrennt: %s", c.conn.RemoteAddr())
}() }()
@@ -208,6 +213,35 @@ func (c *WebSocketClient) handleMessage(msg WebSocketMessage) {
} }
log.Printf("📊 WebSocket Score-Submit: Player=%s, Score=%d", submit.PlayerCode, submit.Score) log.Printf("📊 WebSocket Score-Submit: Player=%s, Score=%d", submit.PlayerCode, submit.Score)
// Speichere PlayerCode und subscribe auf Response-Channel
if c.playerCode == "" && submit.PlayerCode != "" {
c.playerCode = submit.PlayerCode
// Subscribe auf Score-Response für diesen Spieler
responseChannel := "score.response." + submit.PlayerCode
sub, err := c.natsConn.Subscribe(responseChannel, func(resp *game.ScoreSubmissionResponse) {
// ScoreSubmissionResponse an WebSocket-Client senden
data, _ := json.Marshal(map[string]interface{}{
"type": "score_response",
"payload": resp,
})
select {
case c.send <- data:
log.Printf("📤 Proof-Code an Client gesendet: %s", resp.ProofCode)
default:
log.Printf("⚠️ Send channel voll, Proof-Code verworfen")
}
})
if err != nil {
log.Printf("❌ Fehler beim Subscribe auf %s: %v", responseChannel, err)
} else {
c.subScoreResp = sub
log.Printf("👂 WebSocket-Client lauscht auf Score-Responses: %s", responseChannel)
}
}
c.natsConn.Publish("score.submit", &submit) c.natsConn.Publish("score.submit", &submit)
default: default:

View File

@@ -5,8 +5,8 @@ import "time"
const ( const (
// Server Settings // Server Settings
Port = ":8080" Port = ":8080"
AssetPath = "./cmd/client/assets/assets.json" AssetPath = "./cmd/client/web/assets/assets.json"
ChunkDir = "./cmd/client/assets/chunks" ChunkDir = "./cmd/client/web/assets/chunks"
// Physics // Physics
Gravity = 0.5 Gravity = 0.5

View File

@@ -66,10 +66,11 @@ type LoginPayload struct {
// Input vom Spieler während des Spiels // Input vom Spieler während des Spiels
type ClientInput struct { type ClientInput struct {
Type string `json:"type"` // "JUMP", "START", "LEFT_DOWN", "RIGHT_DOWN", etc. Type string `json:"type"` // "JUMP", "START", "LEFT_DOWN", "RIGHT_DOWN", "SET_TEAM_NAME", etc.
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
PlayerID string `json:"player_id"` PlayerID string `json:"player_id"`
Sequence uint32 `json:"sequence"` // Sequenznummer für Client Prediction Sequence uint32 `json:"sequence"` // Sequenznummer für Client Prediction
TeamName string `json:"team_name,omitempty"` // Für SET_TEAM_NAME Input
} }
type JoinRequest struct { type JoinRequest struct {
@@ -105,6 +106,7 @@ type GameState struct {
TimeLeft int `json:"time_left"` TimeLeft int `json:"time_left"`
WorldChunks []ActiveChunk `json:"world_chunks"` WorldChunks []ActiveChunk `json:"world_chunks"`
HostID string `json:"host_id"` HostID string `json:"host_id"`
TeamName string `json:"team_name"` // Team-Name (vom Host gesetzt)
ScrollX float64 `json:"scroll_x"` ScrollX float64 `json:"scroll_x"`
CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex) CollectedCoins map[string]bool `json:"collected_coins"` // Welche Coins wurden eingesammelt (Key: ChunkID_ObjectIndex)
CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt CollectedPowerups map[string]bool `json:"collected_powerups"` // Welche Powerups wurden eingesammelt
@@ -125,7 +127,8 @@ type LeaderboardEntry struct {
PlayerName string `json:"player_name"` PlayerName string `json:"player_name"`
PlayerCode string `json:"player_code"` // Eindeutiger Code für Verifikation PlayerCode string `json:"player_code"` // Eindeutiger Code für Verifikation
Score int `json:"score"` Score int `json:"score"`
Timestamp int64 `json:"timestamp"` // Unix-Timestamp Timestamp int64 `json:"timestamp"` // Unix-Timestamp
ProofCode string `json:"proof_code"` // Beweis-Code zum Verifizieren des Scores
} }
// Score-Submission vom Client an Server // Score-Submission vom Client an Server
@@ -133,8 +136,16 @@ type ScoreSubmission struct {
PlayerName string `json:"player_name"` PlayerName string `json:"player_name"`
PlayerCode string `json:"player_code"` PlayerCode string `json:"player_code"`
Score int `json:"score"` Score int `json:"score"`
Name string `json:"name"` // Alternativer Name-Feld (für Kompatibilität) Name string `json:"name"` // Alternativer Name-Feld (für Kompatibilität)
Mode string `json:"mode"` // "solo" oder "coop" Mode string `json:"mode"` // "solo" oder "coop"
TeamName string `json:"team_name"` // Team-Name für Coop-Mode
}
// Score-Submission Response vom Server an Client
type ScoreSubmissionResponse struct {
Success bool `json:"success"`
ProofCode string `json:"proof_code"`
Score int `json:"score"`
} }
// Start-Request vom Client // Start-Request vom Client

View File

@@ -83,7 +83,7 @@ func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider {
continue continue
} }
if def.Type == "obstacle" || def.Type == "platform" { if def.Type == "obstacle" || def.Type == "platform" || def.Type == "wall" {
c := Collider{ c := Collider{
Rect: Rect{ Rect: Rect{
OffsetX: ac.X + obj.X + def.DrawOffX + def.Hitbox.OffsetX, OffsetX: ac.X + obj.X + def.DrawOffX + def.Hitbox.OffsetX,

View File

@@ -2,7 +2,10 @@ package server
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"time" "time"
@@ -39,17 +42,31 @@ func InitLeaderboard(redisAddr string) error {
return nil return nil
} }
func (lb *Leaderboard) AddScore(name, code string, score int) bool { // GenerateProofCode erstellt einen kryptografisch sicheren Proof-Code
func GenerateProofCode(playerCode string, score int, timestamp int64) string {
// Secret Salt (sollte eigentlich aus Config kommen, aber für Demo hier hardcoded)
secret := "EscapeFromTeacher_Secret_2026"
data := fmt.Sprintf("%s:%d:%d:%s", playerCode, score, timestamp, secret)
hash := sha256.Sum256([]byte(data))
// Nehme erste 12 Zeichen des Hex-Hash
return hex.EncodeToString(hash[:])[:12]
}
func (lb *Leaderboard) AddScore(name, code string, score int) (bool, string) {
// Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp // Erstelle eindeutigen Key für diesen Score: PlayerCode + Timestamp
timestamp := time.Now().Unix() timestamp := time.Now().Unix()
uniqueKey := code + "_" + time.Now().Format("20060102_150405") uniqueKey := code + "_" + time.Now().Format("20060102_150405")
// Generiere Proof-Code
proofCode := GenerateProofCode(code, score, timestamp)
// Score speichern // Score speichern
entry := game.LeaderboardEntry{ entry := game.LeaderboardEntry{
PlayerName: name, PlayerName: name,
PlayerCode: code, PlayerCode: code,
Score: score, Score: score,
Timestamp: timestamp, Timestamp: timestamp,
ProofCode: proofCode,
} }
data, _ := json.Marshal(entry) data, _ := json.Marshal(entry)
@@ -61,8 +78,8 @@ func (lb *Leaderboard) AddScore(name, code string, score int) bool {
Member: uniqueKey, Member: uniqueKey,
}) })
log.Printf("🏆 Leaderboard: %s mit %d Punkten (Entry: %s)", name, score, uniqueKey) log.Printf("🏆 Leaderboard: %s mit %d Punkten (Entry: %s, Proof: %s)", name, score, uniqueKey, proofCode)
return true return true, proofCode
} }
func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry { func (lb *Leaderboard) GetTop10() []game.LeaderboardEntry {

View File

@@ -23,6 +23,8 @@ type ServerPlayer struct {
InputX float64 // -1 (Links), 0, 1 (Rechts) InputX float64 // -1 (Links), 0, 1 (Rechts)
LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz LastInputSeq uint32 // Letzte verarbeitete Input-Sequenz
Score int Score int
DistanceScore int // Score basierend auf zurückgelegter Distanz
BonusScore int // Score aus Coins und anderen Boni
IsAlive bool IsAlive bool
IsSpectator bool IsSpectator bool
@@ -68,9 +70,12 @@ type Room struct {
Countdown int Countdown int
NextStart time.Time NextStart time.Time
HostID string HostID string
TeamName string // Name des Teams (vom Host gesetzt)
CollectedCoins map[string]bool // Key: "chunkID_objectIndex" CollectedCoins map[string]bool // Key: "chunkID_objectIndex"
CollectedPowerups map[string]bool // Key: "chunkID_objectIndex" CollectedPowerups map[string]bool // Key: "chunkID_objectIndex"
ScoreAccum float64 // Akkumulator für Distanz-Score ScoreAccum float64 // Akkumulator für Distanz-Score
CurrentSpeed float64 // Aktuelle Geschwindigkeit (steigt mit der Zeit)
GameStartTime time.Time // Wann das Spiel gestartet wurde
// Chunk-Pool für fairen Random-Spawn // Chunk-Pool für fairen Random-Spawn
ChunkPool []string // Verfügbare Chunks für nächsten Spawn ChunkPool []string // Verfügbare Chunks für nächsten Spawn
@@ -100,7 +105,8 @@ func NewRoom(id string, nc *nats.Conn, w *game.World) *Room {
CollectedCoins: make(map[string]bool), CollectedCoins: make(map[string]bool),
CollectedPowerups: make(map[string]bool), CollectedPowerups: make(map[string]bool),
ChunkSpawnedCount: make(map[string]int), ChunkSpawnedCount: make(map[string]int),
pW: 40, pH: 60, // Fallback CurrentSpeed: config.RunSpeed, // Startet mit normaler Geschwindigkeit
pW: 40, pH: 60, // Fallback
} }
// Initialisiere Chunk-Pool mit allen verfügbaren Chunks // Initialisiere Chunk-Pool mit allen verfügbaren Chunks
@@ -281,6 +287,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
if input.PlayerID == r.HostID && r.Status == "LOBBY" { if input.PlayerID == r.HostID && r.Status == "LOBBY" {
r.StartCountdown() r.StartCountdown()
} }
case "SET_TEAM_NAME":
// Nur Host darf Team-Name setzen und nur in der Lobby
if input.PlayerID == r.HostID && r.Status == "LOBBY" {
r.TeamName = input.TeamName
log.Printf("🏷️ Team-Name gesetzt: '%s' (von Host %s)", r.TeamName, p.Name)
}
} }
} }
@@ -301,9 +313,19 @@ func (r *Room) Update() {
r.Countdown = int(rem.Seconds()) + 1 r.Countdown = int(rem.Seconds()) + 1
if rem <= 0 { if rem <= 0 {
r.Status = "RUNNING" r.Status = "RUNNING"
r.GameStartTime = time.Now()
r.CurrentSpeed = config.RunSpeed
} }
} else if r.Status == "RUNNING" { } else if r.Status == "RUNNING" {
r.GlobalScrollX += config.RunSpeed // Geschwindigkeit erhöhen: +0.5 pro 10 Sekunden (max +5.0 nach 100 Sekunden)
elapsed := time.Since(r.GameStartTime).Seconds()
speedIncrease := (elapsed / 10.0) * 0.5
if speedIncrease > 5.0 {
speedIncrease = 5.0
}
r.CurrentSpeed = config.RunSpeed + speedIncrease
r.GlobalScrollX += r.CurrentSpeed
// Bewegende Plattformen updaten // Bewegende Plattformen updaten
r.UpdateMovingPlatforms() r.UpdateMovingPlatforms()
} }
@@ -333,9 +355,9 @@ func (r *Room) Update() {
// X Bewegung // X Bewegung
// Symmetrische Geschwindigkeit: Links = Rechts // Symmetrische Geschwindigkeit: Links = Rechts
// Nach rechts: RunSpeed + 11, Nach links: RunSpeed - 11 // Nach rechts: CurrentSpeed + 11, Nach links: CurrentSpeed - 11
// Ergebnis: Rechts = 18, Links = -4 (beide gleich weit vom Scroll) // Verwendet r.CurrentSpeed statt config.RunSpeed für dynamische Geschwindigkeit
currentSpeed := config.RunSpeed + (p.InputX * 11.0) currentSpeed := r.CurrentSpeed + (p.InputX * 11.0)
nextX := p.X + currentSpeed nextX := p.X + currentSpeed
hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) hitX, typeX := r.CheckCollision(nextX+r.pDrawOffX+r.pHitboxOffX, p.Y+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
@@ -343,7 +365,7 @@ func (r *Room) Update() {
if typeX == "wall" { if typeX == "wall" {
// Wand getroffen - kann klettern! // Wand getroffen - kann klettern!
p.OnWall = true p.OnWall = true
// X-Position nicht ändern (bleibt an der Wand) // X-Position NICHT ändern (bleibt vor der Wand stehen)
} else if typeX == "obstacle" { } else if typeX == "obstacle" {
// Godmode prüfen // Godmode prüfen
if p.HasGodMode && time.Now().Before(p.GodModeEndTime) { if p.HasGodMode && time.Now().Before(p.GodModeEndTime) {
@@ -405,15 +427,9 @@ func (r *Room) Update() {
hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH) hitY, typeY := r.CheckCollision(p.X+r.pDrawOffX+r.pHitboxOffX, nextY+r.pDrawOffY+r.pHitboxOffY, r.pW, r.pH)
if hitY { if hitY {
if typeY == "wall" { if typeY == "wall" {
// An der Wand: Nicht töten, sondern Position halten // An der Wand: Nicht töten, Position halten und klettern ermöglichen
if p.OnWall { p.VY = 0
p.VY = 0 p.OnWall = true
} else {
// Von oben/unten gegen Wand - töten (kein Klettern in Y-Richtung)
p.Y = nextY
r.KillPlayer(p)
continue
}
} else if typeY == "obstacle" { } else if typeY == "obstacle" {
// Obstacle - immer töten // Obstacle - immer töten
p.Y = nextY p.Y = nextY
@@ -774,6 +790,7 @@ func (r *Room) Broadcast() {
TimeLeft: r.Countdown, TimeLeft: r.Countdown,
WorldChunks: r.ActiveChunks, WorldChunks: r.ActiveChunks,
HostID: r.HostID, HostID: r.HostID,
TeamName: r.TeamName,
ScrollX: r.GlobalScrollX, ScrollX: r.GlobalScrollX,
CollectedCoins: r.CollectedCoins, CollectedCoins: r.CollectedCoins,
CollectedPowerups: r.CollectedPowerups, CollectedPowerups: r.CollectedPowerups,

View File

@@ -60,7 +60,8 @@ func (r *Room) CheckCoinCollision(p *ServerPlayer) {
if game.CheckRectCollision(playerHitbox, coinHitbox) { if game.CheckRectCollision(playerHitbox, coinHitbox) {
// Coin einsammeln! // Coin einsammeln!
r.CollectedCoins[coinKey] = true r.CollectedCoins[coinKey] = true
p.Score += 200 p.BonusScore += 200
p.Score = p.DistanceScore + p.BonusScore
log.Printf("💰 %s hat Coin eingesammelt! Score: %d", p.Name, p.Score) log.Printf("💰 %s hat Coin eingesammelt! Score: %d", p.Name, p.Score)
} }
} }
@@ -143,18 +144,28 @@ func (r *Room) UpdateDistanceScore() {
return return
} }
// Jeder Spieler bekommt Punkte basierend auf seiner eigenen Distanz // Zähle lebende Spieler
// Punkte = (X-Position / TileSize) = Distanz in Tiles aliveCount := 0
for _, p := range r.Players { for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator { if p.IsAlive && !p.IsSpectator {
// Berechne Score basierend auf X-Position aliveCount++
// 1 Punkt pro Tile (64px) }
newScore := int(p.X / 64.0) }
// Nur updaten wenn höher als aktueller Score if aliveCount == 0 {
if newScore > p.Score { return
p.Score = newScore }
}
// Pro lebendem Spieler werden Punkte hinzugefügt
// Dies akkumuliert die Punkte: mehr Spieler = schnellere Punktesammlung
// Jeder Tick (bei 60 FPS) fügt aliveCount Punkte hinzu
pointsToAdd := aliveCount
// Jeder lebende Spieler bekommt die gleichen Punkte
for _, p := range r.Players {
if p.IsAlive && !p.IsSpectator {
p.DistanceScore += pointsToAdd
p.Score = p.DistanceScore + p.BonusScore
} }
} }
} }