Private
Public Access
1
0

Merge pull request 'test_solo' (#1) from test_solo into main
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m29s

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-04-22 22:07:50 +00:00
25 changed files with 1645 additions and 46 deletions

View File

@@ -34,10 +34,14 @@ jobs:
if [ "$BRANCH_LOWER" = "main" ] || [ "$BRANCH_LOWER" = "master" ]; then if [ "$BRANCH_LOWER" = "main" ] || [ "$BRANCH_LOWER" = "master" ]; then
APP_URL="${{ env.BASE_DOMAIN }}" APP_URL="${{ env.BASE_DOMAIN }}"
TARGET_NS="${REPO_LOWER}" TARGET_NS="${REPO_LOWER}"
BUILD_MODE="main"
CERT_ISSUER="letsencrypt-prod"
echo "Mode: PRODUCTION (Root Domain)" echo "Mode: PRODUCTION (Root Domain)"
else else
APP_URL="${REPO_LOWER}-${BRANCH_LOWER}.${{ env.BASE_DOMAIN }}" APP_URL="${REPO_LOWER}-${BRANCH_LOWER}.${{ env.BASE_DOMAIN }}"
TARGET_NS="${REPO_LOWER}-${BRANCH_LOWER}" TARGET_NS="${REPO_LOWER}-${BRANCH_LOWER}"
BUILD_MODE="dev"
CERT_ISSUER="letsencrypt-prod"
echo "Mode: DEVELOPMENT (Subdomain)" echo "Mode: DEVELOPMENT (Subdomain)"
fi fi
@@ -50,6 +54,8 @@ jobs:
echo "DEBUG: Namespace: $TARGET_NS" echo "DEBUG: Namespace: $TARGET_NS"
echo "DEBUG: URL: $APP_URL" echo "DEBUG: URL: $APP_URL"
echo "DEBUG: Branch-Tag: $BRANCH_TAG" echo "DEBUG: Branch-Tag: $BRANCH_TAG"
echo "DEBUG: Build-Mode: $BUILD_MODE"
echo "DEBUG: Cert-Issuer: $CERT_ISSUER"
# In Gitea Actions Environment schreiben # In Gitea Actions Environment schreiben
echo "FULL_IMAGE_PATH=$FULL_IMAGE_PATH" >> $GITHUB_ENV echo "FULL_IMAGE_PATH=$FULL_IMAGE_PATH" >> $GITHUB_ENV
@@ -58,6 +64,8 @@ jobs:
echo "APP_URL=$APP_URL" >> $GITHUB_ENV echo "APP_URL=$APP_URL" >> $GITHUB_ENV
echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV
echo "BRANCH_TAG=$BRANCH_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 # 3. Prüfen ob ein Image-Rebuild nötig ist
- name: Detect Source Changes - name: Detect Source Changes
@@ -93,7 +101,7 @@ jobs:
image: ${{ env.FULL_IMAGE_PATH }} image: ${{ env.FULL_IMAGE_PATH }}
tag: ${{ env.IMAGE_TAG }} tag: ${{ env.IMAGE_TAG }}
cache: true 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) # 5. Stabilen Branch-Tag aktualisieren (NUR nach erfolgreichem Build)
# Damit weiß der nächste Nicht-Build-Push welches Image er verwenden soll. # Damit weiß der nächste Nicht-Build-Push welches Image er verwenden soll.
@@ -160,10 +168,15 @@ jobs:
# Namespace erstellen (falls nicht existiert) # Namespace erstellen (falls nicht existiert)
kubectl create namespace ${{ env.TARGET_NS }} --dry-run=client -o yaml | kubectl apply -f - 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|\${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 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) # Admin-Credentials Secret anlegen/aktualisieren (aus Gitea Secret)
kubectl create secret generic admin-credentials \ kubectl create secret generic admin-credentials \

View File

@@ -25,11 +25,19 @@ RUN if [ ! -f cmd/client/web/assets/assets.json ]; then \
RUN chmod +x scripts/cache-version.sh && \ RUN chmod +x scripts/cache-version.sh && \
./scripts/cache-version.sh ./scripts/cache-version.sh
# Bilder komprimieren (verlustfrei für PNG, leichter Verlust für JPG) # ARG für den Build-Modus (z.B. "main" für Produktion)
RUN echo "🗜️ Komprimiere Bilder..." && \ ARG BUILD_MODE=dev
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 {} \; && \ # Bilder komprimieren (NUR für BUILD_MODE=main)
echo "✅ Bildkompression abgeschlossen" # 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 # Server binary bauen
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-w -s" -o server ./cmd/server RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -ldflags="-w -s" -o server ./cmd/server

View File

@@ -51,8 +51,8 @@ func NewAudioSystem() *AudioSystem {
as := &AudioSystem{ as := &AudioSystem{
audioContext: ctx, audioContext: ctx,
musicVolume: 0.3, // 30% Standard-Lautstärke musicVolume: 0.7, // 70% Standard-Lautstärke
sfxVolume: 0.5, // 50% Standard-Lautstärke sfxVolume: 0.3, // 30% Standard-Lautstärke
muted: false, muted: false,
} }

View File

@@ -4,7 +4,16 @@
package main package main
import ( import (
"fmt"
"image/color"
"math"
"strings"
"time"
"github.com/hajimehoshi/ebiten/v2" "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 // 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) { func (g *Game) drawLeaderboard(screen *ebiten.Image) {
g.DrawLeaderboard(screen) 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})
}

View File

@@ -26,3 +26,8 @@ func (g *Game) drawLeaderboard(screen *ebiten.Image) {
// Schwarzer Hintergrund - HTML-Leaderboard ist darüber // Schwarzer Hintergrund - HTML-Leaderboard ist darüber
screen.Fill(color.RGBA{0, 0, 0, 255}) 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})
}

