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
|
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 \
|
||||||
|
|||||||
18
Dockerfile
18
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
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
|
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
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
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 {
|
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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user