Private
Public Access
1
0

Merge pull request 'add-hot-reload' (#18) from add-hot-reload into main
All checks were successful
Dynamic Branch Deploy / build-and-deploy (push) Successful in 1m44s

Reviewed-on: #18
This commit is contained in:
2025-11-30 18:35:59 +00:00
19 changed files with 417 additions and 548 deletions

192
README.md
View File

@@ -1,146 +1,130 @@
# **🏃 Escape the Teacher**
**A server-authoritative 2D endless runner built with Go, Redis, and JavaScript.**
**A high-performance, server-authoritative 2D endless runner built with Go, Redis, and WebSockets.**
## **📖 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.
"Escape the Teacher" is a web-based game developed as a final project (IT232). 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 **competitive multiplayer architecture** usually found in shooters or RTS games. The browser is treated as a "dumb" display terminal; the server simulates the entire world to prevent cheating.
**🔗 Repository:** [https://git.zb-server.de/ZB-Server/it232Abschied](https://git.zb-server.de/ZB-Server/it232Abschied)
---
## **✨ Features**
### **🎮 Gameplay**
* **Endless Progression:** The game speed increases 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.
* 💰 **Coins:** Bonus points for the highscore.
* **Level Editor:** Custom level segments ("Chunks") can be designed live in the Admin Panel and are streamed directly into active game sessions.
* **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.
### **🛡️ Security & Technology**
* **Server-Authoritative:** Physics runs entirely on the server (Go). Speedhacks or Godmode cheats are impossible.
* **Admin Panel:** Password-protected interface to manage levels, badwords, and leaderboards.
* **Proof System:** Players receive an 8-character "Claim Code" to validate their high score offline.
### **📱 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"**.
The project utilizes a **Realtime Streaming Architecture** to handle latency and synchronization.
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).
1. **Backend (Go):**
* Simulates physics at a fixed **20 TPS (Ticks Per Second)** to save CPU.
* Generates level chunks 5 seconds into the future.
* Streams object positions and game state via **WebSockets**.
2. **Frontend (JS):**
* **Client-Side Prediction:** Inputs are applied immediately for a "Zero-Lag" feel.
* **Buffering & Interpolation:** Although the server calculates at 20 FPS, the client renders at 60+ FPS by interpolating positions (`Lerp`) between server ticks.
* **Lag Compensation (RTT):** The client measures Round Trip Time and visually shifts server objects to match the client's timeline.
3. **Database (Redis):**
* Stores highscores, active session states, and level editor chunks.
### **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)
## **🔧 Engineering Challenges & Solutions**
## **🔧 Development Challenges & Solutions**
Building a lag-free, cheat-proof game on the web came with significant hurdles. Here is how we solved them:
Developing a cheat-proof game for the web came with significant technical hurdles. Here is how we solved them:
### **1. The "Netflix" Approach (Streaming vs. RNG Sync)**
* **Problem:** Initially, we tried to sync the Random Number Generators (RNG) between Client and Server. However, a single floating-point deviation caused a "Butterfly Effect," desynchronizing the entire game world.
* **Solution:** **Streaming.** The client no longer generates obstacles. The server pre-calculates the future and streams objects into a buffer on the client. The client simply plays back the stream, similar to a video player.
### **1\. The "Butterfly Effect" (RNG Desynchronization)**
### **2. Low Tick-Rate & Interpolation**
* **Problem:** To allow many concurrent players, the server physics runs at only **20 TPS**. Without smoothing, this looks choppy (like a slideshow) on a 144Hz monitor.
* **Solution:** **Linear Interpolation.** The rendering engine calculates the visual position between the current and the previous physics state (`alpha = accumulator / dt`). The result is buttery smooth motion despite the low server update rate.
* **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.
### **3. Latency & "Ghost Kills"**
* **Problem:** If the internet lags (e.g., 100ms Ping), the server sees the player hitting an obstacle that appeared "safe" on the client screen.
* **Solution:** **RTT Compensation.** The client constantly measures the network delay. Incoming objects from the server are visually shifted towards the player (`Latency * Speed`), ensuring they appear exactly where the server expects them to be at the moment of interaction.
### **2\. Floating Point Drift & Spawning**
### **4. Vertical Synchronization (Platforms)**
* **Problem:** Jumping onto platforms is tricky. At high speeds, the server might calculate that the player fell *through* a platform between two ticks ("Tunneling").
* **Solution:** **Vertical Sweeping & Client Authority override.** The server checks the *path* of the player, not just the position. Additionally, if the client detects a safe landing, it forces a physics sync update to the server to prevent unfair deaths.
* **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:**
```bash
git clone [https://git.zb-server.de/ZB-Server/it232Abschied.git](https://git.zb-server.de/ZB-Server/it232Abschied.git)
cd it232Abschied
```
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. **Run:**
```bash
docker-compose up --build -d
```
2. Configure Environment (Optional):
Edit docker-compose.yml to set your admin credentials:
environment:
\- ADMIN\_USER=teacher
\- ADMIN\_PASS=secret123
3. **Play:** Open `http://localhost:8080`
4. **Admin:** Open `http://localhost:8080/admin` (User: `lehrer`, Pass: `geheim123`)
3. **Run:**
docker-compose up \--build \-d
### **Local Development**
4. **Play:** Open http://localhost:8080
1. Start Redis:
```bash
docker run -d -p 6379:6379 redis:alpine
```
2. Start the Server:
```bash
go run .
```
### **Local Development (Go & Redis)**
---
1. Start Redis:
docker run \-d \-p 6379:6379 redis:alpine
## **🎶 Credits**
2. Start the Server:
go run .
This project was made possible by:
*(Note: Use go run . to include all files, not just main.go)*
* **Development & Code:** Sebastian Unterschütz
* **Music Design:** Max E.
* **Sound Effects:** Generated via [bfxr.net](https://www.bfxr.net/)
* **Graphics:** [Kenney.nl](https://kenney.nl) / Sebastian Unterschütz
* **Fonts:** "Press Start 2P" (Google Fonts / Local)
## **📂 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
└── ...
## **⚖️ License & Rights**
## **📜 Legal**
**© 2025 IT232 Final Project**
This is a non-commercial educational project.
This project is released under a proprietary, restrictive license:
* **Privacy:** No tracking cookies are used. Highscores are stored in Redis; local scores in LocalStorage.
* **Assets:** Font "Press Start 2P" is hosted locally.
1. **No Commercial Use:** The source code, assets, and the game itself may not be used for commercial purposes or sold.
2. **No Modification:** You are not allowed to modify, fork, or redistribute this project in modified form without express written permission from the authors.
3. **Educational Use:** Viewing and running the code for educational purposes within the context of the school project is permitted.
**Good luck escaping\! 🏃💨**
---
**Run for your grade! 🏃💨**

View File

@@ -21,7 +21,6 @@ const (
GameWidth = 800.0
)
// Globale Variablen
var (
ctx = context.Background()
rdb *redis.Client
@@ -58,30 +57,41 @@ func initGameConfig() {
{ID: "p_bat", Type: "powerup", Width: 30, Height: 30, Color: "red", Image: "powerup_bat1.png", YOffset: 20.0}, // Schläger
{ID: "p_boot", Type: "powerup", Width: 30, Height: 30, Color: "lime", Image: "powerup_boot1.png", YOffset: 20.0}, // Boots
},
// Mehrere Hintergründe für Level-Wechsel
Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"},
}
log.Println("✅ Config mit Powerups geladen")
loadChunksFromRedis()
defaultConfig.Chunks = loadChunksFromRedis()
}
func loadChunksFromRedis() {
// Gleiche Logik wie im Handler, aber speichert es in die globale Variable
func loadChunksFromRedis() []ChunkDef {
if rdb == nil {
return
} // Falls Redis noch nicht da ist
ids, _ := rdb.SMembers(ctx, "config:chunks:list").Result()
sort.Strings(ids) // WICHTIG
var chunks []ChunkDef
for _, id := range ids {
val, _ := rdb.Get(ctx, "config:chunks:data:"+id).Result()
var c ChunkDef
json.Unmarshal([]byte(val), &c)
chunks = append(chunks, c)
return []ChunkDef{}
}
defaultConfig.Chunks = chunks
log.Printf("📦 %d Custom Chunks geladen", len(chunks))
ids, err := rdb.SMembers(ctx, "config:chunks:list").Result()
if err != nil {
log.Println("Redis: Keine Chunks geladen")
return []ChunkDef{}
}
sort.Strings(ids)
var loadedChunks []ChunkDef
for _, id := range ids {
val, err := rdb.Get(ctx, "config:chunks:data:"+id).Result()
if err == nil {
var c ChunkDef
json.Unmarshal([]byte(val), &c)
c.ID = id
loadedChunks = append(loadedChunks, c)
}
}
if len(defaultConfig.Chunks) == 0 {
log.Printf("📦 Lade %d Chunks aus Redis", len(loadedChunks))
}
return loadedChunks
}

View File

@@ -137,7 +137,6 @@ func handleSubmitName(w http.ResponseWriter, r *http.Request) {
return
}
// Validierung
if len(req.Name) > 4 {
http.Error(w, "Zu lang", 400)
return
@@ -297,7 +296,6 @@ func handleClaimDelete(w http.ResponseWriter, r *http.Request) {
log.Printf("🗑️ USER DELETE: Session %s deleted via code", req.SessionID)
// Aus Listen entfernen
rdb.ZRem(ctx, "leaderboard:unverified", req.SessionID)
rdb.ZRem(ctx, "leaderboard:public", req.SessionID)
@@ -318,7 +316,6 @@ func generateClaimCode() string {
func handleAdminBadwords(w http.ResponseWriter, r *http.Request) {
key := "config:badwords"
// GET: Liste abrufen
if r.Method == http.MethodGet {
words, _ := rdb.SMembers(ctx, key).Result()
w.Header().Set("Content-Type", "application/json")
@@ -326,12 +323,11 @@ func handleAdminBadwords(w http.ResponseWriter, r *http.Request) {
return
}
// POST: Hinzufügen oder Löschen
if r.Method == http.MethodPost {
// Wir nutzen ein einfaches Struct für den Request
type WordReq struct {
Word string `json:"word"`
Action string `json:"action"` // "add" oder "remove"
Action string `json:"action"`
}
var req WordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

View File

@@ -35,7 +35,6 @@ func main() {
fs := http.FileServer(http.Dir("./static"))
http.Handle("/", fs)
// API Routes (jetzt mit Logger!)
http.HandleFunc("/api/config", Logger(handleConfig))
http.HandleFunc("/api/start", Logger(handleStart))
http.HandleFunc("/api/validate", Logger(handleValidate))
@@ -44,7 +43,6 @@ func main() {
http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard))
http.HandleFunc("/api/claim/delete", Logger(handleClaimDelete))
// Admin Routes (Logger + BasicAuth kombinieren)
http.HandleFunc("/admin", Logger(BasicAuth(handleAdminPage)))
http.HandleFunc("/api/admin/badwords", Logger(BasicAuth(handleAdminBadwords)))
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))

View File

@@ -6,40 +6,29 @@ import (
"time"
)
// Wir bauen unseren eigenen ResponseWriter, der den Status-Code "mitschreibt"
type StatusRecorder struct {
http.ResponseWriter
Status int
}
// Überschreiben der WriteHeader Methode, um den Code abzufangen
func (r *StatusRecorder) WriteHeader(status int) {
r.Status = status
r.ResponseWriter.WriteHeader(status)
}
// Die eigentliche Middleware Funktion
func Logger(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 1. Startzeit messen
start := time.Now()
// 2. Recorder initialisieren (Standard ist 200 OK)
recorder := &StatusRecorder{
ResponseWriter: w,
Status: http.StatusOK,
}
// 3. Den echten Handler aufrufen (mit unserem Recorder)
next(recorder, r)
// 4. Dauer berechnen
duration := time.Since(start)
// 5. Loggen
// Format: [METHODE] PFAD | STATUS | DAUER | IP
// Beispiel: [POST] /api/validate | 200 | 1.2ms | 127.0.0.1
icon := "✅"
if recorder.Status >= 400 {
icon = "⚠️"

View File

@@ -8,7 +8,6 @@ import (
"strconv"
)
// --- INTERNE STATE STRUKTUR ---
type SimState struct {
SessionID string
Score int
@@ -33,18 +32,14 @@ type SimState struct {
// Anti-Cheat
LastJumpDist float64
SuspicionScore int
}
// ============================================================================
// HAUPTFUNKTION
// ============================================================================
Chunks []ChunkDef
}
func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle, []ActivePlatform, PowerUpState, int, int, uint32) {
// 1. State laden
state := loadSimState(sessionID, vals)
// 2. Bot-Check
if isBotSpamming(inputs) {
log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge", sessionID)
state.IsDead = true
@@ -71,22 +66,17 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st
state.Score++
}
// 4. Anti-Cheat Heuristik
if state.SuspicionScore > 15 {
log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID)
state.IsDead = true
}
// 5. Speichern
saveSimState(&state)
return packResponse(&state)
}
// ============================================================================
// LOGIK & PHYSIK FUNKTIONEN
// ============================================================================
func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64) {
jumpPower := JumpPower
if s.BootTicks > 0 {
jumpPower = HighJumpPower
@@ -95,44 +85,55 @@ func updatePhysics(s *SimState, didJump, isCrouching bool, currentSpeed float64)
isGrounded := checkGrounded(s)
// Fehler behoben: "currentHeight declared but not used" entfernt.
// Wir brauchen es hier nicht, da checkPlatformLanding mit fixen 50.0 rechnet.
// Die Hitbox-Änderung passiert nur in checkCollisions.
if isCrouching {
if isCrouching && !isGrounded {
s.VelY += 2.0 // Fast Fall
if !isGrounded {
s.VelY += 2.0
}
}
if didJump && isGrounded && !isCrouching {
s.VelY = jumpPower
isGrounded = false
checkJumpSuspicion(s)
}
// 5. Schwerkraft anwenden
s.VelY += Gravity
oldY := s.PosY
newY := s.PosY + s.VelY
landed := false
// A. Plattform Landung (One-Way Logic)
if s.VelY > 0 {
playerFeetOld := oldY + PlayerHeight
playerFeetNew := newY + PlayerHeight
pLeft := 50.0
pRight := 50.0 + 30.0
for _, p := range s.Platforms {
hit, landY := checkPlatformLanding(p.X, p.Y, p.Width, 50.0, oldY, newY, s.VelY)
if hit {
newY = landY
s.VelY = 0
landed = true
break
if (pRight-5.0 > p.X) && (pLeft+5.0 < p.X+p.Width) {
if playerFeetOld <= p.Y && playerFeetNew >= p.Y {
newY = p.Y - PlayerHeight
s.VelY = 0
landed = true
isGrounded = true
break
}
}
}
}
// B. Boden Landung
if !landed {
if newY >= PlayerYBase {
newY = PlayerYBase
s.VelY = 0
isGrounded = true
}
}
@@ -150,7 +151,7 @@ func checkCollisions(s *SimState, isCrouching bool, currentSpeed float64) {
activeObs := []ActiveObstacle{}
for _, obs := range s.Obstacles {
// Passed Check
paddingX := 10.0
if obs.X+obs.Width-paddingX < 55.0 {
activeObs = append(activeObs, obs)
@@ -250,21 +251,19 @@ func handleSpawning(s *SimState, speed float64) {
if s.Ticks >= s.NextSpawnTick {
spawnX := GameWidth + 3200.0
// --- OPTION A: CUSTOM CHUNK (20% Chance) ---
chunkCount := len(defaultConfig.Chunks)
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
chunk := defaultConfig.Chunks[idx]
// Objekte spawnen
for _, p := range chunk.Platforms {
s.Platforms = append(s.Platforms, ActivePlatform{
X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height,
})
}
for _, o := range chunk.Obstacles {
// Fehler behoben: Zugriff auf o.X, o.Y jetzt möglich dank neuem Types-Struct
s.Obstacles = append(s.Obstacles, ActiveObstacle{
ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height,
})
@@ -275,20 +274,14 @@ func handleSpawning(s *SimState, speed float64) {
width = 2000
}
// Fehler behoben: Mismatched Types (int vs float64)
s.NextSpawnTick = s.Ticks + int(float64(width)/speed)
} else {
// --- OPTION B: RANDOM GENERATION ---
spawnRandomObstacle(s, speed, spawnX)
}
}
}
// ============================================================================
// HELPER
// ============================================================================
func loadSimState(sid string, vals map[string]string) SimState {
rngState, _ := strconv.ParseInt(vals["rng_state"], 10, 64)

View File

@@ -88,47 +88,91 @@
</div>
</div>
<div id="modal-impressum" class="modal-overlay">
<div id="modal-impressum" class="modal-overlay" style="display:none;">
<div class="modal-content">
<button class="close-modal" onclick="closeModal()">X</button>
<h2>Impressum</h2>
<p><strong>Angaben gemäß § 5 TMG:</strong></p>
<p>
<h2>Impressum & Credits</h2>
<p><strong>Projektleitung & Code:</strong><br>
Sebastian Unterschütz<br>
Göltzschtalblick 16 <br>
08236 Ellefeld
Göltzschtalblick 16<br>
08236 Ellefeld<br>
<small>Kontakt: sebastian@unterschutz.de</small>
</p>
<hr style="border:1px solid #444; margin: 15px 0;">
<p><strong>🎵 Musik Design:</strong><br>
<span style="color:#ffcc00; font-size:18px;">Max E.</span>
</p>
<p><strong>💻 Quellcode:</strong><br>
<a href="https://git.zb-server.de/ZB-Server/it232Abschied" target="_blank" style="color:#2196F3; text-decoration:none;">
git.zb-server.de/ZB-Server/it232Abschied
</a>
</p>
<hr style="border:1px solid #444; margin: 15px 0;">
<p><strong>⚖️ Lizenzhinweis:</strong></p>
<p style="font-size:12px; color:#aaa;">
Dies ist ein Schulprojekt. <br>
<strong>Kommerzielle Nutzung und Veränderung des Quellcodes sind ausdrücklich untersagt.</strong><br>
Alle Rechte liegen bei den Urhebern.
</p>
<p>Kontakt: sebastian@unterschuetz.de</p>
<p><em>Dies ist ein Schulprojekt ohne kommerzielle Absicht.</em></p>
</div>
</div>
<div id="modal-datenschutz" class="modal-overlay">
<div id="modal-datenschutz" class="modal-overlay" style="display:none;">
<div class="modal-content">
<button class="close-modal" onclick="closeModal()">X</button>
<h2>Datenschutz</h2>
<h2>Datenschutzerklärung</h2>
<p><strong>1. Allgemeines</strong><br>
Dies ist ein Schulprojekt. Wir speichern so wenig Daten wie möglich.</p>
<p><strong>1. Datenschutz auf einen Blick</strong><br>
Allgemeine Hinweise: Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen Sie persönlich identifiziert werden können.</p>
<p><strong>2. Welche Daten speichern wir?</strong><br>
Wenn du einen Highscore einträgst, speichern wir auf unserem Server:
<p><strong>2. Verantwortlicher</strong><br>
Verantwortlich für die Datenverarbeitung auf dieser Website ist:<br>
Sebastian Unterschütz<br>
Göltzschtalblick 16, 08236 Ellefeld<br>
E-Mail: sebastian@unterschutz.de<br>
<em>(Schulprojekt im Rahmen der IT232)</em></p>
<p><strong>3. Hosting (Hetzner)</strong><br>
Wir hosten die Inhalte unserer Website bei folgendem Anbieter:<br>
<strong>Hetzner Online GmbH</strong><br>
Industriestr. 25<br>
91710 Gunzenhausen<br>
Deutschland<br>
<br>
Serverstandort: <strong>Deutschland</strong> (ausschließlich).<br>
Wir haben mit dem Anbieter einen Vertrag zur Auftragsverarbeitung (AVV) geschlossen. Hierbei handelt es sich um einen datenschutzrechtlich vorgeschriebenen Vertrag, der gewährleistet, dass dieser die personenbezogenen Daten unserer Websitebesucher nur nach unseren Weisungen und unter Einhaltung der DSGVO verarbeitet.</p>
<p><strong>4. Datenerfassung auf dieser Website</strong></p>
<p><strong>Server-Log-Dateien</strong><br>
Der Provider der Seiten (Hetzner) erhebt und speichert automatisch Informationen in so genannten Server-Log-Dateien, die Ihr Browser automatisch an uns übermittelt. Dies sind:
<ul>
<li>Deinen gewählten Namen</li>
<li>Deinen Punktestand</li>
<li>Einen Zeitstempel</li>
<li>Einen zufälligen "Beweis-Code"</li>
<li>Uhrzeit der Serveranfrage</li>
<li>IP-Adresse</li>
</ul>
</p>
Eine Zusammenführung dieser Daten mit anderen Datenquellen wird nicht vorgenommen.<br>
<strong>Rechtsgrundlage:</strong> Art. 6 Abs. 1 lit. f DSGVO. Der Websitebetreiber hat ein berechtigtes Interesse an der technisch fehlerfreien Darstellung und der Optimierung seiner Website hierzu müssen die Server-Log-Files erfasst werden. Die Daten werden nach spätestens 7 Tagen automatisch gelöscht.</p>
<p><strong>3. Lokale Speicherung (Dein Gerät)</strong><br>
Das Spiel nutzt den "LocalStorage" deines Browsers, um deinen persönlichen Rekord und deine gesammelten Beweis-Codes zu speichern. Diese Daten verlassen dein Gerät nicht, außer du sendest sie aktiv ab.</p>
<p><strong>Spielstände & Highscores</strong><br>
Wenn Sie einen Highscore eintragen, speichern wir in unserer Datenbank:
<ul>
<li>Den von Ihnen gewählten Namen (Pseudonym empfohlen!)</li>
<li>Ihren Punktestand und Zeitstempel</li>
<li>Eine Session-ID und einen "Claim-Code" zur Verifizierung</li>
</ul>
Diese Daten dienen ausschließlich der Darstellung der Bestenliste und der Spielmechanik.</p>
<p><strong>4. Cookies & Tracking</strong><br>
Wir verwenden <strong>keine</strong> Tracking-Cookies, keine Analyse-Tools (wie Google Analytics) und laden keine Schriftarten von fremden Servern.</p>
<p><strong>Lokale Speicherung (LocalStorage)</strong><br>
Das Spiel speichert Einstellungen (z.B. "Ton aus") und Ihre persönlichen "Claim-Codes" lokal in Ihrem Browser (`LocalStorage`). Diese Daten verlassen Ihr Gerät nicht, außer Sie übermitteln einen Highscore aktiv an den Server. Wir setzen <strong>keine Tracking-Cookies</strong> oder Analyse-Tools (wie Google Analytics) ein.</p>
<p><strong>5. Deine Rechte</strong><br>
Du kannst deine Einträge jederzeit selbstständig über das Menü "Meine Codes" vom Server löschen.</p>
<p><strong>5. Ihre Rechte</strong><br>
Sie haben jederzeit das Recht auf unentgeltliche Auskunft über Ihre gespeicherten personenbezogenen Daten, deren Herkunft und Empfänger und den Zweck der Datenverarbeitung sowie ein Recht auf Berichtigung oder Löschung dieser Daten. Hierzu sowie zu weiteren Fragen zum Thema personenbezogene Daten können Sie sich jederzeit an die im Impressum angegebene Adresse wenden.</p>
</div>
</div>

View File

@@ -14,8 +14,7 @@ SOUNDS.hit.volume = 0.6;
SOUNDS.music.loop = true;
SOUNDS.music.volume = 0.2;
// --- STATUS LADEN ---
// Wir lesen den String 'true'/'false' aus dem LocalStorage
let isMuted = localStorage.getItem('escape_muted') === 'true';
function playSound(name) {
@@ -29,15 +28,13 @@ function playSound(name) {
function toggleMute() {
isMuted = !isMuted;
// --- STATUS SPEICHERN ---
localStorage.setItem('escape_muted', isMuted);
// Musik sofort pausieren/starten
if(isMuted) {
SOUNDS.music.pause();
} else {
// Nur starten, wenn wir schon im Spiel sind (user interaction needed)
// Wir fangen Fehler ab, falls der Browser Autoplay blockiert
SOUNDS.music.play().catch(()=>{});
}
@@ -45,13 +42,13 @@ function toggleMute() {
}
function startMusic() {
// Nur abspielen, wenn NICHT stummgeschaltet
if(!isMuted) {
SOUNDS.music.play().catch(e => console.log("Audio Autoplay blocked", e));
}
}
// Getter für UI
function getMuteState() {
return isMuted;
}

View File

@@ -1,27 +1,22 @@
// ==========================================
// SPIEL KONFIGURATION & KONSTANTEN
// ==========================================
// Dimensionen (Muss zum Canvas passen)
const GAME_WIDTH = 800;
const GAME_HEIGHT = 400;
// Physik (Muss exakt synchron zum Go-Server sein!)
const GRAVITY = 1.8;
const JUMP_POWER = -20.0; // Vorher -36.0 (Deutlich weniger!)
const HIGH_JUMP_POWER = -28.0;// Vorher -48.0 (Boots)
const GROUND_Y = 350; // Y-Position des Bodens
const JUMP_POWER = -20.0;
const HIGH_JUMP_POWER = -28.0;
const GROUND_Y = 350;
// Geschwindigkeit
const BASE_SPEED = 15.0;
// Game Loop Einstellungen
const TARGET_FPS = 20;
const MS_PER_TICK = 1000 / TARGET_FPS;
const CHUNK_SIZE = 20; // Intervall für Berechnungen (Legacy)
const CHUNK_SIZE = 20;
// Debugging
// true = Zeigt Hitboxen (Grün) und Server-Daten (Cyan)
const DEBUG_SYNC = true;
function lerp(a, b, t) {

View File

@@ -3,41 +3,31 @@
// ==========================================
function handleInput(action, active) {
// 1. Game Over Reset
if (isGameOver) {
if(active) location.reload();
return;
}
// 2. JUMP LOGIK
if (action === "JUMP" && active) {
// Wir prüfen lokal, ob wir springen dürfen (Client Prediction)
if (player.grounded && !isCrouching) {
// A. Sofort lokal anwenden (damit es sich direkt anfühlt)
player.vy = JUMP_POWER;
player.grounded = false;
playSound('jump');
spawnParticles(player.x + 15, player.y + 50, 'dust', 5); // Staubwolke an den Füßen
// B. An Server senden ("Ich habe JETZT gedrückt")
// Die Funktion sendInput ist in network.js definiert
if (typeof sendInput === "function") {
sendInput("input", "JUMP");
}
}
}
// 3. DUCK LOGIK
if (action === "DUCK") {
// Status merken, um unnötiges Senden zu vermeiden
const wasCrouching = isCrouching;
// A. Lokal anwenden
isCrouching = active;
// B. An Server senden (State Change: Start oder Ende)
if (wasCrouching !== isCrouching) {
if (typeof sendInput === "function") {
sendInput("input", active ? "DUCK_START" : "DUCK_END");
@@ -50,9 +40,9 @@ function handleInput(action, active) {
// EVENT LISTENERS
// ==========================================
// Tastatur
window.addEventListener('keydown', (e) => {
// Ignorieren, wenn User gerade Name in Highscore tippt
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'Space' || e.code === 'ArrowUp') handleInput("JUMP", true);
@@ -61,7 +51,6 @@ window.addEventListener('keydown', (e) => {
e.preventDefault();
console.log("🐞 Fordere Debug-Daten vom Server an...");
if (typeof sendInput === "function") {
// Wir senden ein manuelles Paket, da sendInput meist nur für Game-Inputs ist
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: "debug" }));
}

View File

@@ -1,8 +1,4 @@
function updateGameLogic() {
// ===============================================
// 1. GESCHWINDIGKEIT
// ===============================================
// Wir nutzen den lokalen Score für die Geschwindigkeit
let currentSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
if (currentSpeed > 36.0) currentSpeed = 36.0;
@@ -14,33 +10,25 @@ function updateGameLogic() {
obstacleBuffer.forEach(o => o.prevX = o.x);
platformBuffer.forEach(p => p.prevX = p.x);
// ===============================================
// 2. SPIELER PHYSIK (CLIENT PREDICTION)
// ===============================================
const originalHeight = 50;
const crouchHeight = 25;
// Hitbox & Y-Pos anpassen
player.h = isCrouching ? crouchHeight : originalHeight;
let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y;
// Alte Position (für One-Way Check)
const oldY = player.y;
// Physik
player.vy += GRAVITY;
if (isCrouching && !player.grounded) player.vy += 2.0;
let newY = player.y + player.vy;
let landed = false;
// --- PLATTFORMEN ---
if (player.vy > 0) {
for (let plat of platformBuffer) {
// Nur relevante Plattformen prüfen
if (plat.x < GAME_WIDTH + 100 && plat.x > -100) {
if (player.x + 30 > plat.x && player.x < plat.x + plat.w) {
// "Passed Check": Vorher drüber, jetzt drauf/drunter
const feetOld = oldY + originalHeight;
const feetNew = newY + originalHeight;
if (feetOld <= plat.y && feetNew >= plat.y) {
@@ -55,7 +43,7 @@ function updateGameLogic() {
}
}
// --- BODEN ---
if (!landed && newY + originalHeight >= GROUND_Y) {
newY = GROUND_Y - originalHeight;
player.vy = 0;
@@ -69,34 +57,28 @@ function updateGameLogic() {
player.y = newY;
player.grounded = landed;
// ===============================================
// 3. PUFFER BEWEGEN (STREAMING)
// ===============================================
obstacleBuffer.forEach(o => o.x -= currentSpeed);
platformBuffer.forEach(p => p.x -= currentSpeed);
// Aufräumen (Links raus)
obstacleBuffer = obstacleBuffer.filter(o => o.x + (o.w||30) > -200); // Muss -200 sein
platformBuffer = platformBuffer.filter(p => p.x + (p.w||100) > -200); // Muss -200 sein
// ===============================================
// 4. KOLLISION & TRANSFER (LOGIK + RENDER LISTE)
// ===============================================
obstacles = [];
platforms = [];
const RENDER_LIMIT = 900;
// Hitbox definieren (für lokale Prüfung)
const pHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
// --- HINDERNISSE ---
obstacleBuffer.forEach(obs => {
// Nur verarbeiten, wenn im Sichtbereich
if (obs.x < RENDER_LIMIT) {
// A. Metadaten laden (falls noch nicht da)
if (!obs.def) {
let baseDef = null;
if(gameConfig && gameConfig.obstacles) {
@@ -115,8 +97,6 @@ function updateGameLogic() {
};
}
// B. Kollision prüfen (Nur wenn noch nicht eingesammelt)
// Wir nutzen 'obs.collected' als Flag, damit wir Coins nicht doppelt zählen
if (!obs.collected && !isGameOver) {
if (checkCollision(pHitbox, obs)) {
@@ -125,8 +105,8 @@ function updateGameLogic() {
// 1. COIN
if (type === "coin") {
score += 2000; // Sofort addieren!
obs.collected = true; // Markieren als "weg"
score += 2000;
obs.collected = true;
playSound('coin');
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 10);
}
@@ -134,27 +114,27 @@ function updateGameLogic() {
else if (type === "powerup") {
if (id === "p_god") godModeLives = 3;
if (id === "p_bat") hasBat = true;
if (id === "p_boot") bootTicks = 600; // ca. 10 Sekunden
if (id === "p_boot") bootTicks = 600;
playSound('powerup');
spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 20); // Mehr Partikel
obs.collected = true; // Markieren als "weg"
obs.collected = true;
}
// 3. GEGNER (Teacher/Obstacle)
else {
// Baseballschläger vs Lehrer
if (hasBat && type === "teacher") {
hasBat = false;
obs.collected = true; // Wegschlagen
obs.collected = true;
playSound('hit');
spawnParticles(obs.x, obs.y, 'explosion', 5);
// Effekt?
}
// Godmode (Schild)
else if (godModeLives > 0) {
godModeLives--;
// Optional: Gegner entfernen oder durchlaufen lassen?
// Hier entfernen wir ihn, damit man nicht 2 Leben im selben Objekt verliert
playSound('hit');
spawnParticles(obs.x, obs.y, 'explosion', 5);
obs.collected = true;
}
// TOT
@@ -170,14 +150,13 @@ function updateGameLogic() {
}
}
// C. Zur Render-Liste hinzufügen (Nur wenn NICHT eingesammelt)
if (!obs.collected) {
obstacles.push(obs);
}
}
});
// --- PLATTFORMEN ---
platformBuffer.forEach(plat => {
if (plat.x < RENDER_LIMIT) {
platforms.push(plat);
@@ -185,17 +164,17 @@ function updateGameLogic() {
});
}
// Helper: Robuste Kollisionsprüfung
function checkCollision(p, obs) {
const def = obs.def || {};
const w = def.width || obs.w || 30;
const h = def.height || obs.h || 30;
// Kleines Padding, damit es fair ist
const padX = 8;
const padY = (def.type === "teacher" || def.type === "principal") ? 20 : 5;
// Koordinaten
const pL = p.x + padX;
const pR = p.x + p.w - padX;
const pT = p.y + padY;

View File

@@ -30,9 +30,7 @@ async function loadAssets() {
await Promise.all([pPromise, ...bgPromises, ...obsPromises]);
}
// ==========================================
// 2. SPIEL STARTEN
// ==========================================
window.startGameClick = async function() {
if (!isLoaded) return;
@@ -50,9 +48,7 @@ window.startGameClick = async function() {
resize();
};
// ==========================================
// 3. GAME OVER & HIGHSCORE LOGIK
// ==========================================
window.gameOver = function(reason) {
if (isGameOver) return;
isGameOver = true;
@@ -69,16 +65,16 @@ window.gameOver = function(reason) {
gameOverScreen.style.display = 'flex';
document.getElementById('finalScore').innerText = finalScore;
// Input wieder anzeigen
document.getElementById('inputSection').style.display = 'flex';
document.getElementById('submitBtn').disabled = false;
// Liste laden
loadLeaderboard();
}
};
// Name absenden (Button Click)
window.submitScore = async function() {
const nameInput = document.getElementById('playerNameInput');
const name = nameInput.value.trim();
@@ -91,14 +87,14 @@ window.submitScore = async function() {
const res = await fetch('/api/submit-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ sessionId: sessionID, name: name }) // sessionID aus state.js
body: JSON.stringify({ sessionId: sessionID, name: name })
});
if (!res.ok) throw new Error("Fehler beim Senden");
const data = await res.json();
// Code lokal speichern (Claims)
let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
myClaims.push({
name: name,
@@ -109,7 +105,7 @@ window.submitScore = async function() {
});
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
// UI Update
document.getElementById('inputSection').style.display = 'none';
loadLeaderboard();
alert(`Gespeichert! Dein Code: ${data.claimCode}`);
@@ -121,10 +117,10 @@ window.submitScore = async function() {
}
};
// Bestenliste laden (Game Over Screen)
async function loadLeaderboard() {
try {
// sessionID wird mitgesendet, um den eigenen Eintrag zu markieren
const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
const entries = await res.json();
@@ -149,9 +145,7 @@ async function loadLeaderboard() {
}
}
// ==========================================
// 4. GAME LOOP
// ==========================================
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
@@ -183,9 +177,7 @@ function gameLoop(timestamp) {
drawGame(isGameRunning ? accumulator / MS_PER_TICK : 1.0);
}
// ==========================================
// 5. INIT
// ==========================================
async function initGame() {
try {
const cRes = await fetch('/api/config');
@@ -215,7 +207,7 @@ async function initGame() {
}
}
// Helper: Mini-Leaderboard auf Startseite
async function loadStartScreenLeaderboard() {
try {
const listEl = document.getElementById('startLeaderboardList');
@@ -235,15 +227,13 @@ async function loadStartScreenLeaderboard() {
} catch (e) {}
}
// Audio Toggle Funktion für den Button
window.toggleAudioClick = function() {
// 1. Audio umschalten (in audio.js)
const muted = toggleMute();
// 2. Button Icon updaten
updateMuteIcon(muted);
// 3. Fokus vom Button nehmen (damit Space nicht den Button drückt, sondern springt)
document.getElementById('mute-btn').blur();
};
@@ -256,19 +246,15 @@ function updateMuteIcon(isMuted) {
}
}
// ==========================================
// MEINE CODES (LOCAL STORAGE)
// ==========================================
// 1. Codes anzeigen (Wird vom Button im Startscreen aufgerufen)
window.showMyCodes = function() {
// Modal öffnen
openModal('codes');
const listEl = document.getElementById('codesList');
if(!listEl) return;
// Daten aus dem Browser-Speicher holen
const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
if (rawClaims.length === 0) {
@@ -276,7 +262,7 @@ window.showMyCodes = function() {
return;
}
// Sortieren nach Score (Höchster zuerst)
const sortedClaims = rawClaims
.map((item, index) => ({ ...item, originalIndex: index }))
.sort((a, b) => b.score - a.score);
@@ -284,7 +270,7 @@ window.showMyCodes = function() {
let html = "";
sortedClaims.forEach(c => {
// Icons basierend auf Score
let rankIcon = "📄";
if (c.score >= 5000) rankIcon = "⭐";
if (c.score >= 10000) rankIcon = "🔥";
@@ -307,11 +293,11 @@ window.showMyCodes = function() {
listEl.innerHTML = html;
};
// 2. Code löschen (Lokal und auf Server)
window.deleteClaim = async function(sid, code) {
if(!confirm("Eintrag wirklich löschen?")) return;
// Versuch, es auf dem Server zu löschen
try {
await fetch('/api/claim/delete', {
method: 'POST',
@@ -322,25 +308,23 @@ window.deleteClaim = async function(sid, code) {
console.warn("Server Delete fehlgeschlagen (vielleicht schon weg), lösche lokal...");
}
// Lokal löschen
let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
// Wir filtern den Eintrag raus, der die gleiche SessionID UND den gleichen Code hat
claims = claims.filter(c => c.code !== code);
localStorage.setItem('escape_claims', JSON.stringify(claims));
// Liste aktualisieren
window.showMyCodes();
// Leaderboard aktualisieren (falls im Hintergrund sichtbar)
if(document.getElementById('startLeaderboardList')) {
loadStartScreenLeaderboard();
}
};
// ==========================================
// MODAL LOGIK (Fenster auf/zu)
// ==========================================
window.openModal = function(id) {
const el = document.getElementById('modal-' + id);
if(el) el.style.display = 'flex';
@@ -351,7 +335,7 @@ window.closeModal = function() {
modals.forEach(el => el.style.display = 'none');
}
// Klick nebendran schließt Modal
window.onclick = function(event) {
if (event.target.classList.contains('modal-overlay')) {
closeModal();

View File

@@ -1,35 +1,18 @@
// ==========================================
// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
// ==========================================
/*
GLOBALE VARIABLEN (aus state.js):
- socket
- obstacleBuffer, platformBuffer
- currentLatencyMs, pingInterval
- isGameRunning, isGameOver
- score, currentTick
*/
function connectGame() {
// Alte Verbindung schließen
if (socket) {
socket.close();
}
// Ping Timer stoppen falls aktiv
if (typeof pingInterval !== 'undefined' && pingInterval) {
clearInterval(pingInterval);
}
// Protokoll automatisch wählen (ws:// oder wss://)
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = proto + "//" + location.host + "/ws";
console.log("Verbinde zu:", url);
socket = new WebSocket(url);
// --- 1. VERBINDUNG GEÖFFNET ---
socket.onopen = () => {
console.log("🟢 WS Verbunden. Spiel startet.");
@@ -38,88 +21,72 @@ function connectGame() {
platformBuffer = [];
obstacles = [];
platforms = [];
currentLatencyMs = 0; // Reset Latenz
currentLatencyMs = 0;
isGameRunning = true;
isGameOver = false;
isLoaded = true;
// PING LOOP STARTEN (Jede Sekunde messen)
pingInterval = setInterval(sendPing, 1000);
// Game Loop anwerfen
requestAnimationFrame(gameLoop);
};
// --- 2. NACHRICHT VOM SERVER ---
socket.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
// A. PONG (Latenzmessung)
if (msg.type === "pong") {
const now = Date.now();
const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
const sentTime = msg.ts;
// Round Trip Time (Hin + Zurück)
const rtt = now - sentTime;
// One Way Latency (Latenz in eine Richtung)
const latency = rtt / 2;
// Glätten (Exponential Moving Average), damit Werte nicht springen
// Wenn es der erste Wert ist, nehmen wir ihn direkt.
if (currentLatencyMs === 0) {
currentLatencyMs = latency;
} else {
// 90% alter Wert, 10% neuer Wert
currentLatencyMs = (currentLatencyMs * 0.9) + (latency * 0.1);
}
// Optional: Debugging im Log
// console.log(`📡 Ping: ${rtt}ms | Latenz: ${currentLatencyMs.toFixed(1)}ms`);
}
// B. CHUNK (Objekte empfangen)
if (msg.type === "chunk") {
// 1. CLOCK SYNC (Die Zeitmaschine)
// Wenn der Server bei Tick 204 ist und wir bei 182, müssen wir aufholen!
// Wir addieren die geschätzte Latenz (in Ticks) auf die Serverzeit.
// 60 FPS = 16ms/Tick. 20 TPS = 50ms/Tick.
const msPerTick = 1000 / 20; // WICHTIG: Wir laufen auf 20 TPS Basis!
const msPerTick = 1000 / 20;
const latencyInTicks = Math.floor(currentLatencyMs / msPerTick);
// Ziel-Zeit: Server-Zeit + Übertragungsweg
const targetTick = msg.serverTick + latencyInTicks;
const drift = targetTick - currentTick;
// Wenn wir mehr als 2 Ticks abweichen -> Korrigieren
if (Math.abs(drift) > 2) {
// console.log(`⏰ Clock Sync: ${currentTick} -> ${targetTick} (Drift: ${drift})`);
currentTick = targetTick; // Harter Sync, damit Physik stimmt
currentTick = targetTick;
}
// 2. PIXEL KORREKTUR (Sanfter!)
// Wir berechnen den Speed
let sTick = msg.serverTick;
// Formel aus logic.js (Base 15 + Zeit)
let currentSpeedPerTick = 15.0 + (sTick / 1000.0) * 1.5;
if (currentSpeedPerTick > 36) currentSpeedPerTick = 36;
const speedPerMs = currentSpeedPerTick / msPerTick; // Speed pro MS
const speedPerMs = currentSpeedPerTick / msPerTick;
// Korrektur: Latenz * Speed
// FIX: Wir kappen die Korrektur bei max 100px, damit Objekte nicht "teleportieren".
let dynamicCorrection = (currentLatencyMs * speedPerMs) + 5;
if (dynamicCorrection > 100) dynamicCorrection = 100; // Limit
// Puffer füllen (mit Limit)
if (msg.obstacles) {
msg.obstacles.forEach(o => {
o.x -= dynamicCorrection;
// Init für Interpolation
o.prevX = o.x;
obstacleBuffer.push(o);
});
@@ -135,7 +102,6 @@ function connectGame() {
if (msg.score !== undefined) score = msg.score;
// Powerups übernehmen (für Anzeige)
if (msg.powerups) {
godModeLives = msg.powerups.godLives;
hasBat = msg.powerups.hasBat;
@@ -154,7 +120,6 @@ function connectGame() {
}
}
// C. TOD (Server Authoritative)
if (msg.type === "dead") {
console.log("💀 Server sagt: Game Over");
@@ -171,15 +136,11 @@ function connectGame() {
// 1. CLIENT SPEED BERECHNEN (Formel aus logic.js)
// Wir nutzen hier 'score', da logic.js das auch tut
let clientSpeed = BASE_SPEED + (currentTick / 1000.0) * 1.5;
if (clientSpeed > 36.0) clientSpeed = 36.0;
// 2. SERVER SPEED HOLEN
let serverSpeed = msg.currentSpeed || 0;
// 3. DIFF BERECHNEN
let diffSpeed = clientSpeed - serverSpeed;
let speedIcon = Math.abs(diffSpeed) < 0.01 ? "✅" : "❌";
@@ -194,12 +155,10 @@ function connectGame() {
console.warn(`⚠️ ACHTUNG: Geschwindigkeiten weichen ab! Diff: ${diffSpeed.toFixed(4)}`);
console.warn("Ursache: Client nutzt 'Score', Server nutzt 'Ticks'. Sind diese synchron?");
}
// -----------------------------
// 1. Hindernisse vergleichen
generateSyncTable("Obstacles", obstacles, msg.obstacles);
// 2. Plattformen vergleichen
generateSyncTable("Platforms", platforms, msg.platforms);
console.groupEnd();
@@ -210,7 +169,7 @@ function connectGame() {
}
};
// --- 3. VERBINDUNG GETRENNT ---
socket.onclose = () => {
console.log("🔴 WS Verbindung getrennt.");
if (pingInterval) clearInterval(pingInterval);
@@ -221,13 +180,9 @@ function connectGame() {
};
}
// ==========================================
// PING SENDEN
// ==========================================
function sendPing() {
if (socket && socket.readyState === WebSocket.OPEN) {
// Wir senden den aktuellen Zeitstempel
// Der Server muss diesen im "tick" Feld zurückschicken (siehe websocket.go)
socket.send(JSON.stringify({
type: "ping",
tick: Date.now() // Timestamp als Integer
@@ -235,9 +190,6 @@ function sendPing() {
}
}
// ==========================================
// INPUT SENDEN
// ==========================================
function sendInput(type, action) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
@@ -256,37 +208,33 @@ function generateSyncTable(label, clientList, serverList) {
const report = [];
const matchedServerIndices = new Set();
// 1. Parameter für Latenz-Korrektur berechnen
// Damit wir wissen: "Wo MÜSSTE das Server-Objekt auf dem Client sein?"
const msPerTick = 50; // Bei 20 TPS
// Speed Schätzung (gleiche Formel wie in logic.js)
const msPerTick = 50;
let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
if (debugSpeed > 36) debugSpeed = 36;
const speedPerMs = debugSpeed / msPerTick;
// Pixel, die das Objekt wegen Ping weiter "links" sein müsste
const latencyPx = currentLatencyMs * speedPerMs;
// 2. Client Objekte durchgehen
clientList.forEach((cObj) => {
let bestMatch = null;
let bestDist = 9999;
let bestSIdx = -1;
// ID sicherstellen
const cID = cObj.def ? cObj.def.id : (cObj.id || "unknown");
// Passendes Server-Objekt suchen
serverList.forEach((sObj, sIdx) => {
if (matchedServerIndices.has(sIdx)) return;
const sID = sObj.id || "unknown";
// Match Kriterien:
// 1. Gleiche ID (oder Plattform)
// 2. Nähe (Wir vergleichen hier die korrigierte Position!)
const sPosCorrected = sObj.x - latencyPx;
const dist = Math.abs(cObj.x - sPosCorrected);
@@ -310,12 +258,10 @@ function generateSyncTable(label, clientList, serverList) {
matchedServerIndices.add(bestSIdx);
serverXRaw = bestMatch.x;
serverXCorrected = bestMatch.x - latencyPx; // Hier rechnen wir den Ping raus
serverXCorrected = bestMatch.x - latencyPx;
// Der "Wahrs" Drift: Differenz nach Latenz-Abzug
diffReal = cObj.x - serverXCorrected;
// Status Bestimmung
const absDiff = Math.abs(diffReal);
if (absDiff < 20) status = "✅ PERFECT";
else if (absDiff < 60) status = "🆗 OK";
@@ -333,10 +279,8 @@ function generateSyncTable(label, clientList, serverList) {
});
});
// 3. Fehlende Server Objekte finden
serverList.forEach((sObj, sIdx) => {
if (!matchedServerIndices.has(sIdx)) {
// Prüfen, ob es vielleicht einfach noch unsichtbar ist (Zukunft)
const sPosCorrected = sObj.x - latencyPx;
let status = "❌ MISSING";
@@ -354,7 +298,6 @@ function generateSyncTable(label, clientList, serverList) {
}
});
// 4. Sortieren nach Position (links nach rechts)
report.sort((a, b) => {
const valA = (typeof a["Client X"] === 'number') ? a["Client X"] : a["Server X (Sim)"];
const valB = (typeof b["Client X"] === 'number') ? b["Client X"] : b["Server X (Sim)"];

View File

@@ -5,23 +5,20 @@ class Particle {
constructor(x, y, type) {
this.x = x;
this.y = y;
this.life = 1.0; // 1.0 = 100% Leben
this.type = type; // 'dust', 'sparkle', 'explosion'
this.life = 1.0;
this.type = type;
// Zufällige Geschwindigkeit
const angle = Math.random() * Math.PI * 2;
let speed = Math.random() * 2;
if (type === 'dust') {
// Staub fliegt eher nach oben/hinten
this.vx = -2 + Math.random();
this.vy = -1 - Math.random();
this.decay = 0.05; // Verschwindet schnell
this.decay = 0.05;
this.color = '#ddd';
this.size = Math.random() * 4 + 2;
}
else if (type === 'sparkle') {
// Münzen glitzern in alle Richtungen
speed = Math.random() * 4 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
@@ -30,7 +27,6 @@ class Particle {
this.size = Math.random() * 3 + 1;
}
else if (type === 'explosion') {
// Tod
speed = Math.random() * 6 + 2;
this.vx = Math.cos(angle) * speed;
this.vy = Math.sin(angle) * speed;
@@ -65,7 +61,6 @@ class Particle {
}
}
// --- API ---
function spawnParticles(x, y, type, count = 5) {
for(let i=0; i<count; i++) {
@@ -74,7 +69,6 @@ function spawnParticles(x, y, type, count = 5) {
}
function updateParticles() {
// Rückwärts loopen zum sicheren Löschen
for (let i = particles.length - 1; i >= 0; i--) {
particles[i].update();
if (particles[i].life <= 0) {

View File

@@ -2,7 +2,6 @@
// RESIZE LOGIK (LETTERBOXING)
// ==========================================
function resize() {
// 1. Interne Auflösung fixieren
canvas.width = GAME_WIDTH; // 800
canvas.height = GAME_HEIGHT; // 400
@@ -35,20 +34,13 @@ window.addEventListener('resize', resize);
resize();
// ==========================================
// DRAWING LOOP (MIT INTERPOLATION)
// ==========================================
// alpha (0.0 bis 1.0) gibt an, wie weit wir zeitlich zwischen zwei Physik-Ticks sind.
function drawGame(alpha = 1.0) {
// 1. Canvas leeren
ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
// ===============================================
// HINTERGRUND
// ===============================================
let currentBg = null;
if (bgSprites.length > 0) {
// Wechselt alle 10.000 Punkte
const changeInterval = 10000;
const currentRawIndex = Math.floor(score / changeInterval);
if (currentRawIndex > maxRawBgIndex) maxRawBgIndex = currentRawIndex;
@@ -63,63 +55,49 @@ function drawGame(alpha = 1.0) {
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
// ===============================================
// BODEN
// ===============================================
ctx.fillStyle = "rgba(60, 60, 60, 0.8)";
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
// ===============================================
// PLATTFORMEN (Interpoliert)
// ===============================================
platforms.forEach(p => {
// Interpolierte X-Position
const rX = (p.prevX !== undefined) ? lerp(p.prevX, p.x, alpha) : p.x;
const rY = p.y;
// Holz-Optik
ctx.fillStyle = "#5D4037";
ctx.fillRect(rX, rY, p.w, p.h);
ctx.fillStyle = "#8D6E63";
ctx.fillRect(rX, rY, p.w, 5); // Highlight oben
ctx.fillRect(rX, rY, p.w, 5);
});
// ===============================================
// HINDERNISSE (Interpoliert)
// ===============================================
obstacles.forEach(obs => {
const def = obs.def || {};
const img = sprites[def.id];
// Interpolation
const rX = (obs.prevX !== undefined) ? lerp(obs.prevX, obs.x, alpha) : obs.x;
const rY = obs.y;
// Hitbox Dimensionen
const hbw = def.width || obs.w || 30;
const hbh = def.height || obs.h || 30;
if (img && img.complete && img.naturalHeight !== 0) {
// --- BILD VORHANDEN ---
// Editor-Werte anwenden
const scale = def.imgScale || 1.0;
const offX = def.imgOffsetX || 0.0;
const offY = def.imgOffsetY || 0.0;
// 1. Skalierte Größe
const drawW = hbw * scale;
const drawH = hbh * scale;
// 2. Positionierung (Zentriert & Unten Bündig zur Hitbox)
const baseX = rX + (hbw - drawW) / 2;
const baseY = rY + (hbh - drawH);
// 3. Zeichnen
ctx.drawImage(img, baseX + offX, baseY + offY, drawW, drawH);
} else {
// --- FALLBACK (KEIN BILD) ---
// Magenta als Warnung, Gold für Coins
let color = "#FF00FF";
if (def.type === "coin") color = "gold";
else if (def.color) color = def.color;
@@ -127,50 +105,36 @@ function drawGame(alpha = 1.0) {
ctx.fillStyle = color;
ctx.fillRect(rX, rY, hbw, hbh);
// Rahmen & Text
ctx.strokeStyle = "rgba(255,255,255,0.5)"; ctx.lineWidth = 2;
ctx.strokeRect(rX, rY, hbw, hbh);
ctx.fillStyle = "white"; ctx.font = "bold 10px monospace";
ctx.fillText(def.id || "?", rX, rY - 5);
}
// --- DEBUG HITBOX (Client) ---
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
ctx.strokeStyle = "rgba(0,255,0,0.5)"; // Grün transparent
ctx.lineWidth = 1;
ctx.strokeRect(rX, rY, hbw, hbh);
}
// Sprechblase
if(obs.speech) drawSpeechBubble(rX, rY, obs.speech);
});
// ===============================================
// DEBUG: SERVER STATE (Cyan)
// ===============================================
// Zeigt an, wo der Server die Objekte sieht (ohne Interpolation)
if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
if (serverObstacles) {
ctx.strokeStyle = "cyan";
ctx.lineWidth = 1;
serverObstacles.forEach(sObj => {
// Wir müssen hier die Latenz-Korrektur aus network.js abziehen,
// um zu sehen, wo network.js sie hingeschoben hat?
// Nein, serverObstacles enthält die Rohdaten.
// Wenn wir wissen wollen, wo der Server "jetzt" ist, müssten wir schätzen.
// Wir zeichnen einfach Raw, das hinkt optisch meist hinterher.
ctx.strokeRect(sObj.x, sObj.y, sObj.w, sObj.h);
});
}
}
// ===============================================
// SPIELER (Interpoliert)
// ===============================================
// Interpolierte Y-Position
let rPlayerY = lerp(player.prevY !== undefined ? player.prevY : player.y, player.y, alpha);
// Ducken Anpassung
const drawY = isCrouching ? rPlayerY + 25 : rPlayerY;
const drawH = isCrouching ? 25 : 50;
@@ -181,16 +145,11 @@ function drawGame(alpha = 1.0) {
ctx.fillRect(player.x, drawY, player.w, drawH);
}
// ===============================================
// PARTIKEL (Visuelle Effekte)
// ===============================================
if (typeof drawParticles === 'function') {
drawParticles();
}
// ===============================================
// HUD (Statusanzeige)
// ===============================================
if (isGameRunning && !isGameOver) {
ctx.fillStyle = "black";
ctx.font = "bold 10px monospace";
@@ -206,9 +165,6 @@ function drawGame(alpha = 1.0) {
}
}
// ===============================================
// GAME OVER OVERLAY
// ===============================================
if (isGameOver) {
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);

View File

@@ -1,41 +1,35 @@
// ==========================================
// GLOBALE STATUS VARIABLEN
// ==========================================
// --- Konfiguration & Flags ---
let gameConfig = null; // Wird von /api/config geladen
let isLoaded = false; // Sind Assets geladen?
let isGameRunning = false; // Läuft der Game Loop?
let isGameOver = false; // Ist der Spieler tot?
let sessionID = null; // UUID der aktuellen Session
let gameConfig = null;
let isLoaded = false;
let isGameRunning = false;
let isGameOver = false;
let sessionID = null;
// --- NETZWERK & STREAMING (NEU) ---
let socket = null; // Die WebSocket Verbindung
let obstacleBuffer = []; // Warteschlange für kommende Hindernisse
let platformBuffer = []; // Warteschlange für kommende Plattformen
let socket = null;
let obstacleBuffer = [];
let platformBuffer = [];
let score = 0;
let currentTick = 0;
// --- SPIELZUSTAND ---
let score = 0; // Aktueller Punktestand (vom Server diktiert)
let currentTick = 0; // Zeit-Einheit des Spiels
// --- POWERUPS (Client Visuals) ---
let godModeLives = 0;
let hasBat = false;
let bootTicks = 0;
// --- HINTERGRUND ---
let maxRawBgIndex = 0; // Welcher Hintergrund wird gezeigt?
// --- GAME LOOP TIMING ---
let maxRawBgIndex = 0;
let lastTime = 0;
let accumulator = 0;
// --- GRAFIKEN ---
let sprites = {}; // Cache für Hindernis-Bilder
let playerSprite = new Image();
let bgSprites = []; // Array der Hintergrund-Bilder
// --- ENTITIES (Render-Listen) ---
let sprites = {};
let playerSprite = new Image();
let bgSprites = [];
let player = {
x: 50,
y: 300,
@@ -49,28 +43,25 @@ let player = {
let particles = [];
// Diese Listen werden von logic.js aus dem Buffer gefüllt und von render.js gezeichnet
let obstacles = [];
let platforms = [];
// Debug-Daten (optional, falls der Server Debug-Infos schickt)
let serverObstacles = [];
let serverPlatforms = [];
let currentLatencyMs = 0; // Aktuelle Latenz in Millisekunden
let pingInterval = null; // Timer für den Ping
let currentLatencyMs = 0;
let pingInterval = null;
// --- INPUT STATE ---
let isCrouching = false;
// ==========================================
// HTML ELEMENTE (Caching)
// ==========================================
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');
const container = document.getElementById('game-container');
// UI Elemente
const startScreen = document.getElementById('startScreen');
const startBtn = document.getElementById('startBtn');
const loadingText = document.getElementById('loadingText');

View File

@@ -12,9 +12,7 @@
}
/* =========================================
1. GRUNDLAGEN & GLOBAL
========================================= */
body, html {
margin: 0;
padding: 0;

View File

@@ -57,6 +57,7 @@ type ActiveObstacle struct {
Y float64 `json:"y"`
Width float64 `json:"w"`
Height float64 `json:"h"`
Speech string `json:"speech,omitempty"`
}
type ActivePlatform struct {

View File

@@ -12,8 +12,8 @@ import (
const (
ServerTickRate = 50 * time.Millisecond
BufferAhead = 60 // Puffergröße (Zukunft)
SpawnXStart = 2000.0 // Spawn Abstand
BufferAhead = 60
SpawnXStart = 2000.0
)
var upgrader = websocket.Upgrader{
@@ -24,7 +24,7 @@ var upgrader = websocket.Upgrader{
type WSInputMsg struct {
Type string `json:"type"`
Input string `json:"input"`
Tick int `json:"tick"` // Optional: Client Timestamp für Ping
Tick int `json:"tick"`
PosY float64 `json:"y"`
VelY float64 `json:"vy"`
}
@@ -37,7 +37,7 @@ type WSServerMsg struct {
Score int `json:"score"`
PowerUps PowerUpState `json:"powerups"`
SessionID string `json:"sessionId"`
Ts int `json:"ts,omitempty"` // Für Pong
Ts int `json:"ts,omitempty"`
CurrentSpeed float64 `json:"currentSpeed"`
}
@@ -74,6 +74,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
Ticks: 0,
PosY: PlayerYBase,
NextSpawnTick: 0,
Chunks: loadChunksFromRedis(),
}
// Channel größer machen, damit bei Lag nichts blockiert
@@ -106,15 +107,7 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
case <-closeChan:
return // Client weg
// WICHTIG: Wir verarbeiten Inputs hier NICHT einzeln,
// sondern sammeln sie im Default-Case (siehe unten),
// oder nutzen eine nicht-blockierende Schleife.
// Aber für einfache Logik reicht select.
// Um "Input Lag" zu verhindern, lesen wir den Channel leer:
case <-ticker.C:
// A. INPUTS VERARBEITEN (Alle die angekommen sind!)
// Wir loopen solange durch den Channel, bis er leer ist.
InputLoop:
for {
select {
@@ -135,7 +128,6 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
if msg.Type == "ping" {
// Sofort Pong zurück (Performance wichtig!)
conn.WriteJSON(WSServerMsg{Type: "pong", Ts: msg.Tick})
}
@@ -162,25 +154,21 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
log.Printf("🐞 Debug Snapshot an Client gesendet (Tick %d)", state.Ticks)
}
default:
// Channel leer, weiter zur Physik
break InputLoop
}
}
// B. LIVE SIMULATION (1 Tick)
// Jetzt simulieren wir genau EINEN Frame (16ms)
state.Ticks++
state.Score++ // Score wächst mit der Zeit
state.Score++
currentSpeed := calculateSpeed(state.Ticks)
updatePhysics(&state, pendingJump, isCrouching, currentSpeed)
pendingJump = false // Jump Trigger reset
pendingJump = false
checkCollisions(&state, isCrouching, currentSpeed)
if state.IsDead {
// Score Persistieren
rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
"score": state.Score,
"is_dead": 1,
@@ -193,13 +181,10 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
moveWorld(&state, currentSpeed)
// C. STREAMING (Zukunft)
// Wir generieren nur, wenn der Puffer zur Neige geht
targetTick := state.Ticks + BufferAhead
var newObs []ActiveObstacle
var newPlats []ActivePlatform
// Um CPU zu sparen, generieren wir max 10 Ticks pro Frame nach
loops := 0
for generatedHeadTick < targetTick && loops < 10 {
generatedHeadTick++
@@ -215,8 +200,6 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
}
// D. SENDEN (Effizienz)
// Nur senden wenn Daten da sind ODER alle 15 Frames (Heartbeat/Score Sync)
if len(newObs) > 0 || len(newPlats) > 0 || state.Ticks%15 == 0 {
msg := WSServerMsg{
Type: "chunk",
@@ -234,46 +217,76 @@ func handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
}
// ... (generateFutureObjects bleibt gleich wie vorher) ...
func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstacle, []ActivePlatform) {
var createdObs []ActiveObstacle
var createdPlats []ActivePlatform
// Initialisierung beim ersten Lauf
if s.NextSpawnTick == 0 {
s.NextSpawnTick = tick + 50
}
// Ist es Zeit für etwas Neues?
if tick >= s.NextSpawnTick {
spawnX := SpawnXStart
chunkCount := len(defaultConfig.Chunks)
if chunkCount > 0 && s.RNG.NextFloat() > 0.8 {
idx := int(s.RNG.NextRange(0, float64(chunkCount)))
chunk := defaultConfig.Chunks[idx]
for _, p := range chunk.Platforms {
createdPlats = append(createdPlats, ActivePlatform{X: spawnX + p.X, Y: p.Y, Width: p.Width, Height: p.Height})
}
for _, o := range chunk.Obstacles {
createdObs = append(createdObs, ActiveObstacle{ID: o.ID, Type: o.Type, X: spawnX + o.X, Y: o.Y, Width: o.Width, Height: o.Height})
createdPlats = append(createdPlats, ActivePlatform{
X: spawnX + p.X,
Y: p.Y,
Width: p.Width,
Height: p.Height,
})
}
width := chunk.TotalWidth
if width == 0 {
width = 2000
for _, o := range chunk.Obstacles {
speech := ""
for _, def := range defaultConfig.Obstacles {
if def.ID == o.ID {
if def.CanTalk && len(def.SpeechLines) > 0 {
if s.RNG.NextFloat() > 0.7 { // 30% Wahrscheinlichkeit
sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines))))
speech = def.SpeechLines[sIdx]
}
}
break
}
}
createdObs = append(createdObs, ActiveObstacle{
ID: o.ID,
Type: o.Type,
X: spawnX + o.X,
Y: o.Y,
Width: o.Width,
Height: o.Height,
Speech: speech,
})
}
s.NextSpawnTick = tick + int(float64(width)/speed)
width := float64(chunk.TotalWidth)
if width == 0 {
width = 2000.0
}
s.NextSpawnTick = tick + int(width/speed)
} else {
// Random Logic
gap := 400 + int(s.RNG.NextRange(0, 500))
s.NextSpawnTick = tick + int(float64(gap)/speed)
defs := defaultConfig.Obstacles
if len(defs) > 0 {
// Boss Check
isBoss := (tick % 1500) > 1200
var pool []ObstacleDef
for _, d := range defs {
if isBoss {
if d.ID == "principal" || d.ID == "trashcan" {
@@ -287,17 +300,32 @@ func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstac
}
def := s.RNG.PickDef(pool)
if def != nil {
// RNG Calls to keep sync (optional now, but good practice)
if def.CanTalk && s.RNG.NextFloat() > 0.7 {
}
if def.Type == "powerup" && s.RNG.NextFloat() > 0.1 {
def = nil
}
if def != nil {
// Speech Logik
speech := ""
if def.CanTalk && len(def.SpeechLines) > 0 {
if s.RNG.NextFloat() > 0.7 {
sIdx := int(s.RNG.NextRange(0, float64(len(def.SpeechLines))))
speech = def.SpeechLines[sIdx]
}
}
spawnY := GroundY - def.Height - def.YOffset
createdObs = append(createdObs, ActiveObstacle{
ID: def.ID, Type: def.Type, X: spawnX, Y: GroundY - def.Height - def.YOffset, Width: def.Width, Height: def.Height,
ID: def.ID,
Type: def.Type,
X: spawnX,
Y: spawnY,
Width: def.Width,
Height: def.Height,
Speech: speech,
})
}
}