From 0412168c4e170ad0b63b2976d8a7e99759a08dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Thu, 27 Nov 2025 19:42:08 +0100 Subject: [PATCH 1/4] better deploy --- .github/workflows/deploy.yaml | 14 +++- README.me | 146 ++++++++++++++++++++++++++++++++++ k8s/app.yaml | 26 +++++- k8s/hpa.yaml | 24 ++++++ k8s/redis.yaml | 22 ++++- simulation.go | 81 ++++++++----------- static/js/config.js | 2 +- static/js/logic.js | 115 +++++++++++++------------- static/js/network.js | 7 ++ static/js/state.js | 1 + types.go | 1 + 11 files changed, 322 insertions(+), 117 deletions(-) create mode 100644 README.me create mode 100644 k8s/hpa.yaml diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 3ca96db..ee193e9 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -113,8 +113,20 @@ jobs: kubectl apply -f k8s/app.yaml -n ${{ env.TARGET_NS }} kubectl apply -f k8s/ingress.yaml -n ${{ env.TARGET_NS }} + # HPA (Autoscaling) nur für Main/Master Branch aktivieren + # Wir vergleichen den Namespace mit dem Repo-Namen + # Wenn Namespace == RepoName, dann sind wir im Main Branch + if [ "${{ env.TARGET_NS }}" == "${{ env.REPO_NAME }}" ]; then + echo "Main Branch detected: Applying HPA (Autoscaling)..." + kubectl apply -f k8s/hpa.yaml -n ${{ env.TARGET_NS }} + else + echo "Feature Branch: Skipping HPA." + # Optional: HPA löschen, falls es versehentlich da ist + kubectl delete hpa escape-game-hpa -n ${{ env.TARGET_NS }} --ignore-not-found + fi + # Force Update (damit das neue Image sicher geladen wird) - kubectl rollout restart deployment/escape-game -n ${{ env.TARGET_NS }} + kubectl rollout restart deployment/escap10e-game -n ${{ env.TARGET_NS }} # 6. Summary - name: Summary diff --git a/README.me b/README.me new file mode 100644 index 0000000..ed10123 --- /dev/null +++ b/README.me @@ -0,0 +1,146 @@ +# **🏃 Escape the Teacher** + +**A server-authoritative 2D endless runner built with Go, Redis, and JavaScript.** + +## **📖 About the Project** + +"Escape the Teacher" is a high-performance web game developed as a school project. You play as a student caught cheating, running away from an angry teacher. The game features increasing difficulty, power-ups, boss phases, and a competitive global leaderboard. +Unlike typical browser games, this project implements **anti-cheat architecture** usually found in multiplayer RTS or FPS games. The browser does not decide if you survived; the server does. + +## **✨ Features** + +### **🎮 Gameplay** + +* **Endless Progression:** The game speeds up over time. +* **Controls:** + * **Jump:** Space / Arrow Up / Tap / Left Click. + * **Crouch:** Arrow Down / Swipe Down (Mobile). +* **Power-Ups:** + * 🛡️ **Godmode:** Survives 3 hits. + * ⚾ **Baseball Bat:** Eliminates the next teacher obstacle. + * 👟 **Jumpboots:** Grants higher jumping power for a limited time. + * 💰 **Coins:** Bonus points (visual score only, does not affect game speed). +* **Dynamic Backgrounds:** Environment changes as you progress. +* **Boss Phases:** Every 1500 ticks, special boss enemies spawn. + +### **📱 Mobile First** + +* Fully responsive Canvas rendering (Letterboxing). +* Touch gestures (Swipe to crouch). +* "Rotate Device" enforcement for optimal gameplay. + +### **🛡️ Security & Admin** + +* **Admin Panel:** Password-protected (/admin) interface. +* **Moderation:** Review and delete leaderboard entries. +* **Badword Filter:** Automatic blocking of inappropriate names using Redis Sets. +* **Proof System:** Players receive an 8-character "Claim Code" to prove their high score to the teacher. + +## **🏗️ Technical Architecture** + +The core philosophy is **"Server-Authoritative, Client-Predicted"**. + +1. **Frontend (JS):** Captures inputs and renders the game state immediately (Client-Side Prediction) to ensure zero input lag. It sends input logs to the server in chunks. +2. **Backend (Go):** Validates the inputs by re-simulating the game physics tick-by-tick. +3. **Database (Redis):** Stores active sessions, RNG states, and leaderboards (Sorted Sets). + +### **Tech Stack** + +* **Backend:** Go (Golang) 1.21+ +* **Frontend:** Vanilla JavaScript (Canvas API), CSS3 +* **Database:** Redis +* **Containerization:** Docker (Multi-Stage Build: Node Minifier \-\> Go Builder \-\> Alpine Runner) +* **Orchestration:** Kubernetes (Deployment, Service, Ingress) + +## **🔧 Development Challenges & Solutions** + +Developing a cheat-proof game for the web came with significant technical hurdles. Here is how we solved them: + +### **1\. The "Butterfly Effect" (RNG Desynchronization)** + +* **Problem:** JavaScript's Math.random() and Go's rand implementations are different. Even if seeded with the same number, they produce different sequences. This caused "Ghost Objects" (Server spawns a teacher, Client spawns a coin). +* **Solution:** We implemented a custom **Linear Congruential Generator (LCG)** in both languages. +* **The Tricky Part:** JavaScript uses 64-bit Floating Point numbers for everything, while Go uses strict types. Math operations drifted apart after a few hundred iterations. +* **Fix:** We forced JavaScript to use BigInt to simulate 32-bit integer overflows exactly like Go's uint32. + +### **2\. Floating Point Drift & Spawning** + +* **Problem:** We initially spawned objects based on pixel positions (if x \< 800). Due to floating point precision errors, the client sometimes calculated 799.99 (Spawn\!) while the server calculated 800.01 (Wait\!). This desynchronized the RNG state immediately. +* **Solution:** **Tick-Based Spawning.** We decoupled spawning from spatial positions. The game now decides: *"The next object spawns at Tick 500"* rather than *"at Pixel 1200"*. Integers don't drift. + +### **3\. Logic-Score Coupling** + +* **Problem:** Initially, game speed and boss phases depended on the *Score*. When we added Coins (+2000 Points), the client's score jumped instantly, speeding up the game on the client side before the server acknowledged the coin pickup. This caused massive desyncs. +* **Solution:** We decoupled logic from the visual score. Game difficulty now depends strictly on **"Ticks Alive" (Time)**, which cannot be manipulated by picking up items. + +### **4\. Physics Tunneling (High Speed Collisions)** + +* **Problem:** At high speeds, the player could move 20 pixels per frame. If an obstacle was only 15 pixels wide, the player effectively "teleported" through it without triggering a collision. +* **Solution:** **Continuous Collision Detection (CCD).** We extend the hitbox of obstacles dynamically based on their current speed (width \+ speed). + +### **5\. "Ghost Hits" (The CCD Side Effect)** + +* **Problem:** The CCD fix caused a new issue where the extended hitbox would hit the player *after* they had already jumped over the obstacle (hitting them from behind). +* **Solution:** A **"Passed Check"**. Before checking collisions, we verify if the obstacle's right edge is already physically behind the player's left edge. If so, collision is ignored. + +## **🚀 Getting Started** + +### **Using Docker (Recommended)** + +This project includes a production-ready Dockerfile and docker-compose.yml. + +1. **Clone the repository:** + git clone \[https://git.zb-server.de/ZB-Server/it232Abschied.git\](https://git.zb-server.de/ZB-Server/it232Abschied.git) + cd it232Abschied + +2. Configure Environment (Optional): + Edit docker-compose.yml to set your admin credentials: + environment: + \- ADMIN\_USER=teacher + \- ADMIN\_PASS=secret123 + +3. **Run:** + docker-compose up \--build \-d + +4. **Play:** Open http://localhost:8080 + +### **Local Development (Go & Redis)** + +1. Start Redis: + docker run \-d \-p 6379:6379 redis:alpine + +2. Start the Server: + go run . + + *(Note: Use go run . to include all files, not just main.go)* + +## **📂 Project Structure** + +. +├── k8s/ \# Kubernetes manifests +├── static/ \# Frontend files +│ ├── assets/ \# Images & Sprites +│ ├── fonts/ \# Local GDPR-compliant fonts +│ ├── js/ \# Modular Game Engine +│ │ ├── config.js \# Constants +│ │ ├── logic.js \# Physics & Collision +│ │ ├── network.js \# Server-Sync +│ │ └── ... +│ ├── index.html \# Entry Point +│ └── style.css \# Retro Design +├── secure/ \# Protected Admin Files +├── main.go \# Go Server Entrypoint +├── simulation.go \# Server-side Physics Engine +├── rng.go \# Deterministic RNG +├── types.go \# Data Structures +├── Dockerfile \# Multi-Stage Build +└── ... + +## **📜 Legal** + +This is a non-commercial educational project. + +* **Privacy:** No tracking cookies are used. Highscores are stored in Redis; local scores in LocalStorage. +* **Assets:** Font "Press Start 2P" is hosted locally. + +**Good luck escaping\! 🏃💨** \ No newline at end of file diff --git a/k8s/app.yaml b/k8s/app.yaml index 1ed8e08..4ce38bb 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -31,12 +31,30 @@ spec: ports: - containerPort: 8080 env: - # Kubernetes DNS: "service-name:port" - # Da wir im selben Namespace sind, reicht "redis:6379" - name: REDIS_ADDR value: "redis:6379" - # Admin Zugangsdaten (werden von CI injected oder hier fest) - name: ADMIN_USER value: "${ADMIN_USER}" - name: ADMIN_PASS - value: "${ADMIN_PASS}" \ No newline at end of file + value: "${ADMIN_PASS}" + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 15 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: / + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 \ No newline at end of file diff --git a/k8s/hpa.yaml b/k8s/hpa.yaml new file mode 100644 index 0000000..ab70620 --- /dev/null +++ b/k8s/hpa.yaml @@ -0,0 +1,24 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: escape-game-hpa +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: escape-game + minReplicas: 3 + maxReplicas: 20 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 \ No newline at end of file diff --git a/k8s/redis.yaml b/k8s/redis.yaml index c27247f..e82ebcf 100644 --- a/k8s/redis.yaml +++ b/k8s/redis.yaml @@ -38,8 +38,7 @@ spec: initContainers: - name: fix-permissions image: busybox - # Ändert den Besitzer von /data auf User 999 (redis) und Gruppe 999 - command: [ "sh", "-c", "chown -R 999:999 /data" ] + command: ["sh", "-c", "chown -R 999:999 /data"] volumeMounts: - name: data mountPath: /data @@ -51,6 +50,25 @@ spec: volumeMounts: - name: data mountPath: /data + resources: + requests: + memory: "64Mi" + cpu: "50m" # 0.05 CPU Cores + limits: + memory: "256Mi" + cpu: "1000m" # 0.5 CPU Cores + livenessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + + readinessProbe: + exec: + command: ["redis-cli", "ping"] + initialDelaySeconds: 5 + periodSeconds: 10 volumes: - name: data persistentVolumeClaim: diff --git a/simulation.go b/simulation.go index c0ee4a8..5a00d46 100644 --- a/simulation.go +++ b/simulation.go @@ -8,20 +8,19 @@ import ( "strconv" ) -func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, PowerUpState, int) { - // 1. State laden +func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, PowerUpState, int, int) { posY := parseOr(vals["pos_y"], PlayerYBase) velY := parseOr(vals["vel_y"], 0.0) score := int(parseOr(vals["score"], 0)) ticksAlive := int(parseOr(vals["total_ticks"], 0)) rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64) - // Powerups laden + nextSpawnTick := int(parseOr(vals["next_spawn_tick"], 0)) + godLives := int(parseOr(vals["p_god_lives"], 0)) hasBat := vals["p_has_bat"] == "1" bootTicks := int(parseOr(vals["p_boot_ticks"], 0)) - // Anti-Cheat State lastJumpDist := parseOr(vals["ac_last_dist"], 0.0) suspicionScore := int(parseOr(vals["ac_suspicion"], 0)) @@ -34,12 +33,6 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st obstacles = []ActiveObstacle{} } - // --- DEBUG: Chunk Info --- - if len(inputs) > 0 { - log.Printf("📦 [%s] Processing Chunk: %d Ticks, %d Inputs", sessionID, totalTicks, len(inputs)) - } - - // --- ANTI-CHEAT: Spam Check --- jumpCount := 0 for _, inp := range inputs { if inp.Act == "JUMP" { @@ -48,35 +41,29 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } if jumpCount > 10 { log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge (%d)", sessionID, jumpCount) - return true, score, obstacles, PowerUpState{}, ticksAlive + return true, score, obstacles, PowerUpState{}, ticksAlive, nextSpawnTick } playerDead := false - // --- SIMULATION LOOP --- for i := 0; i < totalTicks; i++ { ticksAlive++ - // 1. Speed (Zeitbasiert) currentSpeed := BaseSpeed + (float64(ticksAlive)/3000.0)*0.5 if currentSpeed > 12.0 { currentSpeed = 12.0 } - // 2. Powerups Timer currentJumpPower := JumpPower if bootTicks > 0 { currentJumpPower = HighJumpPower bootTicks-- } - // 3. Input Verarbeitung (MIT DEBUG LOG) didJump := false isCrouching := false for _, inp := range inputs { if inp.Tick == i { - log.Printf("🕹️ [%s] ACTION at Tick %d: %s", sessionID, ticksAlive, inp.Act) - if inp.Act == "JUMP" { didJump = true } @@ -86,7 +73,6 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } } - // 4. Physik isGrounded := posY >= PlayerYBase-1.0 currentHeight := PlayerHeight if isCrouching { @@ -99,7 +85,6 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st if didJump && isGrounded && !isCrouching { velY = currentJumpPower - // Heuristik Anti-Cheat (Abstand messen) var distToObs float64 = -1.0 for _, o := range obstacles { if o.X > 50.0 { @@ -130,9 +115,7 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st hitboxY = posY + (PlayerHeight - currentHeight) } - // 5. Hindernisse & Kollision nextObstacles := []ActiveObstacle{} - rightmostX := 0.0 for _, obs := range obstacles { obs.X -= currentSpeed @@ -141,13 +124,11 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st continue } - // Passed Check paddingX := 10.0 - if obs.X+obs.Width-paddingX < 55.0 { + realRightEdge := obs.X + obs.Width - paddingX + + if realRightEdge < 55.0 { nextObstacles = append(nextObstacles, obs) - if obs.X+obs.Width > rightmostX { - rightmostX = obs.X + obs.Width - } continue } @@ -195,19 +176,19 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } nextObstacles = append(nextObstacles, obs) - if obs.X+obs.Width > rightmostX { - rightmostX = obs.X + obs.Width - } } obstacles = nextObstacles - // 6. Spawning - if rightmostX < GameWidth-10.0 { - gap := float64(int(400.0 + rng.NextRange(0, 500))) - spawnX := rightmostX + gap - if spawnX < GameWidth { - spawnX = GameWidth - } + if nextSpawnTick == 0 { + nextSpawnTick = ticksAlive + 50 + } + + if ticksAlive >= nextSpawnTick { + gapPixel := 400 + int(rng.NextRange(0, 500)) + ticksToWait := int(float64(gapPixel) / currentSpeed) + nextSpawnTick = ticksAlive + ticksToWait + + spawnX := GameWidth + 50.0 isBossPhase := (ticksAlive % 1500) > 1200 var possibleDefs []ObstacleDef @@ -261,7 +242,7 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } } - if suspicionScore > 10 { + if suspicionScore > 15 { log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID) playerDead = true } @@ -273,27 +254,27 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ - "score": score, - "total_ticks": ticksAlive, - "pos_y": fmt.Sprintf("%f", posY), - "vel_y": fmt.Sprintf("%f", velY), - "rng_state": rng.State, - "obstacles": string(obsJson), - "p_god_lives": godLives, - "p_has_bat": batStr, - "p_boot_ticks": bootTicks, - "ac_last_dist": fmt.Sprintf("%f", lastJumpDist), - "ac_suspicion": suspicionScore, + "score": score, + "total_ticks": ticksAlive, + "next_spawn_tick": nextSpawnTick, + "pos_y": fmt.Sprintf("%f", posY), + "vel_y": fmt.Sprintf("%f", velY), + "rng_state": rng.State, + "obstacles": string(obsJson), + "p_god_lives": godLives, + "p_has_bat": batStr, + "p_boot_ticks": bootTicks, + "ac_last_dist": fmt.Sprintf("%f", lastJumpDist), + "ac_suspicion": suspicionScore, }) - // Return PowerUp State pState := PowerUpState{ GodLives: godLives, HasBat: hasBat, BootTicks: bootTicks, } - return playerDead, score, obstacles, pState, ticksAlive + return playerDead, score, obstacles, pState, ticksAlive, nextSpawnTick } func parseOr(s string, def float64) float64 { diff --git a/static/js/config.js b/static/js/config.js index 7107670..5eefcc0 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -10,7 +10,7 @@ const CHUNK_SIZE = 60; const TARGET_FPS = 60; const MS_PER_TICK = 1000 / TARGET_FPS; -const DEBUG_SYNC = false; +const DEBUG_SYNC = true; const SYNC_TOLERANCE = 5.0; // RNG Klasse diff --git a/static/js/logic.js b/static/js/logic.js index 6a350c2..1f35471 100644 --- a/static/js/logic.js +++ b/static/js/logic.js @@ -5,26 +5,20 @@ function updateGameLogic() { } // 2. Geschwindigkeit (Basiert auf ZEIT/Ticks, nicht Score!) - // Formel: Start bei 5, erhöht sich alle 3000 Ticks (ca. 50 Sek) um 0.5 let currentSpeed = 5 + (currentTick / 3000.0) * 0.5; - if (currentSpeed > 20.0) currentSpeed = 20.0; // Max Speed Cap + if (currentSpeed > 12.0) currentSpeed = 12.0; // 3. Spieler Physik & Größe const originalHeight = 50; const crouchHeight = 25; player.h = isCrouching ? crouchHeight : originalHeight; - - // Visuelle Korrektur Y (damit Füße am Boden bleiben) let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y; - // Schwerkraft player.vy += GRAVITY; - if (isCrouching && !player.grounded) player.vy += 2.0; // Schneller fallen (Fast Fall) - + if (isCrouching && !player.grounded) player.vy += 2.0; // Fast Fall player.y += player.vy; - // Boden-Kollision if (player.y + originalHeight >= GROUND_Y) { player.y = GROUND_Y - originalHeight; player.vy = 0; @@ -35,43 +29,54 @@ function updateGameLogic() { // 4. Hindernisse Bewegen & Kollision let nextObstacles = []; - let rightmostX = 0; for (let obs of obstacles) { obs.x -= currentSpeed; - // Hitbox für aktuellen Frame + // Aufräumen, wenn links raus + if (obs.x + obs.def.width < -50.0) continue; + + // --- PASSED CHECK (Wichtig!) --- + // Wenn das Hindernis den Spieler schon passiert hat, ignorieren wir Kollisionen. + // Das verhindert "Geister-Treffer" von hinten durch CCD. + const paddingX = 10; + const realRightEdge = obs.x + obs.def.width - paddingX; + + // Spieler ist bei 50. Wir geben 5px Puffer. + if (realRightEdge < 55) { + nextObstacles.push(obs); // Behalten, aber keine Kollisionsprüfung mehr + continue; + } + // ------------------------------- + + // Kollisionsprüfung const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h }; if (checkCollision(playerHitbox, obs)) { - // A. MÜNZE (+2000 Punkte) + // A. COIN if (obs.def.type === "coin") { score += 2000; - continue; // Entfernen (Eingesammelt) + continue; // Entfernen } - // B. POWERUP (Aktivieren) + // B. POWERUP else if (obs.def.type === "powerup") { if (obs.def.id === "p_god") godModeLives = 3; if (obs.def.id === "p_bat") hasBat = true; if (obs.def.id === "p_boot") bootTicks = 600; - - lastPowerupTick = currentTick; - continue; // Entfernen (Eingesammelt) + lastPowerupTick = currentTick; // Für Sync merken + continue; // Entfernen } - // C. GEGNER / HINDERNIS + // C. GEGNER else { - // Schläger vs Lehrer if (hasBat && obs.def.type === "teacher") { - hasBat = false; // Schläger kaputt - continue; // Lehrer weg + hasBat = false; + continue; // Zerstört } - // Godmode vs Alles if (godModeLives > 0) { - godModeLives--; // Ein Leben weg - continue; // Hindernis ignoriert + godModeLives--; + continue; // Geschützt } - // Tod player.color = "darkred"; if (!isGameOver) { sendChunk(); @@ -80,53 +85,54 @@ function updateGameLogic() { } } - // Objekt behalten wenn noch im Bild - if (obs.x + obs.def.width > -100) { - nextObstacles.push(obs); - if (obs.x + obs.def.width > rightmostX) rightmostX = obs.x + obs.def.width; - } + nextObstacles.push(obs); } obstacles = nextObstacles; - // 5. Spawning (Basiert auf ZEIT/Ticks) - if (rightmostX < GAME_WIDTH - 10 && gameConfig) { - const gap = Math.floor(400 + rng.nextRange(0, 500)); - let spawnX = rightmostX + gap; - if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + // 5. Spawning (Zeitbasiert & Synchron) - // Boss Phase abhängig von Zeit (nicht Score, wegen Münzen!) + // Fallback für Init + if (typeof nextSpawnTick === 'undefined' || nextSpawnTick === 0) { + nextSpawnTick = currentTick + 50; + } + + if (currentTick >= nextSpawnTick && gameConfig) { + // A. Nächsten Termin berechnen + const gapPixel = Math.floor(400 + rng.nextRange(0, 500)); + const ticksToWait = Math.floor(gapPixel / currentSpeed); + nextSpawnTick = currentTick + ticksToWait; + + // B. Position setzen (Fix rechts außen) + let spawnX = GAME_WIDTH + 50; + + // C. Objekt auswählen const isBossPhase = (currentTick % 1500) > 1200; - let possibleObs = []; + gameConfig.obstacles.forEach(def => { if (isBossPhase) { - // Boss Phase: Nur Principal und Trashcan if (def.id === "principal" || def.id === "trashcan") possibleObs.push(def); } else { - // Normal Phase if (def.id === "principal") return; - // Eraser erst ab Tick 3000 (ca. 50 Sek) + // Eraser erst ab Tick 3000 if (def.id === "eraser" && currentTick < 3000) return; possibleObs.push(def); } }); - // Zufälliges Objekt wählen let def = rng.pick(possibleObs); - // RNG Sync: Sprechblasen + // RNG Sync: Speech let speech = null; if (def && def.canTalk) { - // WICHTIG: Reihenfolge muss 1:1 wie in Go sein if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); } - // RNG Sync: Powerup Seltenheit (nur 10% Chance) + // RNG Sync: Powerup Rarity if (def && def.type === "powerup") { if (rng.nextFloat() > 0.1) def = null; } - // Hinzufügen if (def) { const yOffset = def.yOffset || 0; obstacles.push({ @@ -141,23 +147,12 @@ function updateGameLogic() { function checkCollision(p, obs) { const paddingX = 10; - - - const realRightEdge = obs.x + obs.def.width - paddingX; - - if (realRightEdge < p.x + 5) { - return false; - } - - const paddingY_Top = (obs.def.type === "teacher") ? 25 : 10; const paddingY_Bottom = 5; - // Geschwindigkeit schätzen (oder global holen) für CCD - let currentSpeed = 5 + (score / 5000.0) * 0.5; // /5000 weil score hier /10 ist? - // Moment, in main.js ist 'score' der rohe Wert. Also /500. - // Da wir score global haben: - currentSpeed = 5 + (score / 500.0) * 0.5; + // Speed-basierte Hitbox-Erweiterung (CCD) + // Wir schätzen den Speed hier, damit er ungefähr dem Server entspricht + let currentSpeed = 5 + (currentTick / 3000.0) * 0.5; if (currentSpeed > 12.0) currentSpeed = 12.0; const pLeft = p.x + paddingX; @@ -166,8 +161,10 @@ function checkCollision(p, obs) { const pBottom = p.y + p.h - paddingY_Bottom; const oLeft = obs.x + paddingX; - // CCD Erweiterung + // Wir erweitern die Hitbox nach rechts um die Geschwindigkeit, + // um schnelle Durchschüsse zu verhindern. const oRight = obs.x + obs.def.width - paddingX + currentSpeed; + const oTop = obs.y + paddingY_Top; const oBottom = obs.y + obs.def.height - paddingY_Bottom; diff --git a/static/js/network.js b/static/js/network.js index 975c748..b920560 100644 --- a/static/js/network.js +++ b/static/js/network.js @@ -42,6 +42,13 @@ async function sendChunk() { } } + // Sync Spawning Timer + if (data.nextSpawnTick) { + if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) { + nextSpawnTick = data.nextSpawnTick; + } + } + } if (data.status === "dead") { diff --git a/static/js/state.js b/static/js/state.js index 7ca67f0..80701ab 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -25,6 +25,7 @@ let maxRawBgIndex = 0; let lastTime = 0; let accumulator = 0; let lastPowerupTick = -9999; +let nextSpawnTick = 0; // Grafiken let sprites = {}; diff --git a/types.go b/types.go index 83f5462..233c878 100644 --- a/types.go +++ b/types.go @@ -51,6 +51,7 @@ type ValidateResponse struct { ServerObs []ActiveObstacle `json:"serverObs"` PowerUps PowerUpState `json:"powerups"` ServerTick int `json:"serverTick"` + NextSpawnTick int `json:"nextSpawnTick"` } type StartResponse struct { From 7a088c29db839d31ca53d371c36a1ef807561bfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Thu, 27 Nov 2025 19:43:03 +0100 Subject: [PATCH 2/4] fix README.md --- README.me => README.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename README.me => README.md (100%) diff --git a/README.me b/README.md similarity index 100% rename from README.me rename to README.md From 1f2767a3d47166f81db1c8ff7d122de8f26c03ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Thu, 27 Nov 2025 20:14:51 +0100 Subject: [PATCH 3/4] fix Sync Spawn Timer --- handlers.go | 3 ++- static/js/network.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/handlers.go b/handlers.go index 068ee0a..b387d06 100644 --- a/handlers.go +++ b/handlers.go @@ -65,7 +65,7 @@ func handleValidate(w http.ResponseWriter, r *http.Request) { } // ---> HIER RUFEN WIR JETZT DIE SIMULATION AUF <--- - isDead, score, obstacles, powerUpState, serverTick := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals) + isDead, score, obstacles, powerUpState, serverTick, nextSpawnTick := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals) status := "alive" if isDead { @@ -80,6 +80,7 @@ func handleValidate(w http.ResponseWriter, r *http.Request) { ServerObs: obstacles, PowerUps: powerUpState, ServerTick: serverTick, + NextSpawnTick: nextSpawnTick, }) } diff --git a/static/js/network.js b/static/js/network.js index b920560..1a9106a 100644 --- a/static/js/network.js +++ b/static/js/network.js @@ -43,8 +43,9 @@ async function sendChunk() { } // Sync Spawning Timer - if (data.nextSpawnTick) { + if (data.NextSpawnTick) { if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) { + console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick); nextSpawnTick = data.nextSpawnTick; } } From 913cd3e1396765a8d135d7200ab64c0e0dfe4016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Untersch=C3=BCtz?= Date: Thu, 27 Nov 2025 20:17:10 +0100 Subject: [PATCH 4/4] fix deploy --- .github/workflows/deploy.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index ee193e9..bbbb94e 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -126,7 +126,7 @@ jobs: fi # Force Update (damit das neue Image sicher geladen wird) - kubectl rollout restart deployment/escap10e-game -n ${{ env.TARGET_NS }} + kubectl rollout restart deployment/escape-game -n ${{ env.TARGET_NS }} # 6. Summary - name: Summary