View File

@@ -6,6 +6,7 @@ import (
"log" "log"
"math" "math"
"math/rand" "math/rand"
"strings"
"time" "time"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
@@ -185,9 +186,14 @@ func (g *Game) UpdateGame() {
} }
// --- 5. INPUT SENDEN (MIT CLIENT PREDICTION, 20 TPS) --- // --- 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() g.lastInputTime = time.Now()
// Offline: Update Scroll & World logic locally
if g.isOffline {
g.updateOfflineLoop()
}
g.predictionMutex.Lock() g.predictionMutex.Lock()
wasOnGround := g.predictedGround wasOnGround := g.predictedGround
g.predictionMutex.Unlock() g.predictionMutex.Unlock()
@@ -245,11 +251,33 @@ func (g *Game) UpdateGame() {
g.SendInputWithSequence(input) g.SendInputWithSequence(input)
// Solo: Lokale Prüfung der Runde (Tod/Score)
if g.gameMode == "solo" {
g.checkSoloRound()
}
// Trail: store predicted position every physics step // Trail: store predicted position every physics step
g.trail = append(g.trail, trailPoint{X: g.predictedX, Y: g.predictedY}) g.trail = append(g.trail, trailPoint{X: g.predictedX, Y: g.predictedY})
if len(g.trail) > 8 { if len(g.trail) > 8 {
g.trail = g.trail[1:] 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) --- // --- 6. KAMERA LOGIK (mit Smoothing) ---
@@ -317,6 +345,15 @@ func (g *Game) handleTouchInput() {
x, y := ebiten.TouchPosition(id) x, y := ebiten.TouchPosition(id)
fx, fy := float64(x), float64(y) 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 { if fx >= halfW {
// ── RECHTE SEITE: Jump und Down ────────────────────────────────────── // ── RECHTE SEITE: Jump und Down ──────────────────────────────────────
g.btnJumpPressed = true g.btnJumpPressed = true
@@ -550,7 +587,25 @@ func (g *Game) drawPlayers(screen *ebiten.Image, snap renderSnapshot) {
if name == "" { if name == "" {
name = id 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 { if g.showDebug {
g.drawPlayerHitbox(screen, posX, screenY, snap.viewScale) g.drawPlayerHitbox(screen, posX, screenY, snap.viewScale)
@@ -594,6 +649,13 @@ func (g *Game) drawStatusUI(screen *ebiten.Image, snap renderSnapshot) {
if snap.isDead { if snap.isDead {
g.drawSpectatorOverlay(screen, snap) 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.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) 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}) 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. // 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. // drawTeacher zeichnet den Lehrer-Charakter am linken Bildschirmrand.
func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) { func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) {
if snap.status != "RUNNING" && snap.status != "COUNTDOWN" { if snap.status != "RUNNING" && snap.status != "COUNTDOWN" {
@@ -815,8 +949,13 @@ func (g *Game) drawTeacher(screen *ebiten.Image, snap renderSnapshot) {
// Legs // Legs
vector.DrawFilledRect(screen, bodyX+1, groundY, bodyW/2-2, float32(snap.canvasH), color.RGBA{60, 10, 10, alpha}, false) 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) 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 // Warning text — blinks when close
if danger > 0.55 { if danger > 0.55 {
if (time.Now().UnixMilli()/300)%2 == 0 { if (time.Now().UnixMilli()/300)%2 == 0 {

View File

@@ -27,8 +27,10 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
screen.Fill(color.RGBA{20, 20, 30, 255}) screen.Fill(color.RGBA{20, 20, 30, 255})
// Leaderboard immer beim ersten Mal anfordern (ohne Lock hier!) // 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 g.submitScore() // submitScore() ruft requestLeaderboard() auf
} else if !g.scoreSubmitted && g.gameMode == "solo" && g.isOffline {
// Offline-Solo: Keine automatische Submission
} else { } else {
// Für Coop: Nur Leaderboard anfordern, nicht submitten // Für Coop: Nur Leaderboard anfordern, nicht submitten
g.leaderboardMutex.Lock() g.leaderboardMutex.Lock()
@@ -43,6 +45,14 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
// Großes GAME OVER // Großes GAME OVER
text.Draw(screen, "GAME OVER", basicfont.Face7x13, ScreenWidth/2-50, 60, color.RGBA{255, 0, 0, 255}) 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 // Highscore prüfen und aktualisieren
if myScore > g.localHighscore { if myScore > g.localHighscore {
g.localHighscore = myScore g.localHighscore = myScore
@@ -50,9 +60,9 @@ func (g *Game) drawGameOverScreen(screen *ebiten.Image, myScore int) {
} }
// Persönlicher Highscore anzeigen // Persönlicher Highscore anzeigen
if myScore == g.localHighscore && myScore > 0 { 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 { } 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 // 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) text.Draw(screen, "SUBMIT SCORE", basicfont.Face7x13, submitBtnX+50, submitBtnY+25, color.White)
} else if g.gameMode == "solo" && g.scoreSubmitted { } else if g.gameMode == "solo" && g.scoreSubmitted {
// Solo: Zeige Bestätigungsmeldung // 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 { } else if g.gameMode == "coop" && !g.isHost {
// Coop Non-Host: Warten auf Host // Coop Non-Host: Warten auf Host
text.Draw(screen, "Warte auf Host...", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.Gray{180}) text.Draw(screen, "Warte auf Host...", basicfont.Face7x13, ScreenWidth/2-70, ScreenHeight-100, color.Gray{180})

View File

@@ -15,6 +15,7 @@ require (
github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/compress v1.18.0 // indirect
github.com/nats-io/nkeys v0.4.11 // indirect github.com/nats-io/nkeys v0.4.11 // indirect
github.com/nats-io/nuid v1.0.1 // 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/crypto v0.37.0 // indirect
golang.org/x/sync v0.17.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect golang.org/x/sys v0.36.0 // indirect

View File

@@ -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/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 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= 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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA= golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=

View File

@@ -29,6 +29,7 @@ const (
StateLobby = 1 StateLobby = 1
StateGame = 2 StateGame = 2
StateLeaderboard = 3 StateLeaderboard = 3
StatePresentation = 4
RefFloorY = 540 // Server-Welt Boden-Position (unveränderlich) 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) 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 --- // --- GAME STRUCT ---
type Game struct { type Game struct {
appState int appState int
@@ -73,6 +95,11 @@ type Game struct {
roomID string roomID string
activeField string // "name" oder "room" oder "teamname" activeField string // "name" oder "room" oder "teamname"
gameMode string // "solo" oder "coop" 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 isHost bool
teamName string // Team-Name für Coop beim Game Over teamName string // Team-Name für Coop beim Game Over
@@ -139,10 +166,26 @@ type Game struct {
// Highscore // Highscore
localHighscore int localHighscore int
roundStartTime time.Time // Startzeit der aktuellen Runde (für Solo)
// Audio System // Audio System
audio *AudioSystem 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 // Kamera
camX float64 camX float64
@@ -235,6 +278,34 @@ func (g *Game) Update() error {
g.showDebug = !g.showDebug 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 // Pending Inputs zählen für Debug
g.predictionMutex.Lock() g.predictionMutex.Lock()
g.pendingInputCount = len(g.pendingInputs) g.pendingInputCount = len(g.pendingInputs)
@@ -263,17 +334,22 @@ func (g *Game) Update() error {
} }
} }
// COUNTDOWN/RUNNING-Übergang: AppState auf StateGame setzen + JS benachrichtigen // COUNTDOWN/RUNNING/PRESENTATION-Übergang: AppState auf StateGame setzen + JS benachrichtigen
if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING") && g.appState != StateGame { if (currentStatus == "COUNTDOWN" || currentStatus == "RUNNING" || currentStatus == "PRESENTATION") && g.appState != StateGame && g.appState != StatePresentation {
log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus) log.Printf("🎮 Spiel startet! Status: %s -> %s", g.lastStatus, currentStatus)
g.appState = StateGame g.appState = StateGame
g.notifyGameStarted() g.notifyGameStarted()
} }
if currentStatus == "RUNNING" && g.lastStatus != "RUNNING" { if currentStatus == "RUNNING" && g.lastStatus != "RUNNING" {
g.audio.PlayMusic() g.audio.PlayMusic()
g.roundStartTime = time.Now()
} }
if currentStatus == "GAMEOVER" && g.lastStatus == "RUNNING" { if currentStatus == "GAMEOVER" && g.lastStatus == "RUNNING" {
g.audio.StopMusic() g.audio.StopMusic()
g.deathQuote = game.GetRandomQuote()
if g.gameMode == "solo" {
g.verifyRoundResult()
}
} }
g.lastStatus = currentStatus g.lastStatus = currentStatus
@@ -286,9 +362,13 @@ func (g *Game) Update() error {
g.UpdateGame() g.UpdateGame()
case StateLeaderboard: case StateLeaderboard:
g.updateLeaderboard() g.updateLeaderboard()
case StatePresentation:
g.updatePresentation()
g.updatePresentationState_Platform()
} }
return nil return nil
} }
func (g *Game) updateMenu() { func (g *Game) updateMenu() {
g.handleMenuInput() g.handleMenuInput()
@@ -346,15 +426,13 @@ func (g *Game) updateMenu() {
btnY := ScreenHeight/2 - 20 btnY := ScreenHeight/2 - 20
if isHit(soloX, btnY, btnW, btnH) { if isHit(soloX, btnY, btnW, btnH) {
// SOLO MODE // SOLO MODE (Offline by default)
if g.playerName == "" { if g.playerName == "" {
g.playerName = "Player" g.playerName = "Player"
} }
g.gameMode = "solo" g.gameMode = "solo"
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true g.isHost = true
g.appState = StateGame g.startOfflineGame()
go g.connectAndStart()
} else if isHit(coopX, btnY, btnW, btnH) { } else if isHit(coopX, btnY, btnW, btnH) {
// CO-OP MODE // CO-OP MODE
if g.playerName == "" { if g.playerName == "" {
@@ -449,6 +527,8 @@ func (g *Game) draw(screen *ebiten.Image) {
g.DrawGame(screen) g.DrawGame(screen)
case StateLeaderboard: case StateLeaderboard:
g.drawLeaderboard(screen) g.drawLeaderboard(screen)
case StatePresentation:
g.drawPresentation(screen)
} }
} }
@@ -499,7 +579,8 @@ func (g *Game) DrawMenu(screen *ebiten.Image) {
coopX := ScreenWidth/2 + 20 coopX := ScreenWidth/2 + 20
vector.DrawFilledRect(screen, float32(coopX), float32(btnY), float32(btnW), float32(btnH), ColBtnNormal, false) 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) 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 // Join Section
joinY := ScreenHeight/2 + 100 joinY := ScreenHeight/2 + 100
@@ -561,7 +642,7 @@ func (g *Game) DrawLobby(screen *ebiten.Image) {
screen.Fill(color.RGBA{20, 20, 30, 255}) screen.Fill(color.RGBA{20, 20, 30, 255})
// Titel // 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) // Room Code (groß anzeigen)
text.Draw(screen, "Room Code:", basicfont.Face7x13, ScreenWidth/2-40, 150, color.Gray{200}) 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 g.lastRecvSeq = 0
// Spieler-State zurücksetzen // Spieler-State zurücksetzen
g.isOffline = false
g.godModeEndTime = time.Time{}
g.magnetEndTime = time.Time{}
g.doubleJumpEndTime = time.Time{}
g.scoreSubmitted = false g.scoreSubmitted = false
g.lastStatus = "" g.lastStatus = ""
g.correctionCount = 0 g.correctionCount = 0
@@ -921,6 +1006,10 @@ func (g *Game) SendCommand(cmdType string) {
func (g *Game) SendInputWithSequence(input InputState) { func (g *Game) SendInputWithSequence(input InputState) {
if !g.connected { if !g.connected {
// Im Offline-Modus den Jump-Sound trotzdem lokal abspielen
if input.Jump && g.isOffline {
g.audio.PlayJump()
}
return return
} }

View File

@@ -9,6 +9,9 @@ import (
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
) )
func (g *Game) notifyPresentationStarted_Platform(roomID string) {}
func (g *Game) updatePresentationState_Platform() {}
func main() { func main() {
ebiten.SetWindowSize(ScreenWidth, ScreenHeight) ebiten.SetWindowSize(ScreenWidth, ScreenHeight)
ebiten.SetWindowTitle("Escape From Teacher") ebiten.SetWindowTitle("Escape From Teacher")

View File

@@ -7,8 +7,20 @@ import (
"log" "log"
"github.com/hajimehoshi/ebiten/v2" "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() { func main() {
log.Println("🚀 WASM Version startet...") log.Println("🚀 WASM Version startet...")

377
cmd/client/offline_logic.go Normal file
View File

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

View File

@@ -1,14 +1,69 @@
package main package main
import ( import (
"log"
"math"
"time"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/config" "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/game"
"git.zb-server.de/ZB-Server/EscapeFromTeacher/pkg/physics" "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 // ApplyInput wendet einen Input auf den vorhergesagten Zustand an
// Nutzt die gemeinsame Physik-Engine aus pkg/physics // Nutzt die gemeinsame Physik-Engine aus pkg/physics
func (g *Game) ApplyInput(input InputState) { 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 // Horizontale Bewegung mit analogem Joystick
moveX := 0.0 moveX := 0.0
if input.Left { if input.Left {
@@ -192,3 +247,72 @@ func (g *Game) ReconcileWithServer(serverState game.PlayerState) {
g.predictedHasDoubleJump = serverState.HasDoubleJump g.predictedHasDoubleJump = serverState.HasDoubleJump
g.predictedDoubleJumpUsed = serverState.DoubleJumpUsed 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.
}

106
cmd/client/presentation.go Normal file
View File

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

View File

@@ -4,7 +4,7 @@
package main package main
import ( import (
"fmt" "encoding/base64"
"log" "log"
"syscall/js" "syscall/js"
"time" "time"
@@ -36,11 +36,11 @@ func (g *Game) setupJavaScriptBridge() {
g.savePlayerName(playerName) g.savePlayerName(playerName)
if mode == "solo" { if mode == "solo" {
// Solo Mode - Auto-Start wartet auf Server // Solo Mode - Jetzt standardmäßig OFFLINE
g.roomID = fmt.Sprintf("solo_%d", time.Now().UnixNano())
g.isHost = true g.isHost = true
g.appState = StateLobby // Warte auf Server Auto-Start g.startOfflineGame()
log.Printf("🎮 Solo-Spiel gestartet: %s (warte auf Server)", playerName) log.Printf("🎮 Solo-Spiel OFFLINE gestartet: %s", playerName)
return nil
} else if mode == "coop" && len(args) >= 5 { } else if mode == "coop" && len(args) >= 5 {
// Co-op Mode - in die Lobby // Co-op Mode - in die Lobby
roomID := args[2].String() roomID := args[2].String()
@@ -106,6 +106,32 @@ func (g *Game) setupJavaScriptBridge() {
return nil 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 // Im globalen Scope registrieren
js.Global().Set("startGame", startGameFunc) js.Global().Set("startGame", startGameFunc)
js.Global().Set("requestLeaderboard", requestLeaderboardFunc) js.Global().Set("requestLeaderboard", requestLeaderboardFunc)
@@ -113,6 +139,7 @@ func (g *Game) setupJavaScriptBridge() {
js.Global().Set("setSFXVolume", setSFXVolumeFunc) js.Global().Set("setSFXVolume", setSFXVolumeFunc)
js.Global().Set("startGameFromLobby_WASM", startGameFromLobbyFunc) js.Global().Set("startGameFromLobby_WASM", startGameFromLobbyFunc)
js.Global().Set("setTeamName_WASM", setTeamNameFunc) js.Global().Set("setTeamName_WASM", setTeamNameFunc)
js.Global().Set("togglePresentationMode_WASM", togglePresFunc)
log.Println("✅ JavaScript Bridge registriert") log.Println("✅ JavaScript Bridge registriert")
log.Printf("🔍 window.startGame defined: %v", !js.Global().Get("startGame").IsUndefined()) 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) 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)
}
}

View File

@@ -17,10 +17,17 @@ const UIState = {
COOP_MENU: 'coop_menu', COOP_MENU: 'coop_menu',
MY_CODES: 'mycodes', MY_CODES: 'mycodes',
IMPRESSUM: 'impressum', IMPRESSUM: 'impressum',
DATENSCHUTZ: 'datenschutz' DATENSCHUTZ: 'datenschutz',
PRESENTATION: 'presentation'
}; };
let currentUIState = UIState.LOADING; 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 // Central UI State Manager
function setUIState(newState) { function setUIState(newState) {
@@ -133,6 +140,15 @@ function setUIState(newState) {
} }
document.getElementById('datenschutzMenu').classList.remove('hidden'); document.getElementById('datenschutzMenu').classList.remove('hidden');
break; 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); if (window.setSFXVolume) window.setSFXVolume(0);
} else { } else {
btn.textContent = '🔊'; btn.textContent = '🔊';
const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 70) / 100; const musicVol = parseInt(localStorage.getItem('escape_music_volume') || 80) / 100;
const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 70) / 100; const sfxVol = parseInt(localStorage.getItem('escape_sfx_volume') || 40) / 100;
if (window.setMusicVolume) window.setMusicVolume(musicVol); if (window.setMusicVolume) window.setMusicVolume(musicVol);
if (window.setSFXVolume) window.setSFXVolume(sfxVol); if (window.setSFXVolume) window.setSFXVolume(sfxVol);
} }
@@ -656,7 +672,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Load saved value // Load saved value
const savedMusic = localStorage.getItem('escape_music_volume') || 70; const savedMusic = localStorage.getItem('escape_music_volume') || 80;
musicSlider.value = savedMusic; musicSlider.value = savedMusic;
musicValue.textContent = savedMusic + '%'; musicValue.textContent = savedMusic + '%';
} }
@@ -673,7 +689,7 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// Load saved value // Load saved value
const savedSFX = localStorage.getItem('escape_sfx_volume') || 70; const savedSFX = localStorage.getItem('escape_sfx_volume') || 40;
sfxSlider.value = savedSFX; sfxSlider.value = savedSFX;
sfxValue.textContent = savedSFX + '%'; sfxValue.textContent = savedSFX + '%';
} }
@@ -684,6 +700,21 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('playerName').value = savedName; 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 // Load local highscore
const highscore = localStorage.getItem('escape_local_highscore') || 0; const highscore = localStorage.getItem('escape_local_highscore') || 0;
const hsElement = document.getElementById('localHighscore'); const hsElement = document.getElementById('localHighscore');
@@ -699,6 +730,14 @@ document.addEventListener('keydown', (e) => {
showMenu(); showMenu();
gameStarted = false; 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) // Show Game Over Screen (called by WASM)
@@ -904,3 +943,167 @@ window.restartGame = restartGame;
initWASM(); initWASM();
console.log('🎮 Game.js loaded - Retro Edition'); 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 ? `<img src="${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 = `<img src="assets/playernew.png" style="height: 120px; width: auto; object-fit: contain;">`;
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);
}
}
});
};

View File

@@ -29,7 +29,7 @@
<input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;"> <input type="text" id="playerName" placeholder="NAME (4 ZEICHEN)" maxlength="15" style="text-transform:uppercase;">
<button id="startBtn" onclick="startSoloGame()" disabled style="opacity: 0.5; cursor: not-allowed;">SOLO STARTEN</button> <button id="startBtn" onclick="startSoloGame()" disabled style="opacity: 0.5; cursor: not-allowed;">SOLO STARTEN</button>
<button id="coopBtn" onclick="showCoopMenu()" disabled style="opacity: 0.5; cursor: not-allowed;">CO-OP SPIELEN</button> <button id="coopBtn" onclick="showCoopMenu()" disabled style="opacity: 0.5; cursor: not-allowed;">CO-OP SPIELEN <span style="font-size: 8px; color: #ff4444;">(EXPERIMENTAL)</span></button>
<div class="info-box"> <div class="info-box">
<div class="info-title">SCHUL-NEWS</div> <div class="info-title">SCHUL-NEWS</div>
@@ -73,7 +73,7 @@
<!-- CO-OP MENU --> <!-- CO-OP MENU -->
<div id="coopMenu" class="overlay-screen hidden"> <div id="coopMenu" class="overlay-screen hidden">
<div class="center-box"> <div class="center-box">
<h1>CO-OP MODUS</h1> <h1>CO-OP MODUS <span style="font-size: 12px; color: #ff4444; vertical-align: middle;">(EXPERIMENTAL)</span></h1>
<button id="createRoomBtn" class="big-btn" onclick="createRoom()" disabled style="opacity: 0.5; cursor: not-allowed;">RAUM ERSTELLEN</button> <button id="createRoomBtn" class="big-btn" onclick="createRoom()" disabled style="opacity: 0.5; cursor: not-allowed;">RAUM ERSTELLEN</button>
@@ -95,14 +95,14 @@
<div class="settings-group"> <div class="settings-group">
<div class="setting-item"> <div class="setting-item">
<label>MUSIK LAUTSTÄRKE:</label> <label>MUSIK LAUTSTÄRKE:</label>
<input type="range" id="musicVolume" min="0" max="100" value="70"> <input type="range" id="musicVolume" min="0" max="100" value="80">
<span id="musicValue">70%</span> <span id="musicValue">80%</span>
</div> </div>
<div class="setting-item"> <div class="setting-item">
<label>SFX LAUTSTÄRKE:</label> <label>SFX LAUTSTÄRKE:</label>
<input type="range" id="sfxVolume" min="0" max="100" value="70"> <input type="range" id="sfxVolume" min="0" max="100" value="40">
<span id="sfxValue">70%</span> <span id="sfxValue">40%</span>
</div> </div>
</div> </div>
@@ -242,7 +242,7 @@
<!-- LOBBY SCREEN (CO-OP WAITING ROOM) --> <!-- LOBBY SCREEN (CO-OP WAITING ROOM) -->
<div id="lobbyScreen" class="overlay-screen hidden"> <div id="lobbyScreen" class="overlay-screen hidden">
<div class="center-box"> <div class="center-box">
<h1>LOBBY</h1> <h1>LOBBY <span style="font-size: 12px; color: #ff4444; vertical-align: middle;">(EXPERIMENTAL)</span></h1>
<div id="lobbyContent" style="display: flex; gap: 20px; width: 100%; justify-content: center; align-items: flex-start;"> <div id="lobbyContent" style="display: flex; gap: 20px; width: 100%; justify-content: center; align-items: flex-start;">
<div style="flex: 1; max-width: 400px;"> <div style="flex: 1; max-width: 400px;">
@@ -293,6 +293,35 @@
</div> </div>
</div> </div>
<!-- PRESENTATION SCREEN -->
<div id="presentationScreen" class="overlay-screen hidden presentation-mode">
<div class="presi-background"></div>
<div class="presi-scanlines"></div>
<div class="presi-header">
<h1>PRESENTATION MODE</h1>
<div id="presiRoomInfo">
<span id="presiRoomCode">XXXXX</span>
</div>
</div>
<div class="presi-content">
<div id="presiQuoteBox">
<p id="presiQuoteText">"Lade legendäre Sprüche..."</p>
<p id="presiQuoteAuthor">- Unbekannt</p>
</div>
</div>
<div class="presi-qr-container">
<div id="presiQRCode"></div>
<p>SCANNEN ZUM MITMACHEN!</p>
</div>
<div class="presi-assets-track"></div>
<div class="presi-players-layer"></div>
</div>
<!-- LOADING SCREEN --> <!-- LOADING SCREEN -->
<div id="loading" class="loading-screen"> <div id="loading" class="loading-screen">
<div class="spinner"></div> <div class="spinner"></div>

View File

@@ -165,3 +165,194 @@ input[type=range]{width:100%;max-width:300px}
#rotate-overlay{display:flex} #rotate-overlay{display:flex}
#game-container{display:none!important} #game-container{display:none!important}
} }
/* PRESENTATION MODE */
.presentation-mode {
background: #0a0f1e!important;
flex-direction: column;
padding: 0!important;
overflow: hidden;
}
.presi-background {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: radial-gradient(circle at center, #1a2a4a 0%, #0a0f1e 100%);
z-index: -2;
}
.presi-scanlines {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
background: linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.25) 50%), linear-gradient(90deg, rgba(255, 0, 0, 0.06), rgba(0, 255, 0, 0.02), rgba(0, 0, 100, 0.06));
background-size: 100% 4px, 3px 100%;
pointer-events: none;
z-index: 10;
}
.presi-header {
margin-top: 40px;
text-align: center;
z-index: 5;
}
.presi-header h1 {
font-size: 36px;
color: #ff0;
text-shadow: 0 0 10px rgba(255, 255, 0, 0.5);
margin: 0;
}
#presiRoomInfo {
margin-top: 10px;
background: rgba(0, 0, 0, 0.6);
padding: 5px 15px;
border: 2px solid #ff0;
display: inline-block;
}
#presiRoomCode {
font-size: 24px;
color: #ff0;
letter-spacing: 2px;
}
.presi-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
z-index: 5;
}
#presiQuoteBox {
max-width: 900px;
text-align: center;
background: #2c3e50; /* Solid Blue-Grey */
padding: 50px;
border-radius: 15px;
border: 6px solid #ff0;
box-shadow: 15px 15px 0px rgba(0,0,0,0.6);
z-index: 100;
}
#presiQuoteText {
font-family: sans-serif;
font-size: 36px; /* Larger font */
line-height: 1.4;
font-style: italic;
margin-bottom: 25px;
color: white;
text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
}
#presiQuoteAuthor {
color: #ffcc00;
font-size: 24px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 2px;
}
.presi-qr-container {
position: absolute;
bottom: 40px;
left: 40px;
background: white;
padding: 10px;
border-radius: 5px;
text-align: center;
z-index: 20;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
max-width: 150px;
}
#presiQRCode {
width: 120px;
height: 120px;
}
#presiQRCode img {
width: 100%;
height: 100%;
}
.presi-qr-container p {
color: black;
font-size: 8px;
margin-top: 5px;
font-weight: bold;
}
.presi-assets-track {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1; /* Behind quotes (5/100) and scanlines (10) */
overflow: hidden;
}
.presi-asset {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.1s linear;
}
.presi-asset img {
display: block;
max-height: 100px; /* Limit height */
max-width: 150px; /* Limit width */
object-fit: contain;
filter: drop-shadow(0 5px 10px rgba(0,0,0,0.5));
}
.presi-players-layer {
position: absolute;
top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
z-index: 200; /* Above everything */
}
.presi-player {
position: absolute;
z-index: 210;
}
.presi-player img {
display: block;
filter: drop-shadow(0 0 10px rgba(255,255,255,0.3));
}
.presi-player-emote {
position: absolute;
top: -60px; /* Higher up */
left: 50%;
transform: translateX(-50%);
font-size: 50px; /* Much larger emotes */
z-index: 250;
text-shadow: 0 0 10px rgba(0,0,0,0.5);
animation: emotePop 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes emotePop {
0% { transform: translateX(-50%) scale(0); opacity: 0; }
70% { transform: translateX(-50%) scale(1.5); opacity: 1; }
100% { transform: translateX(-50%) scale(1); opacity: 1; }
}
.presi-footer {
display: none; /* Hidden as requested */
}
/* Animations */
@keyframes assetSlide {
from { transform: translateX(110vw); }
to { transform: translateX(-300px); }
}

View File

@@ -3,7 +3,7 @@ apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy kind: CiliumNetworkPolicy
metadata: metadata:
name: default-deny-all name: default-deny-all
namespace: escapefromteacher namespace: ${TARGET_NS}
spec: spec:
endpointSelector: {} endpointSelector: {}
ingress: ingress:
@@ -125,3 +125,36 @@ spec:
- ports: - ports:
- port: "6222" - port: "6222"
protocol: TCP protocol: TCP
---
# ACME Challenge Solver Network Policy
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: acme-solver-netpol
namespace: ${TARGET_NS}
spec:
endpointSelector:
matchLabels:
acme.cert-manager.io/http01-solver: "true"
ingress:
# Allow ingress from Traefik
- fromEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: traefik
toPorts:
- ports:
- port: "8089"
protocol: TCP
egress:
# Allow egress to internet for self-check (if needed) and DNS
- toEntities:
- world
- toEndpoints:
- matchLabels:
k8s:io.kubernetes.pod.namespace: kube-system
k8s-app: kube-dns
toPorts:
- ports:
- port: "53"
protocol: UDP

View File

@@ -3,7 +3,7 @@ kind: Ingress
metadata: metadata:
name: game-ingress name: game-ingress
annotations: annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod cert-manager.io/cluster-issuer: ${CERT_ISSUER}
traefik.ingress.kubernetes.io/router.entrypoints: web, websecure traefik.ingress.kubernetes.io/router.entrypoints: web, websecure
traefik.ingress.kubernetes.io/router.middlewares: gitea-redirect-https@kubernetescrd,${TARGET_NS}-compress@kubernetescrd traefik.ingress.kubernetes.io/router.middlewares: gitea-redirect-https@kubernetescrd,${TARGET_NS}-compress@kubernetescrd
spec: spec:

49
pkg/game/quotes.go Normal file
View File

@@ -0,0 +1,49 @@
package game
import "math/rand"
type Quote struct {
Text string
Author string
Ctx string
}
var Quotes = []Quote{
{Text: "Mobbing ist besser als gar keine sozialen Kontakte.", Author: "Ein Lehrer"},
{Text: "Was heißt Strafe auf Englisch? „Richard“?", Author: "Ein Lehrer"},
{Text: "Heute ist alles richtig eingetragen.", Author: "Eine Lehrerin"},
{Text: "Verstehen Sie überhaupt die Prüfungsfragen? Neh, ach …", Author: "Ein Lehrer"},
{Text: "Ist das eine rechtsextreme Handlung?!", Author: "Schüler"},
{Text: "Ich bin mit dem Staat verheiratet. … Ich hab das nur wegen der Pension gemacht.", Author: "Ein Lehrer"},
{Text: "Ich hab 'nen Freund! Das ist egal.", Author: "Lehrerin & Schüler"},
{Text: "Neues Lieblingswort: „Hanebüchen“", Author: "Ein Lehrer"},
{Text: "Ich mag Menschen quälen.", Author: "Ein Lehrer"},
{Text: "Morgen sind Schüler unserer polnischen Partnerschule da. Die wollen von dem Besten lernen also von mir.", Author: "Ein Lehrer"},
{Text: "Ich bräuchte jetzt wirklich einen Kaffee oder ein Bier.", Author: "Eine Lehrerin"},
{Text: "Scheiße, darauf kann ich nicht rumschreiben!", Author: "Ein Lehrer"},
{Text: "Warum muss ich jetzt wieder Scheiße erklären, die ich net verzapft hab. Lasst mich doch in Ruhe.", Author: "Ein Lehrer"},
{Text: "Dann können Rollstuhlfahrer gleich mit in den Krieg ziehen.", Author: "Ein Lehrer"},
{Text: "Mobbing ist 3 Monate durchgängig. Ich kann sie also nicht gar nicht mobben, weil sie immer weg sind.", Author: "Ein Lehrer"},
{Text: "Ich spiele kein Schach mehr, seit ich gegen ein Kind verloren hab.", Author: "Ein Lehrer"},
{Text: "Spielen Sie „God of War“? So sehen sie auch aus.", Author: "Ein Lehrer"},
{Text: "Die Antwort soll „ja“ sein. Mit genug Reden kann man auch das Gegenteil argumentieren.", Author: "Ein Lehrer"},
{Text: "Es geht darum, Sie drei Jahre hinzuhalten und dann sind Sie eh weg.", Author: "Ein Lehrer"},
{Text: "Es gibt hier gar kein Problem. ... Es gibt verdammt nochmal keine Probleme!", Author: "Ein Lehrer"},
{Text: "Ich denke immer, ich bin doof. Aber das ist so.", Author: "Ein Lehrer"},
{Text: "Warum wollen Sie die Schule versichern? Die können Sie sowieso nicht verklagen.", Author: "Ein Lehrer"},
{Text: "Haltet euch sklavisch an die Notation!", Author: "Ein Lehrer"},
{Text: "Nur die Paranoiden werden überleben.", Author: "Ein Lehrer"},
{Text: "Ich bin gerade im Größenwahn und es wird immer verrückter.", Author: "Ein Lehrer"},
{Text: "Programmieren kann so ekelhaft sein, wenn man sich wirklich damit beschäftigt.", Author: "Ein Lehrer"},
{Text: "Vorsicht, sauer. Hab ich meinen Kindern geklaut.", Author: "Ein Lehrer"},
{Text: "Es gibt noch solche von der Resterampe wie mich.", Author: "Ein Lehrer"},
{Text: "Jetzt muss ich mir schon die Musterlösung schönsaufen.", Author: "Ein Lehrer"},
{Text: "Ich muss die Prüfung nicht schreiben!", Author: "Ein Lehrer"},
{Text: "Werd ich echt alt?", Author: "Ein Lehrer"},
{Text: "Die Geißel Gottes. Ich freue mich, Sie zu sehen…!", Author: "Ein Lehrer"},
{Text: "Kind kriegen ist glaub ich schon geiler als auf'm Mount Everest zu steigen.", Author: "Ein Lehrer"},
}
func GetRandomQuote() Quote {
return Quotes[rand.Intn(len(Quotes))]
}

View File

@@ -77,6 +77,10 @@ func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider {
} }
for _, obj := range chunk.Objects { for _, obj := range chunk.Objects {
if obj.MovingPlatform != nil {
continue // Überspringe bewegende Plattformen, werden dynamisch geprüft
}
def, ok := w.Manifest.Assets[obj.AssetID] def, ok := w.Manifest.Assets[obj.AssetID]
if !ok { if !ok {
fmt.Printf("⚠️ Asset '%s' nicht in Manifest!\n", obj.AssetID) fmt.Printf("⚠️ Asset '%s' nicht in Manifest!\n", obj.AssetID)

View File

@@ -218,6 +218,10 @@ func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, strin
for _, activeChunk := range c.ActiveChunks { for _, activeChunk := range c.ActiveChunks {
if chunk, ok := c.World.ChunkLibrary[activeChunk.ChunkID]; ok { if chunk, ok := c.World.ChunkLibrary[activeChunk.ChunkID]; ok {
for _, obj := range chunk.Objects { for _, obj := range chunk.Objects {
if obj.MovingPlatform != nil {
continue // Wird separat als MovingPlatform geprüft
}
if assetDef, ok := c.World.Manifest.Assets[obj.AssetID]; ok { if assetDef, ok := c.World.Manifest.Assets[obj.AssetID]; ok {
if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 { if assetDef.Hitbox.W > 0 && assetDef.Hitbox.H > 0 {
colliderRect := game.Rect{ colliderRect := game.Rect{
@@ -228,7 +232,11 @@ func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, strin
} }
if game.CheckRectCollision(playerRect, colliderRect) { if game.CheckRectCollision(playerRect, colliderRect) {
return true, assetDef.Hitbox.Type colType := assetDef.Hitbox.Type
if colType == "" {
colType = assetDef.Type
}
return true, colType
} }
} }
} }

View File

@@ -30,6 +30,7 @@ type ServerPlayer struct {
BonusScore int // Score aus Coins und anderen Boni BonusScore int // Score aus Coins und anderen Boni
IsAlive bool IsAlive bool
IsSpectator bool IsSpectator bool
State string // Aktueller State (z.B. Emote)
// Powerups // Powerups
HasDoubleJump bool // Doppelsprung aktiv? HasDoubleJump bool // Doppelsprung aktiv?
@@ -342,6 +343,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
if input.PlayerID == r.HostID && r.Status == "LOBBY" { if input.PlayerID == r.HostID && r.Status == "LOBBY" {
r.StartCountdown() r.StartCountdown()
} }
case "START_PRESENTATION":
if input.PlayerID == r.HostID && r.Status == "LOBBY" {
r.Status = "PRESENTATION"
r.GameStartTime = time.Now()
r.CurrentSpeed = 0
}
case "SET_TEAM_NAME": case "SET_TEAM_NAME":
// Nur Host darf Team-Name setzen und nur in der Lobby // Nur Host darf Team-Name setzen und nur in der Lobby
if input.PlayerID == r.HostID && r.Status == "LOBBY" { if input.PlayerID == r.HostID && r.Status == "LOBBY" {
@@ -349,6 +356,19 @@ func (r *Room) HandleInput(input game.ClientInput) {
log.Printf("🏷️ Team-Name gesetzt: '%s' (von Host %s)", r.TeamName, p.Name) log.Printf("🏷️ Team-Name gesetzt: '%s' (von Host %s)", r.TeamName, p.Name)
} }
} }
// Emote Handling (z.B. EMOTE_1, EMOTE_2)
if len(input.Type) > 6 && input.Type[:6] == "EMOTE_" {
p.State = input.Type
// Emote nach 2 Sekunden zurücksetzen
go func(player *ServerPlayer, emote string) {
time.Sleep(2 * time.Second)
if player.State == emote {
player.State = ""
}
}(p, input.Type)
}
} }
func (r *Room) StartCountdown() { func (r *Room) StartCountdown() {
@@ -388,6 +408,11 @@ func (r *Room) Update() {
r.GlobalScrollX += r.CurrentSpeed r.GlobalScrollX += r.CurrentSpeed
// Bewegende Plattformen updaten // Bewegende Plattformen updaten
r.UpdateMovingPlatforms() r.UpdateMovingPlatforms()
} else if r.Status == "PRESENTATION" {
// Keine Kamera-Bewegung, keine Schwierigkeitssteigerung, aber Physik läuft weiter
r.CurrentSpeed = 0
// Bewegende Plattformen können sich auch hier bewegen, wenn gewünscht
r.UpdateMovingPlatforms()
} }
maxX := r.GlobalScrollX maxX := r.GlobalScrollX
@@ -458,6 +483,10 @@ func (r *Room) Update() {
} }
// === SERVER-SPEZIFISCHE LOGIK === // === SERVER-SPEZIFISCHE LOGIK ===
if r.Status == "PRESENTATION" {
// Im Präsentationsmodus: Unverwundbar und keine Grenzen
continue
}
// Obstacle-Kollision prüfen -> Spieler töten // Obstacle-Kollision prüfen -> Spieler töten
hitObstacle, obstacleType := r.CheckCollision( hitObstacle, obstacleType := r.CheckCollision(
@@ -889,6 +918,7 @@ func (r *Room) Broadcast() {
Y: p.Y, Y: p.Y,
VX: p.VX, VX: p.VX,
VY: p.VY, VY: p.VY,
State: p.State,
OnGround: p.OnGround, OnGround: p.OnGround,
OnWall: p.OnWall, OnWall: p.OnWall,
LastInputSeq: p.LastInputSeq, LastInputSeq: p.LastInputSeq,