Private
Public Access
1
0

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

Reviewed-on: #15
This commit is contained in:
2025-11-27 19:19:02 +00:00
12 changed files with 324 additions and 117 deletions

View File

@@ -113,6 +113,18 @@ 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 }}

146
README.md Normal file
View File

@@ -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\! 🏃💨**

View File

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

View File

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

24
k8s/hpa.yaml Normal file
View File

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

View File

@@ -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:

View File

@@ -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,20 +176,20 @@ 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
}
@@ -275,6 +256,7 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st
rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
"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,
@@ -286,14 +268,13 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st
"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 {

View File

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

View File

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

View File

@@ -42,6 +42,14 @@ async function sendChunk() {
}
}
// Sync Spawning Timer
if (data.NextSpawnTick) {
if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
nextSpawnTick = data.nextSpawnTick;
}
}
}
if (data.status === "dead") {

View File

@@ -25,6 +25,7 @@ let maxRawBgIndex = 0;
let lastTime = 0;
let accumulator = 0;
let lastPowerupTick = -9999;
let nextSpawnTick = 0;
// Grafiken
let sprites = {};

View File

@@ -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 {