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 @@ - +
SCHUL-NEWS
@@ -73,7 +73,7 @@