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
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:
19
.github/workflows/deploy.yaml
vendored
19
.github/workflows/deploy.yaml
vendored
@@ -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,11 +168,16 @@ 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 \
|
||||
--from-literal=username="${{ secrets.ADMIN_USER }}" \
|
||||
|
||||
14
Dockerfile
14
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..." && \
|
||||
# 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"
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
377
cmd/client/offline_logic.go
Normal file
377
cmd/client/offline_logic.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
106
cmd/client/presentation.go
Normal file
106
cmd/client/presentation.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? `<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);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<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="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-title">SCHUL-NEWS</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
<!-- CO-OP MENU -->
|
||||
<div id="coopMenu" class="overlay-screen hidden">
|
||||
<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>
|
||||
|
||||
@@ -95,14 +95,14 @@
|
||||
<div class="settings-group">
|
||||
<div class="setting-item">
|
||||
<label>MUSIK LAUTSTÄRKE:</label>
|
||||
<input type="range" id="musicVolume" min="0" max="100" value="70">
|
||||
<span id="musicValue">70%</span>
|
||||
<input type="range" id="musicVolume" min="0" max="100" value="80">
|
||||
<span id="musicValue">80%</span>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<label>SFX LAUTSTÄRKE:</label>
|
||||
<input type="range" id="sfxVolume" min="0" max="100" value="70">
|
||||
<span id="sfxValue">70%</span>
|
||||
<input type="range" id="sfxVolume" min="0" max="100" value="40">
|
||||
<span id="sfxValue">40%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,7 +242,7 @@
|
||||
<!-- LOBBY SCREEN (CO-OP WAITING ROOM) -->
|
||||
<div id="lobbyScreen" class="overlay-screen hidden">
|
||||
<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 style="flex: 1; max-width: 400px;">
|
||||
@@ -293,6 +293,35 @@
|
||||
</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 -->
|
||||
<div id="loading" class="loading-screen">
|
||||
<div class="spinner"></div>
|
||||
|
||||
@@ -165,3 +165,194 @@ input[type=range]{width:100%;max-width:300px}
|
||||
#rotate-overlay{display:flex}
|
||||
#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); }
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: default-deny-all
|
||||
namespace: escapefromteacher
|
||||
namespace: ${TARGET_NS}
|
||||
spec:
|
||||
endpointSelector: {}
|
||||
ingress:
|
||||
@@ -125,3 +125,36 @@ spec:
|
||||
- ports:
|
||||
- port: "6222"
|
||||
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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ kind: Ingress
|
||||
metadata:
|
||||
name: game-ingress
|
||||
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.middlewares: gitea-redirect-https@kubernetescrd,${TARGET_NS}-compress@kubernetescrd
|
||||
spec:
|
||||
|
||||
49
pkg/game/quotes.go
Normal file
49
pkg/game/quotes.go
Normal 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))]
|
||||
}
|
||||
@@ -77,6 +77,10 @@ func (w *World) GenerateColliders(activeChunks []ActiveChunk) []Collider {
|
||||
}
|
||||
|
||||
for _, obj := range chunk.Objects {
|
||||
if obj.MovingPlatform != nil {
|
||||
continue // Überspringe bewegende Plattformen, werden dynamisch geprüft
|
||||
}
|
||||
|
||||
def, ok := w.Manifest.Assets[obj.AssetID]
|
||||
if !ok {
|
||||
fmt.Printf("⚠️ Asset '%s' nicht in Manifest!\n", obj.AssetID)
|
||||
|
||||
@@ -218,6 +218,10 @@ func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, strin
|
||||
for _, activeChunk := range c.ActiveChunks {
|
||||
if chunk, ok := c.World.ChunkLibrary[activeChunk.ChunkID]; ok {
|
||||
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.Hitbox.W > 0 && assetDef.Hitbox.H > 0 {
|
||||
colliderRect := game.Rect{
|
||||
@@ -228,7 +232,11 @@ func (c *ClientCollisionChecker) CheckCollision(x, y, w, h float64) (bool, strin
|
||||
}
|
||||
|
||||
if game.CheckRectCollision(playerRect, colliderRect) {
|
||||
return true, assetDef.Hitbox.Type
|
||||
colType := assetDef.Hitbox.Type
|
||||
if colType == "" {
|
||||
colType = assetDef.Type
|
||||
}
|
||||
return true, colType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type ServerPlayer struct {
|
||||
BonusScore int // Score aus Coins und anderen Boni
|
||||
IsAlive bool
|
||||
IsSpectator bool
|
||||
State string // Aktueller State (z.B. Emote)
|
||||
|
||||
// Powerups
|
||||
HasDoubleJump bool // Doppelsprung aktiv?
|
||||
@@ -342,6 +343,12 @@ func (r *Room) HandleInput(input game.ClientInput) {
|
||||
if input.PlayerID == r.HostID && r.Status == "LOBBY" {
|
||||
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":
|
||||
// Nur Host darf Team-Name setzen und nur in der 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
@@ -388,6 +408,11 @@ func (r *Room) Update() {
|
||||
r.GlobalScrollX += r.CurrentSpeed
|
||||
// Bewegende Plattformen updaten
|
||||
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
|
||||
@@ -458,6 +483,10 @@ func (r *Room) Update() {
|
||||
}
|
||||
|
||||
// === SERVER-SPEZIFISCHE LOGIK ===
|
||||
if r.Status == "PRESENTATION" {
|
||||
// Im Präsentationsmodus: Unverwundbar und keine Grenzen
|
||||
continue
|
||||
}
|
||||
|
||||
// Obstacle-Kollision prüfen -> Spieler töten
|
||||
hitObstacle, obstacleType := r.CheckCollision(
|
||||
@@ -889,6 +918,7 @@ func (r *Room) Broadcast() {
|
||||
Y: p.Y,
|
||||
VX: p.VX,
|
||||
VY: p.VY,
|
||||
State: p.State,
|
||||
OnGround: p.OnGround,
|
||||
OnWall: p.OnWall,
|
||||
LastInputSeq: p.LastInputSeq,
|
||||
|
||||
Reference in New Issue
Block a user