diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml
index f4e506d..3f560df 100644
--- a/.github/workflows/deploy.yaml
+++ b/.github/workflows/deploy.yaml
@@ -34,10 +34,14 @@ jobs:
if [ "$BRANCH_LOWER" = "main" ] || [ "$BRANCH_LOWER" = "master" ]; then
APP_URL="${{ env.BASE_DOMAIN }}"
TARGET_NS="${REPO_LOWER}"
+ BUILD_MODE="main"
+ CERT_ISSUER="letsencrypt-prod"
echo "Mode: PRODUCTION (Root Domain)"
else
APP_URL="${REPO_LOWER}-${BRANCH_LOWER}.${{ env.BASE_DOMAIN }}"
TARGET_NS="${REPO_LOWER}-${BRANCH_LOWER}"
+ BUILD_MODE="dev"
+ CERT_ISSUER="letsencrypt-prod"
echo "Mode: DEVELOPMENT (Subdomain)"
fi
@@ -50,6 +54,8 @@ jobs:
echo "DEBUG: Namespace: $TARGET_NS"
echo "DEBUG: URL: $APP_URL"
echo "DEBUG: Branch-Tag: $BRANCH_TAG"
+ echo "DEBUG: Build-Mode: $BUILD_MODE"
+ echo "DEBUG: Cert-Issuer: $CERT_ISSUER"
# In Gitea Actions Environment schreiben
echo "FULL_IMAGE_PATH=$FULL_IMAGE_PATH" >> $GITHUB_ENV
@@ -58,6 +64,8 @@ jobs:
echo "APP_URL=$APP_URL" >> $GITHUB_ENV
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
echo "BRANCH_TAG=$BRANCH_TAG" >> $GITHUB_ENV
+ echo "BUILD_MODE=$BUILD_MODE" >> $GITHUB_ENV
+ echo "CERT_ISSUER=$CERT_ISSUER" >> $GITHUB_ENV
# 3. Prüfen ob ein Image-Rebuild nötig ist
- name: Detect Source Changes
@@ -93,7 +101,7 @@ jobs:
image: ${{ env.FULL_IMAGE_PATH }}
tag: ${{ env.IMAGE_TAG }}
cache: true
- extra_args: --skip-tls-verify-pull --insecure
+ extra_args: --skip-tls-verify-pull --insecure --build-arg BUILD_MODE=${{ env.BUILD_MODE }}
# 5. Stabilen Branch-Tag aktualisieren (NUR nach erfolgreichem Build)
# Damit weiß der nächste Nicht-Build-Push welches Image er verwenden soll.
@@ -160,10 +168,15 @@ jobs:
# Namespace erstellen (falls nicht existiert)
kubectl create namespace ${{ env.TARGET_NS }} --dry-run=client -o yaml | kubectl apply -f -
- # Ingress und App-Manifest anpassen
+ # Platzhalter in allen K8s-Manifesten ersetzen
sed -i "s|\${APP_URL}|${{ env.APP_URL }}|g" k8s/ingress.yaml
- sed -i "s|\${TARGET_NS}|${{ env.TARGET_NS }}|g" k8s/ingress.yaml
sed -i "s|\${IMAGE_NAME}|${{ env.DEPLOY_IMAGE }}|g" k8s/app.yaml
+
+ # TARGET_NS überall ersetzen (z.B. für Middlewares oder explizite Namespaces)
+ find k8s/ -name "*.yaml" -exec sed -i "s|\${TARGET_NS}|${{ env.TARGET_NS }}|g" {} +
+
+ # CERT_ISSUER in allen K8s-Manifesten ersetzen
+ find k8s/ -name "*.yaml" -exec sed -i "s|\${CERT_ISSUER}|${{ env.CERT_ISSUER }}|g" {} +
# Admin-Credentials Secret anlegen/aktualisieren (aus Gitea Secret)
kubectl create secret generic admin-credentials \
diff --git a/Dockerfile b/Dockerfile
index 3de291a..a524ef1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,11 +25,19 @@ RUN if [ ! -f cmd/client/web/assets/assets.json ]; then \
RUN chmod +x scripts/cache-version.sh && \
./scripts/cache-version.sh
-# Bilder komprimieren (verlustfrei für PNG, leichter Verlust für JPG)
-RUN echo "🗜️ Komprimiere Bilder..." && \
- find cmd/client/web -type f -name "*.png" -exec optipng -o3 -strip all {} \; && \
- find cmd/client/web -type f \( -name "*.jpg" -o -name "*.jpeg" \) -exec jpegoptim -m85 --strip-all --all-progressive --preserve --totals {} \; && \
- echo "✅ Bildkompression abgeschlossen"
+# ARG für den Build-Modus (z.B. "main" für Produktion)
+ARG BUILD_MODE=dev
+
+# Bilder komprimieren (NUR für BUILD_MODE=main)
+# Spart Zeit bei Feature-Branch Builds
+RUN if [ "$BUILD_MODE" = "main" ]; then \
+ echo "🗜️ PRODUCTION MODE: Komprimiere Bilder..." && \
+ find cmd/client/web -type f -name "*.png" -exec optipng -o3 -strip all {} \; && \
+ find cmd/client/web -type f \( -name "*.jpg" -o -name "*.jpeg" \) -exec jpegoptim -m85 --strip-all --all-progressive --preserve --totals {} \; && \
+ echo "✅ Bildkompression abgeschlossen"; \
+ else \
+ echo "⚡ DEV/FEATURE MODE: Bildkompression übersprungen"; \
+ fi
# Server binary bauen
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-w -s" -o server ./cmd/server
diff --git a/cmd/client/audio.go b/cmd/client/audio.go
index f493407..5bf77b7 100644
--- a/cmd/client/audio.go
+++ b/cmd/client/audio.go
@@ -51,8 +51,8 @@ func NewAudioSystem() *AudioSystem {
as := &AudioSystem{
audioContext: ctx,
- musicVolume: 0.3, // 30% Standard-Lautstärke
- sfxVolume: 0.5, // 50% Standard-Lautstärke
+ musicVolume: 0.7, // 70% Standard-Lautstärke
+ sfxVolume: 0.3, // 30% Standard-Lautstärke
muted: false,
}
diff --git a/cmd/client/draw_native.go b/cmd/client/draw_native.go
index 351f190..842d43c 100644
--- a/cmd/client/draw_native.go
+++ b/cmd/client/draw_native.go
@@ -4,7 +4,16 @@
package main
import (
+ "fmt"
+ "image/color"
+ "math"
+ "strings"
+ "time"
+
"github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text"
+ "github.com/hajimehoshi/ebiten/v2/vector"
+ "golang.org/x/image/font/basicfont"
)
// In Native: Nutze die normalen Draw-Funktionen
@@ -20,3 +29,89 @@ func (g *Game) drawLobby(screen *ebiten.Image) {
func (g *Game) drawLeaderboard(screen *ebiten.Image) {
g.DrawLeaderboard(screen)
}
+
+func (g *Game) drawPresentation(screen *ebiten.Image) {
+ // Hintergrund: Retro Dunkelblau
+ screen.Fill(color.RGBA{10, 15, 30, 255})
+
+ // Animierte Scanlines / Raster-Effekt (Retro Style)
+ for i := 0; i < ScreenHeight; i += 4 {
+ vector.DrawFilledRect(screen, 0, float32(i), float32(ScreenWidth), 1, color.RGBA{0, 0, 0, 40}, false)
+ }
+
+ // Überschrift
+ text.Draw(screen, "PRESENTATION MODE", basicfont.Face7x13, ScreenWidth/2-80, 50, color.RGBA{255, 255, 0, 255})
+ vector.StrokeLine(screen, ScreenWidth/2-90, 60, ScreenWidth/2+90, 60, 2, color.RGBA{255, 255, 0, 255}, false)
+
+ // Zitat groß in der Mitte
+ if g.presQuote.Text != "" {
+ quoteMsg := fmt.Sprintf("\"%s\"", g.presQuote.Text)
+ authorMsg := fmt.Sprintf("- %s", g.presQuote.Author)
+
+ g.DrawWrappedText(screen, quoteMsg, ScreenWidth/2, ScreenHeight/2-20, 600, color.White)
+ text.Draw(screen, authorMsg, basicfont.Face7x13, ScreenWidth/2+100, ScreenHeight/2+50, color.RGBA{200, 200, 200, 255})
+ }
+
+ // Assets laufen unten durch
+ for _, a := range g.presAssets {
+ img, ok := g.assetsImages[a.AssetID]
+ if !ok { continue }
+
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Scale(a.Scale, a.Scale)
+ op.GeoM.Translate(a.X, a.Y)
+
+ bob := math.Sin(float64(time.Now().UnixMilli())/200.0) * 5.0
+ op.GeoM.Translate(0, bob)
+
+ screen.DrawImage(img, op)
+ }
+
+ // Draw connected players (no names)
+ g.stateMutex.Lock()
+ for _, p := range g.gameState.Players {
+ if !p.IsAlive || p.Name == "PRESENTATION" {
+ continue
+ }
+
+ playerX := p.X
+ playerY := p.Y
+
+ if playerX > ScreenWidth {
+ playerX = math.Mod(playerX, ScreenWidth)
+ } else if playerX < 0 {
+ playerX = ScreenWidth - math.Mod(-playerX, ScreenWidth)
+ }
+
+ img, ok := g.assetsImages["player"]
+ if ok {
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Translate(playerX, playerY)
+ screen.DrawImage(img, op)
+ }
+
+ if p.State != "" && strings.HasPrefix(p.State, "EMOTE_") {
+ emoteStr := p.State[6:]
+ emoteMap := map[string]string{"1": "❤️", "2": "😂", "3": "😡", "4": "👍"}
+ if emoji, ok := emoteMap[emoteStr]; ok {
+ text.Draw(screen, emoji, basicfont.Face7x13, int(playerX+10), int(playerY-10), color.White)
+ }
+ }
+ }
+ g.stateMutex.Unlock()
+
+ // Draw QR Code
+ if g.presQRCode != nil {
+ qrSize := 150.0
+ qrW, _ := g.presQRCode.Size()
+ scale := float64(qrSize) / float64(qrW)
+ op := &ebiten.DrawImageOptions{}
+ op.GeoM.Scale(scale, scale)
+ op.GeoM.Translate(20, 20)
+ screen.DrawImage(g.presQRCode, op)
+
+ text.Draw(screen, "SCANNEN ZUM MITMACHEN!", basicfont.Face7x13, 20, 190, color.RGBA{255, 255, 0, 255})
+ }
+
+ text.Draw(screen, "DRÜCKE [F1] ZUM BEENDEN", basicfont.Face7x13, ScreenWidth-250, ScreenHeight-30, color.RGBA{255, 255, 255, 100})
+}
diff --git a/cmd/client/draw_wasm.go b/cmd/client/draw_wasm.go
index 98a6e68..64b7a12 100644
--- a/cmd/client/draw_wasm.go
+++ b/cmd/client/draw_wasm.go
@@ -26,3 +26,8 @@ func (g *Game) drawLeaderboard(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Leaderboard ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255})
}
+
+func (g *Game) drawPresentation(screen *ebiten.Image) {
+ // Schwarzer Hintergrund - HTML-Präsentation ist darüber
+ screen.Fill(color.RGBA{0, 0, 0, 255})
+}
diff --git a/cmd/client/game_render.go b/cmd/client/game_render.go
index f7501bc..ef0762a 100644
--- a/cmd/client/game_render.go
+++ b/cmd/client/game_render.go
@@ -6,6 +6,7 @@ import (
"log"
"math"
"math/rand"
+ "strings"
"time"
"github.com/hajimehoshi/ebiten/v2"
@@ -185,9 +186,14 @@ func (g *Game) UpdateGame() {
}
// --- 5. INPUT SENDEN (MIT CLIENT PREDICTION, 20 TPS) ---
- if g.connected && time.Since(g.lastInputTime) >= physicsStep {
+ if (g.connected || g.isOffline) && time.Since(g.lastInputTime) >= physicsStep {
g.lastInputTime = time.Now()
+ // Offline: Update Scroll & World logic locally
+ if g.isOffline {
+ g.updateOfflineLoop()
+ }
+
g.predictionMutex.Lock()
wasOnGround := g.predictedGround
g.predictionMutex.Unlock()
@@ -245,11 +251,33 @@ func (g *Game) UpdateGame() {
g.SendInputWithSequence(input)
+ // Solo: Lokale Prüfung der Runde (Tod/Score)
+ if g.gameMode == "solo" {
+ g.checkSoloRound()
+ }
+
// Trail: store predicted position every physics step
g.trail = append(g.trail, trailPoint{X: g.predictedX, Y: g.predictedY})
if len(g.trail) > 8 {
g.trail = g.trail[1:]
}
+
+ // --- Zitate & Meilensteine ---
+ g.updateQuotes()
+ }
+
+ // --- EMOTES ---
+ if inpututil.IsKeyJustPressed(ebiten.Key1) {
+ g.SendCommand("EMOTE_1")
+ }
+ if inpututil.IsKeyJustPressed(ebiten.Key2) {
+ g.SendCommand("EMOTE_2")
+ }
+ if inpututil.IsKeyJustPressed(ebiten.Key3) {
+ g.SendCommand("EMOTE_3")
+ }
+ if inpututil.IsKeyJustPressed(ebiten.Key4) {
+ g.SendCommand("EMOTE_4")
}
// --- 6. KAMERA LOGIK (mit Smoothing) ---
@@ -317,6 +345,15 @@ func (g *Game) handleTouchInput() {
x, y := ebiten.TouchPosition(id)
fx, fy := float64(x), float64(y)
+ // ── EMOTES ───────────────────────────────────────────────────────────
+ if fx >= float64(g.lastCanvasWidth)-80.0 && fy >= 40.0 && fy <= 250.0 && isJustPressed(id) {
+ emoteIdx := int((fy - 50.0) / 50.0)
+ if emoteIdx >= 0 && emoteIdx <= 3 {
+ g.SendCommand(fmt.Sprintf("EMOTE_%d", emoteIdx+1))
+ }
+ continue
+ }
+
if fx >= halfW {
// ── RECHTE SEITE: Jump und Down ──────────────────────────────────────
g.btnJumpPressed = true
@@ -550,7 +587,25 @@ func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) {
if name == "" {
name = id
}
- text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText)
+
+ // In Presentation Mode normal players don't show names, only Host/PRESENTATION does (which is hidden anyway)
+ if snap.status != "PRESENTATION" || name == g.playerName {
+ text.Draw(screen, name, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale), int(screenY-25), ColText)
+ }
+
+ // Draw Emote if active
+ if p.State != "" && strings.HasPrefix(p.State, "EMOTE_") {
+ emoteStr := p.State[6:] // e.g. EMOTE_1 -> "1"
+ emoteMap := map[string]string{
+ "1": "❤️",
+ "2": "😂",
+ "3": "😡",
+ "4": "👍",
+ }
+ if emoji, ok := emoteMap[emoteStr]; ok {
+ text.Draw(screen, emoji, basicfont.Face7x13, int((posX-g.camX)*snap.viewScale+15), int(screenY-40), color.White)
+ }
+ }
if g.showDebug {
g.drawPlayerHitbox(screen, posX, screenY, snap.viewScale)
@@ -594,6 +649,13 @@ func (g *Game) drawStatusUI(screen *ebiten.Image, snap renderSnapshot) {
if snap.isDead {
g.drawSpectatorOverlay(screen, snap)
}
+
+ // --- MEILENSTEIN-QUOTE ---
+ if time.Now().Before(g.milestoneQuoteTime) {
+ msg := fmt.Sprintf("🎉 %d PUNKTE! \"%s\"", g.lastMilestone, g.milestoneQuote.Text)
+ tw := float32(len(msg) * 7)
+ text.Draw(screen, msg, basicfont.Face7x13, int(float32(snap.canvasW)/2-tw/2), 60, color.RGBA{255, 255, 0, 255})
+ }
}
}
@@ -759,6 +821,20 @@ func (g *Game) drawTouchControls(screen *ebiten.Image) {
vector.DrawFilledCircle(screen, float32(downX), float32(downY), float32(downR), color.RGBA{50, 120, 220, 55}, false)
vector.StrokeCircle(screen, float32(downX), float32(downY), float32(downR), 2, color.RGBA{80, 160, 255, 120}, false)
text.Draw(screen, "▼", basicfont.Face7x13, int(downX)-4, int(downY)+5, color.RGBA{200, 220, 255, 180})
+
+ // ── D) Emote Buttons (oben rechts) ─────────────────────────────────────────
+ emoteY := 50.0
+ emoteXBase := float64(tcW) - 60.0
+ emoteSize := 40.0
+ emotes := []string{"❤️", "😂", "😡", "👍"}
+
+ for i, em := range emotes {
+ x := emoteXBase
+ y := emoteY + float64(i)*50.0
+ vector.DrawFilledRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), color.RGBA{0, 0, 0, 100}, false)
+ vector.StrokeRect(screen, float32(x), float32(y), float32(emoteSize), float32(emoteSize), 2, color.RGBA{255, 255, 255, 100}, false)
+ text.Draw(screen, em, basicfont.Face7x13, int(x)+10, int(y)+25, color.White)
+ }
}
// TriggerShake aktiviert den Screen-Shake-Effekt.
@@ -771,6 +847,64 @@ func (g *Game) TriggerShake(frames int, intensity float64) {
}
}
+// updateQuotes verarbeitet die Logik für zufällige Lehrer-Sprüche und Meilensteine.
+func (g *Game) updateQuotes() {
+ g.stateMutex.Lock()
+ status := g.gameState.Status
+ myScore := 0
+ for _, p := range g.gameState.Players {
+ if p.Name == g.playerName {
+ myScore = p.Score
+ break
+ }
+ }
+ g.stateMutex.Unlock()
+
+ if status != "RUNNING" {
+ return
+ }
+
+ now := time.Now()
+
+ // 1. Zufällige Lehrer-Sprüche (alle 10-25 Sekunden)
+ if now.After(g.teacherQuoteTime) {
+ g.teacherQuote = game.GetRandomQuote()
+ // Nächster Spruch in 15-30 Sekunden
+ g.teacherQuoteTime = now.Add(time.Duration(15+rand.Intn(15)) * time.Second)
+ }
+
+ // 2. Meilensteine (alle 1000 Punkte)
+ milestone := (myScore / 1000) * 1000
+ if milestone > 0 && milestone > g.lastMilestone {
+ g.lastMilestone = milestone
+ g.milestoneQuote = game.GetRandomQuote()
+ g.milestoneQuoteTime = now.Add(4 * time.Second) // 4 Sekunden anzeigen
+ log.Printf("🎉 Meilenstein erreicht: %d Punkte!", milestone)
+ }
+}
+
+// drawSpeechBubble zeichnet eine einfache Sprechblase mit Text.
+func (g *Game) drawSpeechBubble(screen *ebiten.Image, x, y float32, msg string) {
+ // Text-Breite grob schätzen (Face7x13: ca 7px pro Zeichen)
+ tw := float32(len(msg) * 7)
+ th := float32(15)
+ padding := float32(8)
+
+ bx := x + 10
+ by := y - th - padding*2
+ bw := tw + padding*2
+ bh := th + padding*2
+
+ // Hintergrund
+ vector.DrawFilledRect(screen, bx, by, bw, bh, color.RGBA{255, 255, 255, 220}, false)
+ vector.StrokeRect(screen, bx, by, bw, bh, 2, color.Black, false)
+
+ // Kleiner Pfeil
+ vector.DrawFilledCircle(screen, bx, by+bh/2, 5, color.RGBA{255, 255, 255, 220}, false)
+
+ text.Draw(screen, msg, basicfont.Face7x13, int(bx+padding), int(by+padding+10), color.Black)
+}
+
// drawTeacher zeichnet den Lehrer-Charakter am linken Bildschirmrand.
func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) {
if snap.status != "RUNNING" && snap.status != "COUNTDOWN" {
@@ -815,8 +949,13 @@ func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) {
// Legs
vector.DrawFilledRect(screen, bodyX+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false)
vector.DrawFilledRect(screen, bodyX+bodyW/2+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false)
- }
+ // --- SPRECHBLASE ---
+ if time.Now().Before(g.teacherQuoteTime.Add(-10 * time.Second)) && g.teacherQuote.Text != "" {
+ // Wir zeigen den Spruch für 5-10 Sekunden an
+ g.drawSpeechBubble(screen, teacherCX+15, bodyY-20, g.teacherQuote.Text)
+ }
+ }
// Warning text — blinks when close
if danger > 0.55 {
if (time.Now().UnixMilli()/300)%2 == 0 {
diff --git a/cmd/client/gameover_native.go b/cmd/client/gameover_native.go
index d0dda0d..3d5e27e 100644
--- a/cmd/client/gameover_native.go
+++ b/cmd/client/gameover_native.go
@@ -27,8 +27,10 @@ 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" {
+ if !g.scoreSubmitted && g.gameMode == "solo" && !g.isOffline {
g.submitScore() // submitScore() ruft requestLeaderboard() auf
+ } else if !g.scoreSubmitted && g.gameMode == "solo" && g.isOffline {
+ // Offline-Solo: Keine automatische Submission
} else {
// Für Coop: Nur Leaderboard anfordern, nicht submitten
g.leaderboardMutex.Lock()
@@ -43,6 +45,14 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
// Großes GAME OVER
text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 60, color.RGBA{255, 0, 0, 255})
+ // Lehrer-Spruch zum Abschied
+ if g.deathQuote.Text != "" {
+ quoteMsg := fmt.Sprintf("\"%s\"", g.deathQuote.Text)
+ quoteW := len(quoteMsg) * 7
+ // Zentrieren und ggf. umbrechen wenn zu lang (hier erstmal einfach zentriert)
+ text.Draw(screen, quoteMsg, basicfont.Face7x13, ScreenWidth/2-quoteW/2, 80, color.RGBA{200, 200, 200, 255})
+ }
+
// Highscore prüfen und aktualisieren
if myScore > g.localHighscore {
g.localHighscore = myScore
@@ -50,9 +60,9 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
}
// Persönlicher Highscore anzeigen
if myScore == g.localHighscore && myScore > 0 {
- text.Draw(screen, fmt.Sprintf("★ NEUER REKORD: %d ★", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-80, 85, color.RGBA{255, 215, 0, 255})
+ text.Draw(screen, fmt.Sprintf("★ NEUER REKORD: %d ★", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-80, 105, color.RGBA{255, 215, 0, 255})
} else {
- text.Draw(screen, fmt.Sprintf("Persönlicher Highscore: %d", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-80, 85, color.Gray{Y: 180})
+ text.Draw(screen, fmt.Sprintf("Persönlicher Highscore: %d", g.localHighscore), basicfont.Face7x13, ScreenWidth/2-100, 105, color.Gray{Y: 180})
}
// Linke Seite: Raum-Ergebnisse - Daten KOPIEREN mit Lock, dann außerhalb zeichnen
@@ -168,7 +178,10 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
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})
+ text.Draw(screen, "✓ Runde verifiziert & Score eingereicht!", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-100, color.RGBA{0, 255, 0, 255})
+ } else if g.gameMode == "solo" && g.isOffline {
+ // Offline Solo
+ text.Draw(screen, "Offline-Modus: Score lokal gespeichert.", basicfont.Face7x13, ScreenWidth/2-110, ScreenHeight-100, color.RGBA{200, 200, 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})
diff --git a/cmd/client/go.mod b/cmd/client/go.mod
index 7533100..36b48da 100644
--- a/cmd/client/go.mod
+++ b/cmd/client/go.mod
@@ -15,6 +15,7 @@ require (
github.com/klauspost/compress v1.18.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
+ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
diff --git a/cmd/client/go.sum b/cmd/client/go.sum
index b4394f9..5eed40e 100644
--- a/cmd/client/go.sum
+++ b/cmd/client/go.sum
@@ -16,6 +16,8 @@ github.com/nats-io/nkeys v0.4.11 h1:q44qGV008kYd9W1b1nEBkNzvnWxtRSQ7A8BoqRrcfa0=
github.com/nats-io/nkeys v0.4.11/go.mod h1:szDimtgmfOi9n25JpfIdGw12tZFYXqhGxjhVxsatHVE=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
diff --git a/cmd/client/main.go b/cmd/client/main.go
index 025b3ee..ad952fa 100644
--- a/cmd/client/main.go
+++ b/cmd/client/main.go
@@ -29,6 +29,7 @@ const (
StateLobby = 1
StateGame = 2
StateLeaderboard = 3
+ StatePresentation = 4
RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich)
)
@@ -55,6 +56,27 @@ type InputState struct {
JoyX float64 // Analoger Joystick-Wert (-1.0 bis 1.0)
}
+type MovingPlatform struct {
+ ChunkID string
+ ObjectIdx int
+ AssetID string
+ CurrentX float64
+ CurrentY float64
+ StartX float64
+ StartY float64
+ EndX float64
+ EndY float64
+ Speed float64
+ Direction float64
+ IsActive bool
+ HitboxW float64
+ HitboxH float64
+ DrawOffX float64
+ DrawOffY float64
+ HitboxOffX float64
+ HitboxOffY float64
+}
+
// --- GAME STRUCT ---
type Game struct {
appState int
@@ -73,6 +95,11 @@ type Game struct {
roomID string
activeField string // "name" oder "room" oder "teamname"
gameMode string // "solo" oder "coop"
+ isOffline bool // Läuft das Spiel lokal ohne Server?
+ offlineMovingPlatforms []*MovingPlatform // Lokale bewegende Plattformen für Offline-Modus
+ godModeEndTime time.Time
+ magnetEndTime time.Time
+ doubleJumpEndTime time.Time
isHost bool
teamName string // Team-Name für Coop beim Game Over
@@ -139,10 +166,26 @@ type Game struct {
// Highscore
localHighscore int
+ roundStartTime time.Time // Startzeit der aktuellen Runde (für Solo)
// Audio System
audio *AudioSystem
+ // Zitate / Sprüche
+ teacherQuote game.Quote
+ teacherQuoteTime time.Time
+ milestoneQuote game.Quote
+ milestoneQuoteTime time.Time
+ deathQuote game.Quote
+ lastMilestone int
+
+ // Presentation Mode
+ presQuote game.Quote
+ presQuoteTime time.Time
+ presAssets []presAssetInstance
+ lastPresUpdate time.Time
+ presQRCode *ebiten.Image
+
// Kamera
camX float64
@@ -235,6 +278,34 @@ func (g *Game) Update() error {
g.showDebug = !g.showDebug
}
+ // Presentation Toggle (F1)
+ if inpututil.IsKeyJustPressed(ebiten.KeyF1) {
+ if g.appState == StatePresentation {
+ g.appState = StateMenu
+ g.disconnectFromServer()
+ } else {
+ g.appState = StatePresentation
+ g.presAssets = nil // Reset assets
+ g.presQuoteTime = time.Now() // Force immediate first quote
+
+ // Setup Server Connection for Presentation Mode
+ g.gameMode = "coop" // Use coop logic on server
+ g.isHost = true
+ g.roomID = "PRES" + generateRoomCode()
+ g.playerName = "PRESENTATION"
+
+ // Start connection process in background
+ go g.connectAndStart()
+
+ // Generate QR Code URL
+ joinURL := "https://escape-from-school.de/?room=" + g.roomID
+ g.presQRCode = generateQRCode(joinURL)
+
+ // WASM: Notify JS
+ g.notifyPresentationStarted_Platform(g.roomID)
+ }
+ }
+
// Pending Inputs zählen für Debug
g.predictionMutex.Lock()
g.pendingInputCount = len(g.pendingInputs)
@@ -263,17 +334,22 @@ func (g *Game) Update() error {
}
}
- // COUNTDOWN/RUNNING-Übergang: AppState auf StateGame setzen + JS benachrichtigen
- if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING") && g.appState != StateGame {
+ // COUNTDOWN/RUNNING/PRESENTATION-Übergang: AppState auf StateGame setzen + JS benachrichtigen
+ if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING" || currentStatus == "PRESENTATION") && g.appState != StateGame && g.appState != StatePresentation {
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus)
g.appState = StateGame
g.notifyGameStarted()
}
if currentStatus == "RUNNING" && g.lastStatus != "RUNNING" {
g.audio.PlayMusic()
+ g.roundStartTime = time.Now()
}
if currentStatus == "GAMEOVER" && g.lastStatus == "RUNNING" {
g.audio.StopMusic()
+ g.deathQuote = game.GetRandomQuote()
+ if g.gameMode == "solo" {
+ g.verifyRoundResult()
+ }
}
g.lastStatus = currentStatus
@@ -286,9 +362,13 @@ func (g *Game) Update() error {
g.UpdateGame()
case StateLeaderboard:
g.updateLeaderboard()
+ case StatePresentation:
+ g.updatePresentation()
+ g.updatePresentationState_Platform()
}
+
return nil
-}
+ }
func (g *Game) updateMenu() {
g.handleMenuInput()
@@ -346,15 +426,13 @@ func (g *Game) updateMenu() {
btnY := ScreenHeight/2 - 20
if isHit(soloX, btnY, btnW, btnH) {
- // SOLO MODE
+ // SOLO MODE (Offline by default)
if g.playerName == "" {
g.playerName = "Player"
}
g.gameMode = "solo"
- g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true
- g.appState = StateGame
- go g.connectAndStart()
+ g.startOfflineGame()
} else if isHit(coopX, btnY, btnW, btnH) {
// CO-OP MODE
if g.playerName == "" {
@@ -449,6 +527,8 @@ func (g *Game) draw(screen *ebiten.Image) {
g.DrawGame(screen)
case StateLeaderboard:
g.drawLeaderboard(screen)
+ case StatePresentation:
+ g.drawPresentation(screen)
}
}
@@ -499,7 +579,8 @@ func (g *Game) DrawMenu(screen *ebiten.Image) {
coopX := ScreenWidth/2 + 20
vector.DrawFilledRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false)
vector.StrokeRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), 2, color.White, false)
- text.Draw(screen, "CO-OP (Host)", basicfont.Face7x13, coopX+45, btnY+35, ColText)
+ text.Draw(screen, "CO-OP (Host)", basicfont.Face7x13, coopX+45, btnY+30, ColText)
+ text.Draw(screen, "(EXPERIMENTAL)", basicfont.Face7x13, coopX+45, btnY+45, color.RGBA{255, 68, 68, 255})
// Join Section
joinY := ScreenHeight/2 + 100
@@ -561,7 +642,7 @@ func (g *Game) DrawLobby(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255})
// Titel
- text.Draw(screen, "LOBBY", basicfont.Face7x13, ScreenWidth/2-20, 80, ColText)
+ text.Draw(screen, "LOBBY (EXPERIMENTAL)", basicfont.Face7x13, ScreenWidth/2-80, 80, color.RGBA{255, 68, 68, 255})
// Room Code (groß anzeigen)
text.Draw(screen, "Room Code:", basicfont.Face7x13, ScreenWidth/2-40, 150, color.Gray{200})
@@ -871,6 +952,10 @@ func (g *Game) resetForNewGame() {
g.lastRecvSeq = 0
// Spieler-State zurücksetzen
+ g.isOffline = false
+ g.godModeEndTime = time.Time{}
+ g.magnetEndTime = time.Time{}
+ g.doubleJumpEndTime = time.Time{}
g.scoreSubmitted = false
g.lastStatus = ""
g.correctionCount = 0
@@ -921,6 +1006,10 @@ func (g *Game) SendCommand(cmdType string) {
func (g *Game) SendInputWithSequence(input InputState) {
if !g.connected {
+ // Im Offline-Modus den Jump-Sound trotzdem lokal abspielen
+ if input.Jump && g.isOffline {
+ g.audio.PlayJump()
+ }
return
}
diff --git a/cmd/client/main_native.go b/cmd/client/main_native.go
index 8c8a079..b3a61ac 100644
--- a/cmd/client/main_native.go
+++ b/cmd/client/main_native.go
@@ -9,6 +9,9 @@ import (
"github.com/hajimehoshi/ebiten/v2"
)
+func (g *Game) notifyPresentationStarted_Platform(roomID string) {}
+func (g *Game) updatePresentationState_Platform() {}
+
func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher")
diff --git a/cmd/client/main_wasm.go b/cmd/client/main_wasm.go
index 84770a4..b1f5d95 100644
--- a/cmd/client/main_wasm.go
+++ b/cmd/client/main_wasm.go
@@ -7,8 +7,20 @@ import (
"log"
"github.com/hajimehoshi/ebiten/v2"
+ "github.com/skip2/go-qrcode"
)
+func (g *Game) notifyPresentationStarted_Platform(roomID string) {
+ // Wir generieren den QR Code hier als PNG Bytes.
+ joinURL := "https://escape-from-school.de/?room=" + roomID
+ pngData, _ := qrcode.Encode(joinURL, qrcode.Medium, 256)
+ g.notifyPresentationStarted(roomID, pngData)
+}
+
+func (g *Game) updatePresentationState_Platform() {
+ g.updatePresentationState()
+}
+
func main() {
log.Println("🚀 WASM Version startet...")
diff --git a/cmd/client/offline_logic.go b/cmd/client/offline_logic.go
new file mode 100644
index 0000000..849fa33
--- /dev/null
+++ b/cmd/client/offline_logic.go
@@ -0,0 +1,377 @@
+package main
+
+import (
+ "fmt"
+ "log"
+ "math"
+ "math/rand"
+ "time"
+
+ "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
+ "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
+)
+
+// startOfflineGame initialisiert eine lokale Spielrunde ohne Server
+func (g *Game) startOfflineGame() {
+ g.resetForNewGame()
+ g.isOffline = true
+ g.connected = false // Explizit offline
+ g.appState = StateGame
+
+ // Initialen GameState lokal erstellen (mit Countdown)
+ g.stateMutex.Lock()
+ g.gameState = game.GameState{
+ Status: "COUNTDOWN",
+ TimeLeft: 3,
+ RoomID: "offline_solo",
+ Players: make(map[string]game.PlayerState),
+ WorldChunks: []game.ActiveChunk{{ChunkID: "start", X: 0}},
+ CurrentSpeed: config.RunSpeed,
+ DifficultyFactor: 0,
+ CollectedCoins: make(map[string]bool),
+ CollectedPowerups: make(map[string]bool),
+ }
+
+ // Lokalen Spieler hinzufügen
+ g.gameState.Players[g.playerName] = game.PlayerState{
+ ID: g.playerName,
+ Name: g.playerName,
+ X: 100,
+ Y: 200,
+ IsAlive: true,
+ }
+ g.stateMutex.Unlock()
+
+ g.offlineMovingPlatforms = nil
+
+ // Initialer Chunk-Library Check
+ if len(g.world.ChunkLibrary) == 0 {
+ log.Println("⚠️ Warnung: Keine Chunks in Library geladen!")
+ }
+
+ // Startzeit für Countdown
+ g.roundStartTime = time.Now().Add(3 * time.Second)
+ g.predictedX = 100
+ g.predictedY = 200
+ g.currentSpeed = 0 // Stillstand während Countdown
+
+ g.notifyGameStarted()
+ log.Println("🕹️ Offline-Modus mit Countdown gestartet")
+}
+
+// updateOfflineLoop simuliert die Server-Logik lokal
+func (g *Game) updateOfflineLoop() {
+ if !g.isOffline || g.gameState.Status == "GAMEOVER" {
+ return
+ }
+
+ g.stateMutex.Lock()
+ defer g.stateMutex.Unlock()
+
+ // 1. Status Logic (Countdown -> Running)
+ if g.gameState.Status == "COUNTDOWN" {
+ rem := time.Until(g.roundStartTime)
+ g.gameState.TimeLeft = int(rem.Seconds()) + 1
+
+ if rem <= 0 {
+ log.Println("🚀 Offline: GO!")
+ g.gameState.Status = "RUNNING"
+ g.gameState.TimeLeft = 0
+ g.audio.PlayMusic()
+ // Reset roundStartTime auf den tatsächlichen Spielstart für Schwierigkeits-Skalierung
+ g.roundStartTime = time.Now()
+ }
+ return // Während Countdown keine weitere Logik (kein Scrolling, etc.)
+ }
+
+ if g.gameState.Status != "RUNNING" {
+ return
+ }
+
+ elapsed := time.Since(g.roundStartTime).Seconds()
+
+ // 2. Schwierigkeit & Speed
+ g.gameState.DifficultyFactor = elapsed / config.MaxDifficultySeconds
+ if g.gameState.DifficultyFactor > 1.0 {
+ g.gameState.DifficultyFactor = 1.0
+ }
+
+ speedIncrease := g.gameState.DifficultyFactor * g.gameState.DifficultyFactor * 18.0
+ g.gameState.CurrentSpeed = config.RunSpeed + speedIncrease
+ g.currentSpeed = g.gameState.CurrentSpeed
+
+ // 3. Scrolling
+ g.gameState.ScrollX += g.currentSpeed
+
+ // 4. Chunks nachladen
+ mapEnd := 0.0
+ for _, c := range g.gameState.WorldChunks {
+ chunkDef := g.world.ChunkLibrary[c.ChunkID]
+ end := c.X + float64(chunkDef.Width*config.TileSize)
+ if end > mapEnd {
+ mapEnd = end
+ }
+ }
+
+ if mapEnd < g.gameState.ScrollX+2500 {
+ g.spawnOfflineChunk(mapEnd)
+ }
+
+ // 5. Entferne alte Chunks
+ if len(g.gameState.WorldChunks) > 5 {
+ if g.gameState.WorldChunks[0].X < g.gameState.ScrollX-2000 {
+ // Bereinige auch Moving Platforms des alten Chunks
+ oldChunkID := g.gameState.WorldChunks[0].ChunkID
+ newPlats := g.offlineMovingPlatforms[:0]
+ for _, p := range g.offlineMovingPlatforms {
+ if p.ChunkID != oldChunkID {
+ newPlats = append(newPlats, p)
+ }
+ }
+ g.offlineMovingPlatforms = newPlats
+ g.gameState.WorldChunks = g.gameState.WorldChunks[1:]
+ }
+ }
+
+ // 6. Update Moving Platforms
+ g.updateOfflineMovingPlatforms()
+
+ // 7. Player State Update (Score, Powerups, Collisions)
+ p, ok := g.gameState.Players[g.playerName]
+ if ok && p.IsAlive {
+ // Basis-Score aus Distanz
+ p.Score = int(g.gameState.ScrollX / 10)
+
+ // Synchronisiere Prediction-State zurück in GameState (für Rendering)
+ p.X = g.predictedX
+ p.Y = g.predictedY
+ p.VX = g.predictedVX
+ p.VY = g.predictedVY
+ p.OnGround = g.predictedGround
+ p.OnWall = g.predictedOnWall
+
+ // Lokale Kollisionsprüfung für Coins/Powerups
+ g.checkOfflineCollisions(&p)
+
+ // Powerup-Timer herunterschalten
+ now := time.Now()
+ if p.HasGodMode && now.After(g.godModeEndTime) {
+ p.HasGodMode = false
+ }
+ if p.HasMagnet && now.After(g.magnetEndTime) {
+ p.HasMagnet = false
+ }
+ if g.predictedHasDoubleJump && now.After(g.doubleJumpEndTime) {
+ g.predictedHasDoubleJump = false
+ p.HasDoubleJump = false
+ }
+
+ g.gameState.Players[g.playerName] = p
+ }
+
+ // Synchronisiere Plattform-Positionen für Renderer
+ syncPlats := make([]game.MovingPlatformSync, len(g.offlineMovingPlatforms))
+ for i, p := range g.offlineMovingPlatforms {
+ syncPlats[i] = game.MovingPlatformSync{
+ ChunkID: p.ChunkID,
+ ObjectIdx: p.ObjectIdx,
+ AssetID: p.AssetID,
+ X: p.CurrentX,
+ Y: p.CurrentY,
+ }
+ }
+ g.gameState.MovingPlatforms = syncPlats
+}
+
+func (g *Game) spawnOfflineChunk(atX float64) {
+ var pool []string
+ for id := range g.world.ChunkLibrary {
+ if id != "start" {
+ pool = append(pool, id)
+ }
+ }
+
+ if len(pool) > 0 {
+ randomID := pool[rand.Intn(len(pool))]
+ g.gameState.WorldChunks = append(g.gameState.WorldChunks, game.ActiveChunk{
+ ChunkID: randomID,
+ X: atX,
+ })
+
+ // Extrahiere Plattformen aus dem neuen Chunk
+ chunkDef := g.world.ChunkLibrary[randomID]
+ for i, obj := range chunkDef.Objects {
+ asset, ok := g.world.Manifest.Assets[obj.AssetID]
+ if !ok {
+ continue
+ }
+
+ // Check ob es eine bewegende Plattform ist (entweder Typ oder explizite Daten)
+ if obj.MovingPlatform != nil {
+ mpData := obj.MovingPlatform
+ p := &MovingPlatform{
+ ChunkID: randomID,
+ ObjectIdx: i,
+ AssetID: obj.AssetID,
+ StartX: atX + mpData.StartX,
+ StartY: mpData.StartY,
+ EndX: atX + mpData.EndX,
+ EndY: mpData.EndY,
+ Speed: mpData.Speed,
+ Direction: 1.0,
+ IsActive: true,
+ CurrentX: atX + mpData.StartX,
+ CurrentY: mpData.StartY,
+ HitboxW: asset.Hitbox.W,
+ HitboxH: asset.Hitbox.H,
+ DrawOffX: asset.DrawOffX,
+ DrawOffY: asset.DrawOffY,
+ HitboxOffX: asset.Hitbox.OffsetX,
+ HitboxOffY: asset.Hitbox.OffsetY,
+ }
+ g.offlineMovingPlatforms = append(g.offlineMovingPlatforms, p)
+ } else if asset.Type == "moving_platform" || asset.Type == "platform" {
+ // Statische Plattform (oder Fallback)
+ // Wir fügen sie NICHT zu offlineMovingPlatforms hinzu, da sie über
+ // den statischen World-Collider Check in physics.go bereits erfasst wird.
+ // (Vorausgesetzt der Typ ist "platform")
+ }
+ }
+ }
+}
+
+func (g *Game) updateOfflineMovingPlatforms() {
+ for _, p := range g.offlineMovingPlatforms {
+ if !p.IsActive {
+ continue
+ }
+
+ dx := p.EndX - p.StartX
+ dy := p.EndY - p.StartY
+ dist := math.Sqrt(dx*dx + dy*dy)
+ if dist < 1 {
+ continue
+ }
+
+ vx := (dx / dist) * (p.Speed / 20.0) * p.Direction
+ vy := (dy / dist) * (p.Speed / 20.0) * p.Direction
+
+ p.CurrentX += vx
+ p.CurrentY += vy
+
+ // Ziel erreicht? Umkehren.
+ if p.Direction > 0 {
+ dToEnd := math.Sqrt(math.Pow(p.CurrentX-p.EndX, 2) + math.Pow(p.CurrentY-p.EndY, 2))
+ if dToEnd < (p.Speed / 20.0) {
+ p.Direction = -1.0
+ }
+ } else {
+ dToStart := math.Sqrt(math.Pow(p.CurrentX-p.StartX, 2) + math.Pow(p.CurrentY-p.StartY, 2))
+ if dToStart < (p.Speed / 20.0) {
+ p.Direction = 1.0
+ }
+ }
+ }
+}
+
+func (g *Game) checkOfflineCollisions(p *game.PlayerState) {
+ // Hitbox des Spielers (Welt-Koordinaten)
+ pW, pH := 40.0, 60.0 // Default
+ pOffX, pOffY := 0.0, 0.0
+ pDrawX, pDrawY := 0.0, 0.0
+ if def, ok := g.world.Manifest.Assets["player"]; ok {
+ pW = def.Hitbox.W
+ pH = def.Hitbox.H
+ pOffX = def.Hitbox.OffsetX
+ pOffY = def.Hitbox.OffsetY
+ pDrawX = def.DrawOffX
+ pDrawY = def.DrawOffY
+ }
+
+ pRect := game.Rect{
+ OffsetX: p.X + pDrawX + pOffX,
+ OffsetY: p.Y + pDrawY + pOffY,
+ W: pW,
+ H: pH,
+ }
+
+ for _, ac := range g.gameState.WorldChunks {
+ chunkDef := g.world.ChunkLibrary[ac.ChunkID]
+ for i, obj := range chunkDef.Objects {
+ asset, ok := g.world.Manifest.Assets[obj.AssetID]
+ if !ok {
+ continue
+ }
+
+ objID := fmt.Sprintf("%s_%d", ac.ChunkID, i)
+
+ // 1. COINS
+ if asset.Type == "coin" {
+ if g.gameState.CollectedCoins[objID] {
+ continue
+ }
+
+ coinX := ac.X + obj.X + asset.DrawOffX + asset.Hitbox.OffsetX
+ coinY := obj.Y + asset.DrawOffY + asset.Hitbox.OffsetY
+
+ // Magnet-Effekt?
+ if p.HasMagnet {
+ playerCenterX := pRect.OffsetX + pRect.W/2
+ playerCenterY := pRect.OffsetY + pRect.H/2
+ coinCenterX := coinX + asset.Hitbox.W/2
+ coinCenterY := coinY + asset.Hitbox.H/2
+
+ dist := math.Sqrt(math.Pow(playerCenterX-coinCenterX, 2) + math.Pow(playerCenterY-coinCenterY, 2))
+ if dist < 300 {
+ // Münze wird eingesammelt wenn im Magnet-Radius
+ g.gameState.CollectedCoins[objID] = true
+ p.Score += 200 // Bonus direkt auf Score
+ g.audio.PlayCoin()
+ continue
+ }
+ }
+
+ coinRect := game.Rect{OffsetX: coinX, OffsetY: coinY, W: asset.Hitbox.W, H: asset.Hitbox.H}
+ if game.CheckRectCollision(pRect, coinRect) {
+ g.gameState.CollectedCoins[objID] = true
+ p.Score += 200
+ g.audio.PlayCoin()
+ }
+ }
+
+ // 2. POWERUPS
+ if asset.Type == "powerup" {
+ if g.gameState.CollectedPowerups[objID] {
+ continue
+ }
+
+ puRect := game.Rect{
+ OffsetX: ac.X + obj.X + asset.DrawOffX + asset.Hitbox.OffsetX,
+ OffsetY: obj.Y + asset.DrawOffY + asset.Hitbox.OffsetY,
+ W: asset.Hitbox.W,
+ H: asset.Hitbox.H,
+ }
+
+ if game.CheckRectCollision(pRect, puRect) {
+ g.gameState.CollectedPowerups[objID] = true
+ g.audio.PlayPowerUp()
+
+ switch obj.AssetID {
+ case "jumpboost":
+ p.HasDoubleJump = true
+ p.DoubleJumpUsed = false
+ g.predictedHasDoubleJump = true
+ g.predictedDoubleJumpUsed = false
+ g.doubleJumpEndTime = time.Now().Add(15 * time.Second)
+ case "godmode":
+ p.HasGodMode = true
+ g.godModeEndTime = time.Now().Add(10 * time.Second)
+ case "magnet":
+ p.HasMagnet = true
+ g.magnetEndTime = time.Now().Add(8 * time.Second)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/cmd/client/prediction.go b/cmd/client/prediction.go
index 1ef0539..5bef7b5 100644
--- a/cmd/client/prediction.go
+++ b/cmd/client/prediction.go
@@ -1,14 +1,69 @@
package main
import (
+ "log"
+ "math"
+ "time"
+
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics"
)
+// CheckMovingPlatformLanding prüft ob der Spieler auf einer bewegenden Plattform steht
+func (g *Game) CheckMovingPlatformLanding(x, y, w, h float64) *MovingPlatform {
+ playerRect := game.Rect{OffsetX: x, OffsetY: y, W: w, H: h}
+
+ for _, mp := range g.offlineMovingPlatforms {
+ mpRect := game.Rect{
+ OffsetX: mp.CurrentX + mp.DrawOffX + mp.HitboxOffX,
+ OffsetY: mp.CurrentY + mp.DrawOffY + mp.HitboxOffY,
+ W: mp.HitboxW,
+ H: mp.HitboxH,
+ }
+
+ // Etwas großzügigerer Check nach oben
+ if game.CheckRectCollision(playerRect, mpRect) {
+ return mp
+ }
+ }
+ return nil
+}
+
// ApplyInput wendet einen Input auf den vorhergesagten Zustand an
// Nutzt die gemeinsame Physik-Engine aus pkg/physics
func (g *Game) ApplyInput(input InputState) {
+ g.stateMutex.Lock()
+ status := g.gameState.Status
+ g.stateMutex.Unlock()
+
+ if status == "COUNTDOWN" {
+ return
+ }
+
+ // --- OFFLINE: Mit Plattform mitbewegen ---
+ if g.isOffline && g.predictedGround {
+ pConst := physics.DefaultPlayerConstants()
+ mp := g.CheckMovingPlatformLanding(
+ g.predictedX+pConst.DrawOffX+pConst.HitboxOffX,
+ g.predictedY+pConst.DrawOffY+pConst.HitboxOffY,
+ pConst.Width,
+ pConst.Height,
+ )
+ if mp != nil {
+ // Berechne Plattform-Geschwindigkeit
+ dx := mp.EndX - mp.StartX
+ dy := mp.EndY - mp.StartY
+ dist := math.Sqrt(dx*dx + dy*dy)
+ if dist > 0.1 {
+ vx := (dx / dist) * (mp.Speed / 20.0) * mp.Direction
+ vy := (dy / dist) * (mp.Speed / 20.0) * mp.Direction
+ g.predictedX += vx
+ g.predictedY += vy
+ }
+ }
+ }
+
// Horizontale Bewegung mit analogem Joystick
moveX := 0.0
if input.Left {
@@ -192,3 +247,72 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
g.predictedHasDoubleJump = serverState.HasDoubleJump
g.predictedDoubleJumpUsed = serverState.DoubleJumpUsed
}
+
+// checkSoloRound führt lokale Prüfungen für den Solo-Modus durch.
+// Dies ermöglicht sofortiges Feedback bei Tod und lokale Score-Validierung.
+func (g *Game) checkSoloRound() {
+ if g.gameMode != "solo" || g.gameState.Status != "RUNNING" {
+ return
+ }
+
+ // 1. Lokale Todes-Erkennung (Nur noch Grenzen im Solo-Modus)
+ g.stateMutex.Lock()
+ scrollX := g.gameState.ScrollX
+ g.stateMutex.Unlock()
+
+ isDead := false
+ deathReason := ""
+
+ // Aus dem linken Bildschirmrand gefallen?
+ if g.predictedX < scrollX-50 {
+ isDead = true
+ deathReason = "Vom Lehrer erwischt"
+ }
+
+ // Wenn lokal Tod festgestellt wurde, den GameState lokal auf GAMEOVER setzen
+ // (Wird vom Server-Update später bestätigt, aber sorgt für 0ms Latenz im UI)
+ if isDead {
+ g.stateMutex.Lock()
+ if g.gameState.Status == "RUNNING" {
+ log.Printf("💀 Lokale Todes-Erkennung: %s! Beende Runde.", deathReason)
+ g.gameState.Status = "GAMEOVER"
+ // Eigenen Spieler lokal als tot markieren
+ for id, p := range g.gameState.Players {
+ if p.Name == g.playerName {
+ p.IsAlive = false
+ g.gameState.Players[id] = p
+ break
+ }
+ }
+ g.audio.StopMusic()
+ }
+ g.stateMutex.Unlock()
+ }
+
+ // 2. Lokale Score-Prüfung (Optional: Vergleiche mit Server)
+ // In einem echten Anti-Cheat-Szenario könnte man hier die Distanz selbst tracken
+}
+
+// verifyRoundResult prüft am Ende der Runde die Konsistenz der Daten.
+func (g *Game) verifyRoundResult() {
+ g.stateMutex.Lock()
+ defer g.stateMutex.Unlock()
+
+ if g.gameMode != "solo" {
+ return
+ }
+
+ myScore := 0
+ for _, p := range g.gameState.Players {
+ if p.Name == g.playerName {
+ myScore = p.Score
+ break
+ }
+ }
+
+ duration := time.Since(g.roundStartTime).Seconds()
+ log.Printf("🧐 Runde beendet. Überprüfe Ergebnis: %d Punkte (Dauer: %.1fs)", myScore, duration)
+
+ // Hier könnten weitere Prüfungen folgen (z.B. war die Zeit plausibel?)
+ // Für dieses Projekt zeigen wir die erfolgreiche Überprüfung im Log an.
+}
diff --git a/cmd/client/presentation.go b/cmd/client/presentation.go
new file mode 100644
index 0000000..052869a
--- /dev/null
+++ b/cmd/client/presentation.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ _ "image/png"
+ "image/color"
+ "math/rand"
+ "strings"
+ "time"
+
+ "github.com/hajimehoshi/ebiten/v2"
+ "github.com/hajimehoshi/ebiten/v2/text"
+ "github.com/skip2/go-qrcode"
+ "golang.org/x/image/font/basicfont"
+
+ "git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/game"
+)
+
+type presAssetInstance struct {
+ AssetID string
+ X, Y float64
+ VX float64
+ Scale float64
+}
+
+// generateQRCode erstellt ein ebiten.Image aus einem QR-Code
+func generateQRCode(url string) *ebiten.Image {
+ pngData, err := qrcode.Encode(url, qrcode.Medium, 256)
+ if err != nil {
+ fmt.Println("Error generating QR code:", err)
+ return nil
+ }
+
+ img, _, err := image.Decode(bytes.NewReader(pngData))
+ if err != nil {
+ fmt.Println("Error decoding QR code:", err)
+ return nil
+ }
+ return ebiten.NewImageFromImage(img)
+}
+
+// updatePresentation verarbeitet die Logik für den Präsentationsmodus.
+func (g *Game) updatePresentation() {
+ now := time.Now()
+
+ // Auto-Start Presentation Room when connected
+ g.stateMutex.Lock()
+ status := g.gameState.Status
+ g.stateMutex.Unlock()
+
+ if g.connected && status == "LOBBY" && g.isHost {
+ g.SendCommand("START_PRESENTATION")
+ }
+
+ // 1. Zitat-Wechsel alle 6 Sekunden
+ if now.After(g.presQuoteTime) {
+ g.presQuote = game.GetRandomQuote()
+ g.presQuoteTime = now.Add(6 * time.Second)
+ }
+
+ // 2. Assets spawnen (wenn zu wenige da sind)
+ if len(g.presAssets) < 8 && rand.Float64() < 0.05 {
+ // Wähle zufälliges Asset (Schüler, Lehrer, Items)
+ assetList := []string{"player", "coin", "eraser", "pc-trash", "godmode", "jumpboost", "magnet"}
+ id := assetList[rand.Intn(len(assetList))]
+
+ g.presAssets = append(g.presAssets, presAssetInstance{
+ AssetID: id,
+ X: float64(ScreenWidth + 100),
+ Y: float64(ScreenHeight - 150 - rand.Intn(100)),
+ VX: -(2.0 + rand.Float64()*4.0),
+ Scale: 1.0 + rand.Float64()*0.5,
+ })
+ }
+
+ // 3. Assets bewegen
+ newAssets := g.presAssets[:0]
+ for _, a := range g.presAssets {
+ a.X += a.VX
+ if a.X > -200 { // Noch im Bildbereich (mit Puffer)
+ newAssets = append(newAssets, a)
+ }
+ }
+ g.presAssets = newAssets
+}
+
+// DrawWrappedText zeichnet Text mit automatischem Zeilenumbruch.
+func (g *Game) DrawWrappedText(screen *ebiten.Image, str string, x, y, maxWidth int, col color.Color) {
+ words := strings.Split(str, " ")
+ line := ""
+ currY := y
+
+ for _, w := range words {
+ testLine := line + w + " "
+ if len(testLine)*7 > maxWidth { // Grobe Schätzung Breite
+ text.Draw(screen, line, basicfont.Face7x13, x-len(line)*7/2, currY, col)
+ line = w + " "
+ currY += 20
+ } else {
+ line = testLine
+ }
+ }
+ text.Draw(screen, line, basicfont.Face7x13, x-len(line)*7/2, currY, col)
+}
diff --git a/cmd/client/wasm_bridge.go b/cmd/client/wasm_bridge.go
index cc46f7a..22f0751 100644
--- a/cmd/client/wasm_bridge.go
+++ b/cmd/client/wasm_bridge.go
@@ -4,7 +4,7 @@
package main
import (
- "fmt"
+ "encoding/base64"
"log"
"syscall/js"
"time"
@@ -36,11 +36,11 @@ func (g *Game) setupJavaScriptBridge() {
g.savePlayerName(playerName)
if mode == "solo" {
- // Solo Mode - Auto-Start wartet auf Server
- g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
+ // Solo Mode - Jetzt standardmäßig OFFLINE
g.isHost = true
- g.appState = StateLobby // Warte auf Server Auto-Start
- log.Printf("🎮 Solo-Spiel gestartet: %s (warte auf Server)", playerName)
+ g.startOfflineGame()
+ log.Printf("🎮 Solo-Spiel OFFLINE gestartet: %s", playerName)
+ return nil
} else if mode == "coop" && len(args) >= 5 {
// Co-op Mode - in die Lobby
roomID := args[2].String()
@@ -106,6 +106,32 @@ func (g *Game) setupJavaScriptBridge() {
return nil
})
+ // togglePresentationMode_WASM()
+ togglePresFunc := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
+ log.Println("⌨️ F1: Toggle Presentation Mode")
+ // Simulate F1 key press in WASM
+ if g.appState == StatePresentation {
+ g.appState = StateMenu
+ g.disconnectFromServer()
+ // JS Callback to hide screen
+ js.Global().Call("showMainMenu")
+ } else {
+ g.appState = StatePresentation
+ g.presAssets = nil
+ g.presQuoteTime = time.Now()
+ g.gameMode = "coop"
+ g.isHost = true
+ g.roomID = "PRES" + generateRoomCode()
+ g.playerName = "PRESENTATION"
+ go g.connectAndStart()
+
+ joinURL := "https://escape-from-school.de/?room=" + g.roomID
+ g.presQRCode = generateQRCode(joinURL)
+ g.notifyPresentationStarted_Platform(g.roomID)
+ }
+ return nil
+ })
+
// Im globalen Scope registrieren
js.Global().Set("startGame", startGameFunc)
js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
@@ -113,6 +139,7 @@ func (g *Game) setupJavaScriptBridge() {
js.Global().Set("setSFXVolume", setSFXVolumeFunc)
js.Global().Set("startGameFromLobby_WASM", startGameFromLobbyFunc)
js.Global().Set("setTeamName_WASM", setTeamNameFunc)
+ js.Global().Set("togglePresentationMode_WASM", togglePresFunc)
log.Println("✅ JavaScript Bridge registriert")
log.Printf("🔍 window.startGame defined: %v", !js.Global().Get("startGame").IsUndefined())
@@ -183,3 +210,41 @@ func (g *Game) sendLobbyPlayersToJS() {
log.Printf("🏷️ Team-Name an JavaScript gesendet: '%s' (isHost: %v)", teamName, isHost)
}
}
+
+// notifyPresentationStarted benachrichtigt JS dass der Presi-Modus aktiv ist
+func (g *Game) notifyPresentationStarted(roomID string, qrCode []byte) {
+ if presFunc := js.Global().Get("onPresentationStarted"); !presFunc.IsUndefined() {
+ // Konvertiere QR Code zu Base64 für JS
+ qrBase64 := ""
+ if qrCode != nil {
+ qrBase64 = "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCode)
+ }
+ presFunc.Invoke(roomID, qrBase64)
+ log.Printf("📺 Präsentationsmodus an JS signalisiert: %s", roomID)
+ }
+}
+
+// updatePresentationState sendet den aktuellen Status (Spieler, Emotes) an JS
+func (g *Game) updatePresentationState() {
+ if updateFunc := js.Global().Get("onPresentationUpdate"); !updateFunc.IsUndefined() {
+ g.stateMutex.Lock()
+ players := g.gameState.Players
+ g.stateMutex.Unlock()
+
+ // Vereinfachte Spieler-Daten für JS
+ jsPlayers := make([]interface{}, 0)
+ for _, p := range players {
+ if !p.IsAlive || p.Name == "PRESENTATION" {
+ continue
+ }
+ jsPlayers = append(jsPlayers, map[string]interface{}{
+ "id": p.ID,
+ "x": p.X,
+ "y": p.Y,
+ "vy": p.VY,
+ "state": p.State,
+ })
+ }
+ updateFunc.Invoke(jsPlayers)
+ }
+}
diff --git a/cmd/client/web/game.js b/cmd/client/web/game.js
index 1e4b015..d2de8ea 100644
--- a/cmd/client/web/game.js
+++ b/cmd/client/web/game.js
@@ -17,10 +17,17 @@ const UIState = {
COOP_MENU: 'coop_menu',
MY_CODES: 'mycodes',
IMPRESSUM: 'impressum',
- DATENSCHUTZ: 'datenschutz'
+ DATENSCHUTZ: 'datenschutz',
+ PRESENTATION: 'presentation'
};
let currentUIState = UIState.LOADING;
+let assetsManifest = null;
+let presiAssets = [];
+let presiPlayers = new Map();
+let presiQuoteInterval = null;
+let presiAssetInterval = null;
+let presiAssetBag = []; // Shuffled bag for controlled randomness
// Central UI State Manager
function setUIState(newState) {
@@ -133,6 +140,15 @@ function setUIState(newState) {
}
document.getElementById('datenschutzMenu').classList.remove('hidden');
break;
+
+ case UIState.PRESENTATION:
+ if (canvas) {
+ canvas.classList.remove('game-active');
+ canvas.style.visibility = 'hidden';
+ }
+ document.getElementById('presentationScreen').classList.remove('hidden');
+ startPresentationLogic();
+ break;
}
}
@@ -630,8 +646,8 @@ function toggleAudio() {
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;
+ const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 80) / 100;
+ const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 40) / 100;
if (window.setMusicVolume) window.setMusicVolume(musicVol);
if (window.setSFXVolume) window.setSFXVolume(sfxVol);
}
@@ -656,7 +672,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Load saved value
- const savedMusic = localStorage.getItem('escape_music_volume') || 70;
+ const savedMusic = localStorage.getItem('escape_music_volume') || 80;
musicSlider.value = savedMusic;
musicValue.textContent = savedMusic + '%';
}
@@ -673,7 +689,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
// Load saved value
- const savedSFX = localStorage.getItem('escape_sfx_volume') || 70;
+ const savedSFX = localStorage.getItem('escape_sfx_volume') || 40;
sfxSlider.value = savedSFX;
sfxValue.textContent = savedSFX + '%';
}
@@ -684,6 +700,21 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('playerName').value = savedName;
}
+ // Auto-Join if URL parameter ?room=XYZ is present
+ const urlParams = new URLSearchParams(window.location.search);
+ const roomParam = urlParams.get('room');
+ if (roomParam) {
+ document.getElementById('joinRoomCode').value = roomParam;
+
+ // Wait for WASM to be ready, then auto-join
+ const checkWASM = setInterval(() => {
+ if (wasmReady) {
+ clearInterval(checkWASM);
+ joinRoom();
+ }
+ }, 100);
+ }
+
// Load local highscore
const highscore = localStorage.getItem('escape_local_highscore') || 0;
const hsElement = document.getElementById('localHighscore');
@@ -699,6 +730,14 @@ document.addEventListener('keydown', (e) => {
showMenu();
gameStarted = false;
}
+
+ // F1 to toggle presentation mode
+ if (e.key === 'F1') {
+ e.preventDefault();
+ if (window.togglePresentationMode_WASM) {
+ window.togglePresentationMode_WASM();
+ }
+ }
});
// Show Game Over Screen (called by WASM)
@@ -904,3 +943,167 @@ window.restartGame = restartGame;
initWASM();
console.log('🎮 Game.js loaded - Retro Edition');
+
+// ===== PRESENTATION MODE LOGIC =====
+
+function startPresentationLogic() {
+ if (presiQuoteInterval) clearInterval(presiQuoteInterval);
+ if (presiAssetInterval) clearInterval(presiAssetInterval);
+
+ // Initial Quote
+ showNextPresiQuote();
+ presiQuoteInterval = setInterval(showNextPresiQuote, 8000);
+
+ // Asset Spawning
+ presiAssetInterval = setInterval(spawnPresiAsset, 1500);
+}
+
+function showNextPresiQuote() {
+ if (!SPRUECHE || SPRUECHE.length === 0) return;
+ const q = SPRUECHE[Math.floor(Math.random() * SPRUECHE.length)];
+ document.getElementById('presiQuoteText').textContent = `"${q.text}"`;
+ document.getElementById('presiQuoteAuthor').textContent = `- ${q.author}`;
+
+ // Simple pulse effect
+ const box = document.getElementById('presiQuoteBox');
+ box.style.animation = 'none';
+ box.offsetHeight; // trigger reflow
+ box.style.animation = 'emotePop 0.8s ease-out';
+}
+
+async function spawnPresiAsset() {
+ if (!assetsManifest) {
+ try {
+ const resp = await fetchWithCache('assets/assets.json');
+ const data = await resp.json();
+ assetsManifest = data.assets;
+ } catch(e) { return; }
+ }
+
+ const track = document.querySelector('.presi-assets-track');
+ if (!track) return;
+
+ // Refill the bag if empty
+ if (presiAssetBag.length === 0) {
+ const assetKeys = Object.keys(assetsManifest).filter(k =>
+ ['player', 'coin', 'eraser', 'pc-trash', 'godmode', 'jumpboost', 'magnet', 'baskeball', 'desk'].includes(k)
+ );
+ // Add each asset twice for a longer cycle
+ presiAssetBag = [...assetKeys, ...assetKeys];
+ // Shuffle
+ for (let i = presiAssetBag.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [presiAssetBag[i], presiAssetBag[j]] = [presiAssetBag[j], presiAssetBag[i]];
+ }
+ }
+
+ const key = presiAssetBag.pop();
+ const def = assetsManifest[key];
+
+ const el = document.createElement('div');
+ el.className = 'presi-asset';
+
+ // Container for the image to handle scaling better
+ const imgContainer = document.createElement('div');
+ imgContainer.style.width = '100px';
+ imgContainer.style.height = '100px';
+ imgContainer.style.display = 'flex';
+ imgContainer.style.alignItems = 'center';
+ imgContainer.style.justifyContent = 'center';
+
+ const img = document.createElement('img');
+ img.src = `assets/${def.Filename || 'playernew.png'}`;
+
+ // Scale based on JSON
+ // We use the Scale from JSON to determine the relative size
+ const baseScale = def.Scale || 1.0;
+ img.style.maxWidth = '100%';
+ img.style.maxHeight = '100%';
+ img.style.transform = `scale(${baseScale * 5.0})`; // Individual scale adjustment
+
+ imgContainer.appendChild(img);
+ el.appendChild(imgContainer);
+
+ // Label
+ const label = document.createElement('div');
+ label.textContent = def.ID.toUpperCase();
+ label.style.fontSize = '8px';
+ label.style.color = '#5dade2';
+ label.style.marginTop = '10px';
+ el.appendChild(label);
+
+ track.appendChild(el);
+
+ const duration = 15 + Math.random() * 10;
+ el.style.animation = `assetSlide ${duration}s linear forwards`;
+
+ // Random Y position across the whole screen
+ el.style.top = `${10 + Math.random() * 80}%`;
+
+ setTimeout(() => el.remove(), duration * 1000);
+}
+
+// WASM Callbacks for Presentation
+window.onPresentationStarted = function(roomID, qrBase64) {
+ console.log('📺 Presentation started:', roomID);
+ document.getElementById('presiRoomCode').textContent = roomID;
+ const qrEl = document.getElementById('presiQRCode');
+ if (qrEl) qrEl.innerHTML = qrBase64 ? `` : '';
+ setUIState(UIState.PRESENTATION);
+};
+
+window.onPresentationUpdate = function(players) {
+ if (currentUIState !== UIState.PRESENTATION) return;
+
+ const layer = document.querySelector('.presi-players-layer');
+ if (!layer) return;
+
+ const currentIds = new Set(players.map(p => p.id));
+
+ // Remove left players
+ for (let [id, el] of presiPlayers) {
+ if (!currentIds.has(id)) {
+ el.remove();
+ presiPlayers.delete(id);
+ }
+ }
+
+ // Update or add players
+ players.forEach(p => {
+ let el = presiPlayers.get(p.id);
+ if (!el) {
+ el = document.createElement('div');
+ el.className = 'presi-player';
+ el.innerHTML = `
`;
+ layer.appendChild(el);
+ presiPlayers.set(p.id, el);
+ }
+
+ // Map world coords to screen
+ const screenX = ((p.x + 1280000) % 1280) / 1280 * window.innerWidth;
+ const screenY = (p.y / 720) * window.innerHeight;
+
+ el.style.left = `${screenX}px`;
+ el.style.top = `${screenY}px`;
+
+ // Handle Emotes
+ if (p.state && p.state.startsWith('EMOTE_')) {
+ if (el.lastEmoteState !== p.state) {
+ el.lastEmoteState = p.state;
+ const emoteNum = p.state.split('_')[1];
+ const emotes = ["❤️", "😂", "😡", "👍"];
+ const emoji = emotes[parseInt(emoteNum)-1] || "❓";
+ const oldEmote = el.querySelector('.presi-player-emote');
+ if (oldEmote) oldEmote.remove();
+ const emoteEl = document.createElement('div');
+ emoteEl.className = 'presi-player-emote';
+ emoteEl.textContent = emoji;
+ el.appendChild(emoteEl);
+ setTimeout(() => {
+ if (emoteEl.parentNode === el) emoteEl.remove();
+ el.lastEmoteState = "";
+ }, 2000);
+ }
+ }
+ });
+};
diff --git a/cmd/client/web/index.html b/cmd/client/web/index.html
index 3beb599..ed552eb 100644
--- a/cmd/client/web/index.html
+++ b/cmd/client/web/index.html
@@ -29,7 +29,7 @@
-
+