better deploy
Some checks failed
Dynamic Branch Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Dynamic Branch Deploy / build-and-deploy (push) Has been cancelled
This commit is contained in:
14
.github/workflows/deploy.yaml
vendored
14
.github/workflows/deploy.yaml
vendored
@@ -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
|
||||
|
||||
146
README.me
Normal file
146
README.me
Normal 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\! 🏃💨**
|
||||
24
k8s/app.yaml
24
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}"
|
||||
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
24
k8s/hpa.yaml
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -25,6 +25,7 @@ let maxRawBgIndex = 0;
|
||||
let lastTime = 0;
|
||||
let accumulator = 0;
|
||||
let lastPowerupTick = -9999;
|
||||
let nextSpawnTick = 0;
|
||||
|
||||
// Grafiken
|
||||
let sprites = {};
|
||||
|
||||
Reference in New Issue
Block a user