diff --git a/config.go b/config.go
index d672d08..5217b6e 100644
--- a/config.go
+++ b/config.go
@@ -2,20 +2,22 @@ package main
import (
"context"
+ "encoding/json"
"log"
"os"
+ "sort"
"github.com/redis/go-redis/v9"
)
const (
- Gravity = 0.6
- JumpPower = -12.0
- HighJumpPower = -16.0
+ Gravity = 1.8
+ JumpPower = -20.0
+ HighJumpPower = -28.0
GroundY = 350.0
PlayerHeight = 50.0
PlayerYBase = GroundY - PlayerHeight
- BaseSpeed = 5.0
+ BaseSpeed = 15.0
GameWidth = 800.0
)
@@ -39,17 +41,17 @@ func initGameConfig() {
defaultConfig = GameConfig{
Obstacles: []ObstacleDef{
// --- HINDERNISSE ---
- {ID: "desk", Type: "obstacle", Width: 40, Height: 30, Color: "#8B4513", Image: "desk1.png"},
- {ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}},
+ {ID: "desk", Type: "obstacle", Width: 50, Height: 65, Color: "#ff0000", Image: "desk.png", YOffset: -19, ImgScale: 1.3, ImgOffsetX: 1, ImgOffsetY: 3}, // desk
+ {ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher1.png"},
+ {ID: "k-m", Type: "teacher", Width: 45, Height: 80, Color: "#ff0000", Image: "k-m.png", YOffset: 5, ImgScale: 1.2, ImgOffsetX: -1, ImgOffsetY: 8}, // k-m
+ {ID: "w-l", Type: "teacher", Width: 50, Height: 70, Color: "#ff0000", Image: "w-l.png", ImgScale: 1.1, ImgOffsetX: 1, ImgOffsetY: 3, CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}}, // w-l
{ID: "trashcan", Type: "obstacle", Width: 25, Height: 35, Color: "#555", Image: "trash1.png"},
- {ID: "eraser", Type: "obstacle", Width: 30, Height: 20, Color: "#fff", Image: "eraser1.png", YOffset: 30.0},
-
+ {ID: "eraser1", Type: "obstacle", Width: 56, Height: 37, Color: "#ff0000", Image: "eraser.png", YOffset: 35, ImgScale: 1.6, ImgOffsetY: 9}, // eraser1
{ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal1.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}},
// --- COINS ---
- {ID: "coin0", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 40.0},
- {ID: "coin1", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 50.0},
- {ID: "coin2", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin1.png", YOffset: 60.0},
+ {ID: "coin0", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", ImgScale: 1.1, ImgOffsetY: 1},
+ {ID: "coin1", Type: "coin", Width: 40, Height: 73, Color: "#ff0000", Image: "coin.png", YOffset: 60, ImgScale: 1.1, ImgOffsetY: 1},
// --- POWERUPS ---
{ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god1.png", YOffset: 20.0}, // Godmode
@@ -60,4 +62,26 @@ func initGameConfig() {
Backgrounds: []string{"school-background.jpg", "gym-background.jpg", "school2-background.jpg"},
}
log.Println("✅ Config mit Powerups geladen")
+
+ loadChunksFromRedis()
+}
+
+func loadChunksFromRedis() {
+ // Gleiche Logik wie im Handler, aber speichert es in die globale Variable
+ 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)
+ }
+ defaultConfig.Chunks = chunks
+ log.Printf("📦 %d Custom Chunks geladen", len(chunks))
}
diff --git a/go.mod b/go.mod
index 168633d..82c968b 100644
--- a/go.mod
+++ b/go.mod
@@ -2,9 +2,13 @@ module escape-teacher
go 1.25.4
+require (
+ github.com/google/uuid v1.6.0
+ github.com/gorilla/websocket v1.5.3
+ github.com/redis/go-redis/v9 v9.17.0
+)
+
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/redis/go-redis/v9 v9.17.0 // indirect
)
diff --git a/go.sum b/go.sum
index eb7ba5f..40fb60e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,8 +1,14 @@
+github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
+github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
+github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
diff --git a/handlers.go b/handlers.go
index b387d06..a1f5677 100644
--- a/handlers.go
+++ b/handlers.go
@@ -6,6 +6,7 @@ import (
"log"
"math/rand"
"net/http"
+ "sort"
"strconv"
"strings"
"time"
@@ -14,9 +15,54 @@ import (
"github.com/redis/go-redis/v9"
)
+func handleEditorPage(w http.ResponseWriter, r *http.Request) {
+ http.ServeFile(w, r, "./secure/editor.html")
+}
+
+func handleObstacleEditorPage(w http.ResponseWriter, r *http.Request) {
+ http.ServeFile(w, r, "./secure/obstacle_editor.html")
+}
+
+func handleAdminChunks(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ var chunk ChunkDef
+ if err := json.NewDecoder(r.Body).Decode(&chunk); err != nil {
+ http.Error(w, "Bad JSON", 400)
+ return
+ }
+
+ chunkJson, _ := json.Marshal(chunk)
+ rdb.SAdd(ctx, "config:chunks:list", chunk.ID)
+ rdb.Set(ctx, "config:chunks:data:"+chunk.ID, chunkJson, 0)
+
+ w.WriteHeader(http.StatusOK)
+ }
+
+}
+
func handleConfig(w http.ResponseWriter, r *http.Request) {
+ conf := defaultConfig
+
+ chunkIDs, _ := rdb.SMembers(ctx, "config:chunks:list").Result()
+
+ sort.Strings(chunkIDs)
+
+ var loadedChunks []ChunkDef
+
+ // 3. Details laden
+ for _, id := range chunkIDs {
+ val, err := rdb.Get(ctx, "config:chunks:data:"+id).Result()
+ if err == nil {
+ var c ChunkDef
+ json.Unmarshal([]byte(val), &c)
+ c.ID = id // Sicherstellen, dass ID gesetzt ist
+ loadedChunks = append(loadedChunks, c)
+ }
+ }
+ conf.Chunks = loadedChunks
+
w.Header().Set("Content-Type", "application/json")
- json.NewEncoder(w).Encode(defaultConfig)
+ json.NewEncoder(w).Encode(conf)
}
func handleStart(w http.ResponseWriter, r *http.Request) {
@@ -64,9 +110,7 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
return
}
- // ---> HIER RUFEN WIR JETZT DIE SIMULATION AUF <---
- isDead, score, obstacles, powerUpState, serverTick, nextSpawnTick := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
-
+ isDead, score, obstacles, platforms, powerUpState, serverTick, nextSpawnTick, finalRngState := simulateChunk(req.SessionID, req.Inputs, req.TotalTicks, vals)
status := "alive"
if isDead {
status = "dead"
@@ -78,9 +122,11 @@ func handleValidate(w http.ResponseWriter, r *http.Request) {
Status: status,
VerifiedScore: score,
ServerObs: obstacles,
+ ServerPlats: platforms,
PowerUps: powerUpState,
ServerTick: serverTick,
NextSpawnTick: nextSpawnTick,
+ RngState: finalRngState,
})
}
diff --git a/main.go b/main.go
index c2421a6..f60aea9 100644
--- a/main.go
+++ b/main.go
@@ -39,6 +39,7 @@ func main() {
http.HandleFunc("/api/config", Logger(handleConfig))
http.HandleFunc("/api/start", Logger(handleStart))
http.HandleFunc("/api/validate", Logger(handleValidate))
+ http.HandleFunc("/ws", handleWebSocket)
http.HandleFunc("/api/submit-name", Logger(handleSubmitName))
http.HandleFunc("/api/leaderboard", Logger(handleLeaderboard))
http.HandleFunc("/api/claim/delete", Logger(handleClaimDelete))
@@ -48,6 +49,9 @@ func main() {
http.HandleFunc("/api/admin/badwords", Logger(BasicAuth(handleAdminBadwords)))
http.HandleFunc("/api/admin/list", Logger(BasicAuth(handleAdminList)))
http.HandleFunc("/api/admin/action", Logger(BasicAuth(handleAdminAction)))
+ http.HandleFunc("/api/admin/chunks", Logger(BasicAuth(handleAdminChunks)))
+ http.HandleFunc("/admin/editor", Logger(BasicAuth(handleEditorPage)))
+ http.HandleFunc("/admin/obstacle_editor", Logger(BasicAuth(handleObstacleEditorPage)))
log.Println("🦖 Server läuft auf :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
diff --git a/secure/editor.html b/secure/editor.html
new file mode 100644
index 0000000..d34ee9c
--- /dev/null
+++ b/secure/editor.html
@@ -0,0 +1,501 @@
+
+
+
+
+ Ultimate Chunk Editor (+Templates)
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/secure/obstacle_editor.html b/secure/obstacle_editor.html
new file mode 100644
index 0000000..8b73d2f
--- /dev/null
+++ b/secure/obstacle_editor.html
@@ -0,0 +1,355 @@
+
+
+
+
+ Obstacle Tuner (Realtime)
+
+
+
+
+
+
🛠 OBSTACLE TUNER
+
+
+
Basis
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 🖼️ Optik (Textur)
+ Drag Canvas to Move
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Maus Drag: Bild verschieben
+ Grün = Spieler (Referenz)
Rot = Hitbox
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/simulation.go b/simulation.go
index 5a00d46..0881768 100644
--- a/simulation.go
+++ b/simulation.go
@@ -8,273 +8,491 @@ import (
"strconv"
)
-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)
+// --- INTERNE STATE STRUKTUR ---
+type SimState struct {
+ SessionID string
+ Score int
+ Ticks int
+ PosY float64
+ VelY float64
+ IsDead bool
- nextSpawnTick := int(parseOr(vals["next_spawn_tick"], 0))
+ // Objekte
+ Obstacles []ActiveObstacle
+ Platforms []ActivePlatform
- godLives := int(parseOr(vals["p_god_lives"], 0))
- hasBat := vals["p_has_bat"] == "1"
- bootTicks := int(parseOr(vals["p_boot_ticks"], 0))
+ // Powerups
+ GodLives int
+ HasBat bool
+ BootTicks int
- lastJumpDist := parseOr(vals["ac_last_dist"], 0.0)
- suspicionScore := int(parseOr(vals["ac_suspicion"], 0))
+ // Spawning & RNG
+ NextSpawnTick int
+ RNG *PseudoRNG
- rng := NewRNG(rngStateVal)
+ // Anti-Cheat
+ LastJumpDist float64
+ SuspicionScore int
+}
- var obstacles []ActiveObstacle
- if val, ok := vals["obstacles"]; ok && val != "" {
- json.Unmarshal([]byte(val), &obstacles)
- } else {
- obstacles = []ActiveObstacle{}
+// ============================================================================
+// HAUPTFUNKTION
+// ============================================================================
+
+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
+ return packResponse(&state)
}
+ // 3. Game Loop
+ for i := 0; i < totalTicks; i++ {
+ state.Ticks++
+
+ currentSpeed := calculateSpeed(state.Ticks)
+ didJump, isCrouching := parseInput(inputs, i)
+
+ updatePhysics(&state, didJump, isCrouching, currentSpeed)
+
+ checkCollisions(&state, isCrouching, currentSpeed)
+ if state.IsDead {
+ break
+ }
+
+ moveWorld(&state, currentSpeed)
+ handleSpawning(&state, currentSpeed)
+
+ 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
+ s.BootTicks--
+ }
+
+ 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 && !isGrounded {
+ s.VelY += 2.0 // Fast Fall
+ }
+
+ if didJump && isGrounded && !isCrouching {
+ s.VelY = jumpPower
+ isGrounded = false
+ checkJumpSuspicion(s)
+ }
+
+ s.VelY += Gravity
+ oldY := s.PosY
+ newY := s.PosY + s.VelY
+
+ landed := false
+
+ // A. Plattform Landung (One-Way Logic)
+ if s.VelY > 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
+ }
+ }
+ }
+
+ // B. Boden Landung
+ if !landed {
+ if newY >= PlayerYBase {
+ newY = PlayerYBase
+ s.VelY = 0
+ }
+ }
+
+ s.PosY = newY
+}
+
+func checkCollisions(s *SimState, isCrouching bool, currentSpeed float64) {
+ hitboxH := PlayerHeight
+ hitboxY := s.PosY
+ if isCrouching {
+ hitboxH = PlayerHeight / 2
+ hitboxY = s.PosY + (PlayerHeight - hitboxH)
+ }
+
+ activeObs := []ActiveObstacle{}
+
+ for _, obs := range s.Obstacles {
+ // Passed Check
+ paddingX := 10.0
+ if obs.X+obs.Width-paddingX < 55.0 {
+ activeObs = append(activeObs, obs)
+ continue
+ }
+
+ pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX
+ pTop, pBottom := hitboxY+10.0, hitboxY+hitboxH-5.0
+ if obs.Type == "teacher" {
+ pTop = hitboxY + 25.0
+ }
+
+ oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX
+ oTop, oBottom := obs.Y+10.0, obs.Y+obs.Height-5.0
+
+ isHit := pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom
+
+ if isHit {
+ if obs.Type == "coin" {
+ s.Score += 2000
+ continue
+ }
+ if obs.Type == "powerup" {
+ applyPowerup(s, obs.ID)
+ continue
+ }
+ if s.HasBat && obs.Type == "teacher" {
+ s.HasBat = false
+ log.Printf("[%s] ⚾ Bat used on %s", s.SessionID, obs.ID)
+ continue
+ }
+ if s.GodLives > 0 {
+ s.GodLives--
+ log.Printf("[%s] 🛡️ Godmode saved life", s.SessionID)
+ continue
+ }
+
+ _, pRight := 50.0+10.0, 50.0+30.0-10.0 // Player X ist fest
+ // Player Y/Height ausrechnen (für Log)
+ pH := PlayerHeight
+ if isCrouching {
+ pH = PlayerHeight / 2
+ }
+ pTopLog := s.PosY + 10.0
+ pBottomLog := s.PosY + pH - 5.0
+
+ log.Printf("\n💀 --- DEATH REPORT [%s] ---\n"+
+ "⏱️ Tick: %d (Speed: %.2f)\n"+
+ "🏃 Player: Y=%.2f (Top: %.2f, Bottom: %.2f) | VelY=%.2f | Duck=%v\n"+
+ "🧱 Killer: ID='%s' (Type=%s)\n"+
+ "📍 Object: X=%.2f (Left: %.2f, Right: %.2f)\n"+
+ " Y=%.2f (Top: %.2f, Bottom: %.2f)\n"+
+ "💥 Overlap: X-Diff=%.2f, Y-Diff=%.2f\n"+
+ "------------------------------------------\n",
+ s.SessionID,
+ s.Ticks, currentSpeed,
+ s.PosY, pTopLog, pBottomLog, s.VelY, isCrouching,
+ obs.ID, obs.Type,
+ obs.X, oLeft, oRight,
+ obs.Y, oTop, oBottom,
+ (oLeft - pRight), (oTop - pBottomLog), // Wenn negativ, überlappen sie
+ )
+
+ log.Printf("💀 DEATH [%s]: Hit %s at Tick %d", s.SessionID, obs.ID, s.Ticks)
+ s.IsDead = true
+ }
+ activeObs = append(activeObs, obs)
+ }
+ s.Obstacles = activeObs
+}
+
+func moveWorld(s *SimState, speed float64) {
+ nextObs := []ActiveObstacle{}
+ for _, o := range s.Obstacles {
+ o.X -= speed
+ if o.X+o.Width > -200.0 {
+ nextObs = append(nextObs, o)
+ }
+ }
+ s.Obstacles = nextObs
+
+ nextPlats := []ActivePlatform{}
+ for _, p := range s.Platforms {
+ p.X -= speed
+ if p.X+p.Width > -200.0 {
+ nextPlats = append(nextPlats, p)
+ }
+ }
+ s.Platforms = nextPlats
+}
+
+func handleSpawning(s *SimState, speed float64) {
+ if s.NextSpawnTick == 0 {
+ s.NextSpawnTick = s.Ticks + 50
+ }
+
+ 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,
+ })
+ }
+
+ width := chunk.TotalWidth
+ if width == 0 {
+ 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)
+
+ s := SimState{
+ SessionID: sid,
+ Score: int(parseOr(vals["score"], 0)),
+ Ticks: int(parseOr(vals["total_ticks"], 0)),
+ PosY: parseOr(vals["pos_y"], PlayerYBase),
+ VelY: parseOr(vals["vel_y"], 0.0),
+ NextSpawnTick: int(parseOr(vals["next_spawn_tick"], 0)),
+ GodLives: int(parseOr(vals["p_god_lives"], 0)),
+ BootTicks: int(parseOr(vals["p_boot_ticks"], 0)),
+ HasBat: vals["p_has_bat"] == "1",
+ LastJumpDist: parseOr(vals["ac_last_dist"], 0.0),
+ SuspicionScore: int(parseOr(vals["ac_suspicion"], 0)),
+ RNG: NewRNG(rngState),
+ }
+
+ if v, ok := vals["obstacles"]; ok && v != "" {
+ json.Unmarshal([]byte(v), &s.Obstacles)
+ }
+ if v, ok := vals["platforms"]; ok && v != "" {
+ json.Unmarshal([]byte(v), &s.Platforms)
+ }
+ return s
+}
+
+func saveSimState(s *SimState) {
+ obsJson, _ := json.Marshal(s.Obstacles)
+ platJson, _ := json.Marshal(s.Platforms)
+ batStr := "0"
+ if s.HasBat {
+ batStr = "1"
+ }
+
+ rdb.HSet(ctx, "session:"+s.SessionID, map[string]interface{}{
+ "score": s.Score,
+ "total_ticks": s.Ticks,
+ "next_spawn_tick": s.NextSpawnTick,
+ "pos_y": fmt.Sprintf("%f", s.PosY),
+ "vel_y": fmt.Sprintf("%f", s.VelY),
+ "rng_state": s.RNG.State,
+ "obstacles": string(obsJson),
+ "platforms": string(platJson),
+ "p_god_lives": s.GodLives,
+ "p_has_bat": batStr,
+ "p_boot_ticks": s.BootTicks,
+ "ac_last_dist": fmt.Sprintf("%f", s.LastJumpDist),
+ "ac_suspicion": s.SuspicionScore,
+ })
+}
+
+func packResponse(s *SimState) (bool, int, []ActiveObstacle, []ActivePlatform, PowerUpState, int, int, uint32) {
+ pState := PowerUpState{
+ GodLives: s.GodLives,
+ HasBat: s.HasBat,
+ BootTicks: s.BootTicks,
+ }
+ return s.IsDead, s.Score, s.Obstacles, s.Platforms, pState, s.Ticks, s.NextSpawnTick, s.RNG.State
+}
+
+func checkPlatformLanding(pX, pY, pW, playerX, oldPlayerY, newPlayerY, velY float64) (bool, float64) {
+ if velY < 0 {
+ return false, 0
+ }
+ const pTolerance = 10.0
+ playerW := 30.0
+
+ if (playerX+playerW-pTolerance > pX) && (playerX+pTolerance < pX+pW) {
+ playerFeetOld := oldPlayerY + PlayerHeight
+ playerFeetNew := newPlayerY + PlayerHeight
+ if playerFeetOld <= pY && playerFeetNew >= pY {
+ return true, pY - PlayerHeight
+ }
+ }
+ return false, 0
+}
+
+func spawnRandomObstacle(s *SimState, speed, spawnX float64) {
+ gapPixel := 400 + int(s.RNG.NextRange(0, 500))
+ ticksToWait := int(float64(gapPixel) / speed)
+ s.NextSpawnTick = s.Ticks + ticksToWait
+
+ isBossPhase := (s.Ticks % 1500) > 1200
+ var possibleDefs []ObstacleDef
+
+ for _, d := range defaultConfig.Obstacles {
+ if isBossPhase {
+ if d.ID == "principal" || d.ID == "trashcan" {
+ possibleDefs = append(possibleDefs, d)
+ }
+ } else {
+ if d.ID == "principal" {
+ continue
+ }
+ if d.ID == "eraser" && s.Ticks < 3000 {
+ continue
+ }
+ possibleDefs = append(possibleDefs, d)
+ }
+ }
+
+ def := s.RNG.PickDef(possibleDefs)
+
+ if def != nil && def.CanTalk {
+ if s.RNG.NextFloat() > 0.7 {
+ s.RNG.NextFloat()
+ }
+ }
+ if def != nil && def.Type == "powerup" {
+ if s.RNG.NextFloat() > 0.1 {
+ def = nil
+ }
+ }
+
+ if def != nil {
+ spawnY := GroundY - def.Height - def.YOffset
+ s.Obstacles = append(s.Obstacles, ActiveObstacle{
+ ID: def.ID, Type: def.Type, X: spawnX, Y: spawnY, Width: def.Width, Height: def.Height,
+ })
+ }
+}
+
+func applyPowerup(s *SimState, id string) {
+ if id == "p_god" {
+ s.GodLives = 3
+ }
+ if id == "p_bat" {
+ s.HasBat = true
+ }
+ if id == "p_boot" {
+ s.BootTicks = 600
+ }
+}
+
+func checkGrounded(s *SimState) bool {
+ if s.VelY != 0 {
+ return false
+ }
+ if s.PosY >= PlayerYBase-0.1 {
+ return true
+ }
+
+ for _, p := range s.Platforms {
+ if math.Abs((s.PosY+PlayerHeight)-p.Y) < 0.5 {
+ if 50+30 > p.X && 50 < p.X+p.Width {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func isBotSpamming(inputs []Input) bool {
jumpCount := 0
for _, inp := range inputs {
if inp.Act == "JUMP" {
jumpCount++
}
}
- if jumpCount > 10 {
- log.Printf("🤖 BOT-ALARM [%s]: Spammt Sprünge (%d)", sessionID, jumpCount)
- return true, score, obstacles, PowerUpState{}, ticksAlive, nextSpawnTick
+ return jumpCount > 10
+}
+
+func parseInput(inputs []Input, currentTick int) (bool, bool) {
+ jump := false
+ duck := false
+ for _, inp := range inputs {
+ if inp.Tick == currentTick {
+ if inp.Act == "JUMP" {
+ jump = true
+ }
+ if inp.Act == "DUCK" {
+ duck = true
+ }
+ }
}
+ return jump, duck
+}
- playerDead := false
+func calculateSpeed(ticks int) float64 {
+ speed := BaseSpeed + (float64(ticks)/1000.0)*1.5
+ if speed > 36.0 {
+ return 36.0
+ }
+ return speed
+}
- for i := 0; i < totalTicks; i++ {
- ticksAlive++
-
- currentSpeed := BaseSpeed + (float64(ticksAlive)/3000.0)*0.5
- if currentSpeed > 12.0 {
- currentSpeed = 12.0
- }
-
- currentJumpPower := JumpPower
- if bootTicks > 0 {
- currentJumpPower = HighJumpPower
- bootTicks--
- }
-
- didJump := false
- isCrouching := false
- for _, inp := range inputs {
- if inp.Tick == i {
- if inp.Act == "JUMP" {
- didJump = true
- }
- if inp.Act == "DUCK" {
- isCrouching = true
- }
- }
- }
-
- isGrounded := posY >= PlayerYBase-1.0
- currentHeight := PlayerHeight
- if isCrouching {
- currentHeight = PlayerHeight / 2
- if !isGrounded {
- velY += 2.0
- }
- }
-
- if didJump && isGrounded && !isCrouching {
- velY = currentJumpPower
-
- var distToObs float64 = -1.0
- for _, o := range obstacles {
- if o.X > 50.0 {
- distToObs = o.X - 50.0
- break
- }
- }
- if distToObs > 0 {
- diff := math.Abs(distToObs - lastJumpDist)
- if diff < 1.0 {
- suspicionScore++
- } else if suspicionScore > 0 {
- suspicionScore--
- }
- lastJumpDist = distToObs
- }
- }
-
- velY += Gravity
- posY += velY
- if posY > PlayerYBase {
- posY = PlayerYBase
- velY = 0
- }
-
- hitboxY := posY
- if isCrouching {
- hitboxY = posY + (PlayerHeight - currentHeight)
- }
-
- nextObstacles := []ActiveObstacle{}
-
- for _, obs := range obstacles {
- obs.X -= currentSpeed
-
- if obs.X+obs.Width < -50.0 {
- continue
- }
-
- paddingX := 10.0
- realRightEdge := obs.X + obs.Width - paddingX
-
- if realRightEdge < 55.0 {
- nextObstacles = append(nextObstacles, obs)
- continue
- }
-
- paddingY_Top := 10.0
- if obs.Type == "teacher" {
- paddingY_Top = 25.0
- }
-
- pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX
- pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-5.0
- oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX
- oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-5.0
-
- isCollision := pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom
-
- if isCollision {
- if obs.Type == "coin" {
- score += 2000
- continue
- } else if obs.Type == "powerup" {
- if obs.ID == "p_god" {
- godLives = 3
- }
- if obs.ID == "p_bat" {
- hasBat = true
- }
- if obs.ID == "p_boot" {
- bootTicks = 600
- }
- continue
- } else {
- if hasBat && obs.Type == "teacher" {
- hasBat = false
- log.Printf("[%s] ⚾ Bat used on %s", sessionID, obs.ID)
- continue
- }
- if godLives > 0 {
- godLives--
- log.Printf("[%s] 🛡️ Godmode saved life", sessionID)
- continue
- }
- log.Printf("💀 DEATH [%s]: Hit %s at Tick %d", sessionID, obs.ID, ticksAlive)
- playerDead = true
- }
- }
-
- nextObstacles = append(nextObstacles, obs)
- }
- obstacles = nextObstacles
-
- 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
-
- for _, d := range defaultConfig.Obstacles {
- if isBossPhase {
- if d.ID == "principal" || d.ID == "trashcan" {
- possibleDefs = append(possibleDefs, d)
- }
- } else {
- if d.ID == "principal" {
- continue
- }
- if d.ID == "eraser" && ticksAlive < 3000 {
- continue
- }
- possibleDefs = append(possibleDefs, d)
- }
- }
-
- def := rng.PickDef(possibleDefs)
- if def != nil && def.CanTalk {
- if rng.NextFloat() > 0.7 {
- rng.NextFloat()
- }
- }
-
- if def != nil {
- if def.Type == "powerup" && rng.NextFloat() > 0.1 {
- def = nil
- }
-
- if def != nil {
- spawnY := GroundY - def.Height - def.YOffset
- obstacles = append(obstacles, ActiveObstacle{
- ID: def.ID,
- Type: def.Type,
- X: spawnX,
- Y: spawnY,
- Width: def.Width,
- Height: def.Height,
- })
- }
- }
- }
-
- if !playerDead {
- score++
- } else {
+func checkJumpSuspicion(s *SimState) {
+ var distToObs float64 = -1.0
+ for _, o := range s.Obstacles {
+ if o.X > 50.0 {
+ distToObs = o.X - 50.0
break
}
}
-
- if suspicionScore > 15 {
- log.Printf("🤖 BOT-ALARM [%s]: Zu perfekte Sprünge (Heuristik)", sessionID)
- playerDead = true
+ if distToObs > 0 {
+ diff := math.Abs(distToObs - s.LastJumpDist)
+ if diff < 1.0 {
+ s.SuspicionScore++
+ } else if s.SuspicionScore > 0 {
+ s.SuspicionScore--
+ }
+ s.LastJumpDist = distToObs
}
-
- obsJson, _ := json.Marshal(obstacles)
- batStr := "0"
- if hasBat {
- batStr = "1"
- }
-
- 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,
- "obstacles": string(obsJson),
- "p_god_lives": godLives,
- "p_has_bat": batStr,
- "p_boot_ticks": bootTicks,
- "ac_last_dist": fmt.Sprintf("%f", lastJumpDist),
- "ac_suspicion": suspicionScore,
- })
-
- pState := PowerUpState{
- GodLives: godLives,
- HasBat: hasBat,
- BootTicks: bootTicks,
- }
-
- return playerDead, score, obstacles, pState, ticksAlive, nextSpawnTick
}
func parseOr(s string, def float64) float64 {
diff --git a/static/assets/baskeball.png b/static/assets/baskeball.png
new file mode 100644
index 0000000..2bcc206
Binary files /dev/null and b/static/assets/baskeball.png differ
diff --git a/static/assets/coin.png b/static/assets/coin.png
index e8edeb8..2cdb6e2 100644
Binary files a/static/assets/coin.png and b/static/assets/coin.png differ
diff --git a/static/assets/desk.png b/static/assets/desk.png
index d4fdedf..b6fecaf 100644
Binary files a/static/assets/desk.png and b/static/assets/desk.png differ
diff --git a/static/assets/eraser.png b/static/assets/eraser.png
index 3fcc99f..58b1824 100644
Binary files a/static/assets/eraser.png and b/static/assets/eraser.png differ
diff --git a/static/assets/g-l.png b/static/assets/g-l.png
new file mode 100644
index 0000000..6f6d012
Binary files /dev/null and b/static/assets/g-l.png differ
diff --git a/static/assets/h-l.png b/static/assets/h-l.png
new file mode 100644
index 0000000..89ff30c
Binary files /dev/null and b/static/assets/h-l.png differ
diff --git a/static/assets/k-l-monitor.png b/static/assets/k-l-monitor.png
new file mode 100644
index 0000000..e347b1f
Binary files /dev/null and b/static/assets/k-l-monitor.png differ
diff --git a/static/assets/k-l.png b/static/assets/k-l.png
new file mode 100644
index 0000000..1085020
Binary files /dev/null and b/static/assets/k-l.png differ
diff --git a/static/assets/k-m.png b/static/assets/k-m.png
new file mode 100644
index 0000000..3df6b53
Binary files /dev/null and b/static/assets/k-m.png differ
diff --git a/static/assets/m-l.png b/static/assets/m-l.png
new file mode 100644
index 0000000..800b141
Binary files /dev/null and b/static/assets/m-l.png differ
diff --git a/static/assets/p-l.png b/static/assets/p-l.png
new file mode 100644
index 0000000..e659e9e
Binary files /dev/null and b/static/assets/p-l.png differ
diff --git a/static/assets/pc-trash.png b/static/assets/pc-trash.png
new file mode 100644
index 0000000..3335dde
Binary files /dev/null and b/static/assets/pc-trash.png differ
diff --git a/static/assets/r-l.png b/static/assets/r-l.png
new file mode 100644
index 0000000..daf3d2d
Binary files /dev/null and b/static/assets/r-l.png differ
diff --git a/static/assets/sfx/coin.mp3 b/static/assets/sfx/coin.mp3
new file mode 100644
index 0000000..885f466
Binary files /dev/null and b/static/assets/sfx/coin.mp3 differ
diff --git a/static/assets/sfx/duck.mp3 b/static/assets/sfx/duck.mp3
new file mode 100644
index 0000000..b93df84
Binary files /dev/null and b/static/assets/sfx/duck.mp3 differ
diff --git a/static/assets/sfx/hit.mp3 b/static/assets/sfx/hit.mp3
new file mode 100644
index 0000000..a46ee83
Binary files /dev/null and b/static/assets/sfx/hit.mp3 differ
diff --git a/static/assets/sfx/jump.mp3 b/static/assets/sfx/jump.mp3
new file mode 100644
index 0000000..4efe9bb
Binary files /dev/null and b/static/assets/sfx/jump.mp3 differ
diff --git a/static/assets/sfx/music_loop.mp3 b/static/assets/sfx/music_loop.mp3
new file mode 100644
index 0000000..6986023
Binary files /dev/null and b/static/assets/sfx/music_loop.mp3 differ
diff --git a/static/assets/sfx/pickup.mp3 b/static/assets/sfx/pickup.mp3
new file mode 100644
index 0000000..476b16c
Binary files /dev/null and b/static/assets/sfx/pickup.mp3 differ
diff --git a/static/assets/sfx/powerup.mp3 b/static/assets/sfx/powerup.mp3
new file mode 100644
index 0000000..76d0902
Binary files /dev/null and b/static/assets/sfx/powerup.mp3 differ
diff --git a/static/assets/t-s.png b/static/assets/t-s.png
new file mode 100644
index 0000000..85b6a3d
Binary files /dev/null and b/static/assets/t-s.png differ
diff --git a/static/assets/w-l.png b/static/assets/w-l.png
new file mode 100644
index 0000000..01353fa
Binary files /dev/null and b/static/assets/w-l.png differ
diff --git a/static/index.html b/static/index.html
index 7377c20..69997bb 100644
--- a/static/index.html
+++ b/static/index.html
@@ -8,13 +8,12 @@
-
+
📱↻
Bitte Gerät drehen!
Querformat benötigt
-
@@ -85,7 +84,7 @@
MEINE BEWEISE
-
Zeige diesen Code dem Lehrer für deinen Preis oder lösche den Eintrag.
+
Zeige diesen Code für deinen Preis oder lösche den Eintrag.
@@ -135,6 +134,8 @@
+
+
diff --git a/static/js/audio.js b/static/js/audio.js
new file mode 100644
index 0000000..4d0402a
--- /dev/null
+++ b/static/js/audio.js
@@ -0,0 +1,57 @@
+const SOUNDS = {
+ jump: new Audio('assets/sfx/jump.mp3'),
+ duck: new Audio('assets/sfx/duck.mp3'),
+ coin: new Audio('assets/sfx/coin.mp3'),
+ hit: new Audio('assets/sfx/hit.mp3'),
+ powerup: new Audio('assets/sfx/powerup.mp3'),
+ music: new Audio('assets/sfx/music_loop.mp3')
+};
+
+// Config
+SOUNDS.jump.volume = 0.4;
+SOUNDS.coin.volume = 0.3;
+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) {
+ if (isMuted || !SOUNDS[name]) return;
+
+ const soundClone = SOUNDS[name].cloneNode();
+ soundClone.volume = SOUNDS[name].volume;
+ soundClone.play().catch(() => {});
+}
+
+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(()=>{});
+ }
+
+ return isMuted;
+}
+
+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;
+}
\ No newline at end of file
diff --git a/static/js/config.js b/static/js/config.js
index 5eefcc0..dc26874 100644
--- a/static/js/config.js
+++ b/static/js/config.js
@@ -1,34 +1,29 @@
-// Konstanten
+// ==========================================
+// SPIEL KONFIGURATION & KONSTANTEN
+// ==========================================
+
+// Dimensionen (Muss zum Canvas passen)
const GAME_WIDTH = 800;
const GAME_HEIGHT = 400;
-const GRAVITY = 0.6;
-const JUMP_POWER = -12;
-const HIGH_JUMP_POWER = -16;
-const GROUND_Y = 350;
-const BASE_SPEED = 5.0;
-const CHUNK_SIZE = 60;
-const TARGET_FPS = 60;
+
+// 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
+
+// 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)
+// Debugging
+// true = Zeigt Hitboxen (Grün) und Server-Daten (Cyan)
const DEBUG_SYNC = true;
-const SYNC_TOLERANCE = 5.0;
-// RNG Klasse
-class PseudoRNG {
- constructor(seed) {
- this.state = BigInt(seed);
- }
- nextFloat() {
- const a = 1664525n; const c = 1013904223n; const m = 4294967296n;
- this.state = (this.state * a + c) % m;
- return Number(this.state) / Number(m);
- }
- nextRange(min, max) {
- return min + (this.nextFloat() * (max - min));
- }
- pick(array) {
- if (!array || array.length === 0) return null;
- const idx = Math.floor(this.nextRange(0, array.length));
- return array[idx];
- }
+function lerp(a, b, t) {
+ return a + (b - a) * t;
}
\ No newline at end of file
diff --git a/static/js/input.js b/static/js/input.js
index 916b772..c471a57 100644
--- a/static/js/input.js
+++ b/static/js/input.js
@@ -1,45 +1,114 @@
+// ==========================================
+// INPUT HANDLING (WEBSOCKET VERSION)
+// ==========================================
+
function handleInput(action, active) {
- if (isGameOver) { if(active) location.reload(); return; }
-
- const relativeTick = currentTick - lastSentTick;
+ // 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;
- inputLog.push({ t: relativeTick, act: "JUMP" });
+
+ 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");
+ }
}
}
- if (action === "DUCK") { isCrouching = active; }
}
-// Event Listeners
+// ==========================================
+// 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);
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", true);
+ if (e.code === 'F9') {
+ 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" }));
+ }
+ }
+ }
});
+
window.addEventListener('keyup', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.code === 'ArrowDown' || e.code === 'KeyS') handleInput("DUCK", false);
});
+
+// Maus / Touch (Einfach)
window.addEventListener('mousedown', (e) => {
+ // Nur Linksklick und nur auf dem Canvas
if (e.target === canvas && e.button === 0) handleInput("JUMP", true);
});
-// Touch Logic
+// Touch (Swipe Gesten)
let touchStartY = 0;
+
window.addEventListener('touchstart', (e) => {
- if(e.target === canvas) { e.preventDefault(); touchStartY = e.touches[0].clientY; }
+ if(e.target === canvas) {
+ e.preventDefault();
+ touchStartY = e.touches[0].clientY;
+ }
}, { passive: false });
+
window.addEventListener('touchend', (e) => {
if(e.target === canvas) {
e.preventDefault();
- const diff = e.changedTouches[0].clientY - touchStartY;
- if (diff < -30) handleInput("JUMP", true);
- else if (diff > 30) { handleInput("DUCK", true); setTimeout(() => handleInput("DUCK", false), 800); }
- else if (Math.abs(diff) < 10) handleInput("JUMP", true);
+ const touchEndY = e.changedTouches[0].clientY;
+ const diff = touchEndY - touchStartY;
+
+ // Nach oben wischen oder Tippen = Sprung
+ if (diff < -30) {
+ handleInput("JUMP", true);
+ }
+ // Nach unten wischen = Ducken (kurz)
+ else if (diff > 30) {
+ handleInput("DUCK", true);
+ setTimeout(() => handleInput("DUCK", false), 800);
+ }
+ // Einfaches Tippen (wenig Bewegung) = Sprung
+ else if (Math.abs(diff) < 10) {
+ handleInput("JUMP", true);
+ }
}
});
\ No newline at end of file
diff --git a/static/js/logic.js b/static/js/logic.js
index 1f35471..d7fe7cc 100644
--- a/static/js/logic.js
+++ b/static/js/logic.js
@@ -1,172 +1,210 @@
function updateGameLogic() {
- // 1. Input Logging (Ducken)
- if (isCrouching) {
- inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" });
- }
+ // ===============================================
+ // 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;
- // 2. Geschwindigkeit (Basiert auf ZEIT/Ticks, nicht Score!)
- let currentSpeed = 5 + (currentTick / 3000.0) * 0.5;
- if (currentSpeed > 12.0) currentSpeed = 12.0;
+ updateParticles();
- // 3. Spieler Physik & Größe
+
+ player.prevY = player.y;
+
+ 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; // Fast Fall
- player.y += player.vy;
+ if (isCrouching && !player.grounded) player.vy += 2.0;
- if (player.y + originalHeight >= GROUND_Y) {
- player.y = GROUND_Y - originalHeight;
+ 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) {
+ newY = plat.y - originalHeight;
+ player.vy = 0;
+ landed = true;
+ sendPhysicsSync(newY, 0);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // --- BODEN ---
+ if (!landed && newY + originalHeight >= GROUND_Y) {
+ newY = GROUND_Y - originalHeight;
player.vy = 0;
- player.grounded = true;
- } else {
- player.grounded = false;
+ landed = true;
}
- // 4. Hindernisse Bewegen & Kollision
- let nextObstacles = [];
+ if (currentTick % 10 === 0) {
+ sendPhysicsSync(player.y, player.vy);
+ }
- for (let obs of obstacles) {
- obs.x -= currentSpeed;
+ player.y = newY;
+ player.grounded = landed;
- // Aufräumen, wenn links raus
- if (obs.x + obs.def.width < -50.0) continue;
+ // ===============================================
+ // 3. PUFFER BEWEGEN (STREAMING)
+ // ===============================================
- // --- 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;
+ obstacleBuffer.forEach(o => o.x -= currentSpeed);
+ platformBuffer.forEach(p => p.x -= currentSpeed);
- // Spieler ist bei 50. Wir geben 5px Puffer.
- if (realRightEdge < 55) {
- nextObstacles.push(obs); // Behalten, aber keine Kollisionsprüfung mehr
- continue;
- }
- // -------------------------------
+ // 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
- // Kollisionsprüfung
- const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h };
+ // ===============================================
+ // 4. KOLLISION & TRANSFER (LOGIK + RENDER LISTE)
+ // ===============================================
- if (checkCollision(playerHitbox, obs)) {
- // A. COIN
- if (obs.def.type === "coin") {
- score += 2000;
- continue; // Entfernen
- }
- // 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; // Für Sync merken
- continue; // Entfernen
- }
- // C. GEGNER
- else {
- if (hasBat && obs.def.type === "teacher") {
- hasBat = false;
- continue; // Zerstört
- }
- if (godModeLives > 0) {
- godModeLives--;
- continue; // Geschützt
+ 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) {
+ baseDef = gameConfig.obstacles.find(x => x.id === obs.id);
}
+ obs.def = {
+ id: obs.id,
+ type: obs.type || (baseDef ? baseDef.type : "obstacle"),
+ width: obs.w || (baseDef ? baseDef.width : 30),
+ height: obs.h || (baseDef ? baseDef.height : 30),
+ color: obs.color || (baseDef ? baseDef.color : "red"),
+ image: baseDef ? baseDef.image : null,
+ imgScale: baseDef ? baseDef.imgScale : 1.0,
+ imgOffsetX: baseDef ? baseDef.imgOffsetX : 0,
+ imgOffsetY: baseDef ? baseDef.imgOffsetY : 0
+ };
+ }
- player.color = "darkred";
- if (!isGameOver) {
- sendChunk();
- gameOver("Kollision");
+ // 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)) {
+
+ const type = obs.def.type;
+ const id = obs.def.id;
+
+ // 1. COIN
+ if (type === "coin") {
+ score += 2000; // Sofort addieren!
+ obs.collected = true; // Markieren als "weg"
+ playSound('coin');
+ spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 10);
+ }
+ // 2. POWERUP
+ 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
+ playSound('powerup');
+ spawnParticles(obs.x + 15, obs.y + 15, 'sparkle', 20); // Mehr Partikel
+
+ obs.collected = true; // Markieren als "weg"
+ }
+ // 3. GEGNER (Teacher/Obstacle)
+ else {
+ // Baseballschläger vs Lehrer
+ if (hasBat && type === "teacher") {
+ hasBat = false;
+ obs.collected = true; // Wegschlagen
+ 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
+ obs.collected = true;
+ }
+ // TOT
+ else {
+ console.log("💥 Kollision!");
+ player.color = "darkred";
+ gameOver("Kollision");
+ playSound('hit');
+ spawnParticles(player.x + 15, player.y + 25, 'explosion', 50); // Riesige Explosion
+ if (typeof sendInput === "function") sendInput("input", "DEATH");
+ }
+ }
}
}
- }
- nextObstacles.push(obs);
- }
- obstacles = nextObstacles;
-
- // 5. Spawning (Zeitbasiert & Synchron)
-
- // 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) {
- if (def.id === "principal" || def.id === "trashcan") possibleObs.push(def);
- } else {
- if (def.id === "principal") return;
- // Eraser erst ab Tick 3000
- if (def.id === "eraser" && currentTick < 3000) return;
- possibleObs.push(def);
+ // C. Zur Render-Liste hinzufügen (Nur wenn NICHT eingesammelt)
+ if (!obs.collected) {
+ obstacles.push(obs);
}
- });
-
- let def = rng.pick(possibleObs);
-
- // RNG Sync: Speech
- let speech = null;
- if (def && def.canTalk) {
- if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines);
}
+ });
- // RNG Sync: Powerup Rarity
- if (def && def.type === "powerup") {
- if (rng.nextFloat() > 0.1) def = null;
+ // --- PLATTFORMEN ---
+ platformBuffer.forEach(plat => {
+ if (plat.x < RENDER_LIMIT) {
+ platforms.push(plat);
}
-
- if (def) {
- const yOffset = def.yOffset || 0;
- obstacles.push({
- x: spawnX,
- y: GROUND_Y - def.height - yOffset,
- def: def,
- speech: speech
- });
- }
- }
+ });
}
+// Helper: Robuste Kollisionsprüfung
function checkCollision(p, obs) {
- const paddingX = 10;
- const paddingY_Top = (obs.def.type === "teacher") ? 25 : 10;
- const paddingY_Bottom = 5;
+ const def = obs.def || {};
+ const w = def.width || obs.w || 30;
+ const h = def.height || obs.h || 30;
- // 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;
+ // Kleines Padding, damit es fair ist
+ const padX = 8;
+ const padY = (def.type === "teacher" || def.type === "principal") ? 20 : 5;
- const pLeft = p.x + paddingX;
- const pRight = p.x + p.w - paddingX;
- const pTop = p.y + paddingY_Top;
- const pBottom = p.y + p.h - paddingY_Bottom;
+ // Koordinaten
+ const pL = p.x + padX;
+ const pR = p.x + p.w - padX;
+ const pT = p.y + padY;
+ const pB = p.y + p.h - 5;
- const oLeft = obs.x + paddingX;
- // 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 oL = obs.x + padX;
+ const oR = obs.x + w - padX;
+ const oT = obs.y + padY;
+ const oB = obs.y + h - 5;
- const oTop = obs.y + paddingY_Top;
- const oBottom = obs.y + obs.def.height - paddingY_Bottom;
-
- return (pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom);
+ return (pR > oL && pL < oR && pB > oT && pT < oB);
}
\ No newline at end of file
diff --git a/static/js/main.js b/static/js/main.js
index 9e99bd2..0ba32cf 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -1,247 +1,163 @@
// ==========================================
-// INIT & ASSETS
+// 1. ASSETS LADEN
// ==========================================
async function loadAssets() {
- playerSprite.src = "assets/player.png";
+ const pPromise = new Promise(resolve => {
+ playerSprite.src = "assets/player.png";
+ playerSprite.onload = resolve;
+ playerSprite.onerror = () => { resolve(); };
+ });
- // Hintergründe laden
const bgPromises = gameConfig.backgrounds.map((bgFile, index) => {
return new Promise((resolve) => {
const img = new Image();
img.src = "assets/" + bgFile;
img.onload = () => { bgSprites[index] = img; resolve(); };
- img.onerror = () => {
- console.warn("BG fehlt:", bgFile);
- bgSprites[index] = null;
- resolve();
- };
- });
- });
-
- // Hindernisse laden
- const obsPromises = gameConfig.obstacles.map(def => {
- return new Promise((resolve) => {
- if (!def.image) { resolve(); return; }
- const img = new Image(); img.src = "assets/" + def.image;
- img.onload = () => { sprites[def.id] = img; resolve(); };
img.onerror = () => { resolve(); };
});
});
- // Player laden (kleiner Promise Wrapper)
- const pPromise = new Promise(r => {
- playerSprite.onload = r;
- playerSprite.onerror = r;
+ const obsPromises = gameConfig.obstacles.map(def => {
+ return new Promise((resolve) => {
+ if (!def.image) { resolve(); return; }
+ const img = new Image();
+ img.src = "assets/" + def.image;
+ img.onload = () => { sprites[def.id] = img; resolve(); };
+ img.onerror = () => { resolve(); };
+ });
});
await Promise.all([pPromise, ...bgPromises, ...obsPromises]);
}
// ==========================================
-// START LOGIK
+// 2. SPIEL STARTEN
// ==========================================
window.startGameClick = async function() {
if (!isLoaded) return;
+
startScreen.style.display = 'none';
document.body.classList.add('game-active');
- try {
- const sRes = await fetch('/api/start', {method:'POST'});
- const sData = await sRes.json();
- sessionID = sData.sessionId;
- rng = new PseudoRNG(sData.seed);
- isGameRunning = true;
- maxRawBgIndex = 0;
- lastTime = performance.now();
- resize();
- } catch(e) {
- alert("Start Fehler: " + e.message);
- location.reload();
- }
+
+ // Score Reset visuell
+ score = 0;
+ const scoreEl = document.getElementById('score');
+ if (scoreEl) scoreEl.innerText = "0";
+
+ // WebSocket Start
+ startMusic();
+ connectGame();
+ resize();
};
// ==========================================
-// SCORE EINTRAGEN
+// 3. GAME OVER & HIGHSCORE LOGIK
// ==========================================
+window.gameOver = function(reason) {
+ if (isGameOver) return;
+ isGameOver = true;
+ console.log("Game Over:", reason);
+
+ const finalScore = Math.floor(score / 10);
+ const currentHighscore = localStorage.getItem('escape_highscore') || 0;
+
+ if (finalScore > currentHighscore) {
+ localStorage.setItem('escape_highscore', finalScore);
+ }
+
+ if (gameOverScreen) {
+ 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;
+ const name = nameInput.value.trim();
const btn = document.getElementById('submitBtn');
- if (!name) return alert("Namen eingeben!");
+ if (!name) return alert("Bitte Namen eingeben!");
btn.disabled = true;
try {
const res = await fetch('/api/submit-name', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({ sessionId: sessionID, name: name })
+ body: JSON.stringify({ sessionId: sessionID, name: name }) // sessionID aus state.js
});
- if (!res.ok) throw new Error("Server Error");
+
+ 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, score: Math.floor(score / 10), code: data.claimCode,
- date: new Date().toLocaleString('de-DE'), sessionId: sessionID
+ name: name,
+ score: Math.floor(score / 10),
+ code: data.claimCode,
+ date: new Date().toLocaleString('de-DE'),
+ sessionId: sessionID
});
localStorage.setItem('escape_claims', JSON.stringify(myClaims));
+ // UI Update
document.getElementById('inputSection').style.display = 'none';
loadLeaderboard();
- alert(`Gespeichert! Code: ${data.claimCode}`);
+ alert(`Gespeichert! Dein Code: ${data.claimCode}`);
+
} catch (e) {
- alert("Fehler: " + e.message);
+ console.error(e);
+ alert("Fehler beim Speichern: " + e.message);
btn.disabled = false;
}
};
-// ==========================================
-// MEINE CODES & LÖSCHEN
-// ==========================================
-window.showMyCodes = function() {
- if(window.openModal) window.openModal('codes');
-
- const listEl = document.getElementById('codesList');
- if(!listEl) return;
-
-
- const rawClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
-
- if (rawClaims.length === 0) {
- listEl.innerHTML = "Keine Codes gespeichert.
";
- return;
- }
-
- const sortedClaims = rawClaims
- .map((item, index) => ({ ...item, originalIndex: index }))
- .sort((a, b) => b.score - a.score);
-
- let html = "";
-
-
- sortedClaims.forEach(c => {
- const canDelete = c.sessionId ? true : false;
- const btnStyle = canDelete ? "cursor:pointer; color:#ff4444; border-color:#ff4444;" : "cursor:not-allowed; color:gray; border-color:gray;";
- const btnAttr = canDelete ? `onclick="deleteClaim(${c.originalIndex}, '${c.sessionId}', '${c.code}')"` : "disabled";
-
- let rankIcon = "📄";
- if (c.score >= 10000) rankIcon = "🔥";
- if (c.score >= 5000) rankIcon = "⭐";
-
- html += `
-
-
- ${rankIcon} ${c.code}
- (${c.score} Pkt)
- ${c.name} • ${c.date}
-
-
-
`;
- });
-
- listEl.innerHTML = html;
-};
-
-window.deleteClaim = async function(index, sid, code) {
- if(!confirm("Wirklich löschen?")) return;
- try {
- const res = await fetch('/api/claim/delete', {
- method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ sessionId: sid, claimCode: code })
- });
- if (!res.ok) {
- if(!confirm("Server Fehler (evtl. schon weg). Lokal löschen?")) return;
- }
- let claims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
- claims.splice(index, 1);
- localStorage.setItem('escape_claims', JSON.stringify(claims));
- window.showMyCodes();
- loadLeaderboard();
- } catch(e) { alert("Verbindungsfehler!"); }
-};
-
+// 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();
- let html = "BESTENLISTE
";
+ let html = "BESTENLISTE
";
+
+ if(entries.length === 0) html += "Noch keine Einträge.
";
entries.forEach(e => {
- const color = e.isMe ? "yellow" : "white";
- const bgStyle = e.isMe ? "background:rgba(255,255,0,0.1);" : "";
-
- const betterThanMe = e.rank - 1;
- let infoText = "";
-
- if (e.isMe && betterThanMe > 0) {
- infoText = `(${betterThanMe} waren besser)
`;
- } else if (e.isMe && betterThanMe === 0) {
- infoText = `👑 NIEMAND ist besser!
`;
- }
+ const color = e.isMe ? "cyan" : "white"; // Eigener Name in Cyan
+ const bgStyle = e.isMe ? "background:rgba(0,255,255,0.1);" : "";
html += `
-
-
- #${e.rank} ${e.name.toUpperCase()}
- ${Math.floor(e.score/10)}
-
- ${infoText}
+
+ #${e.rank} ${e.name}
+ ${Math.floor(e.score/10)}
`;
-
- if(e.rank === 3 && entries.length > 3 && !entries[3].isMe) {
- html += "
...
";
- }
});
document.getElementById('leaderboard').innerHTML = html;
- } catch(e) { console.error(e); }
-}
-
-async function loadStartScreenLeaderboard() {
- try {
- const listEl = document.getElementById('startLeaderboardList');
- if (!listEl) return;
- const res = await fetch('/api/leaderboard');
- const entries = await res.json();
- if (entries.length === 0) { listEl.innerHTML = "
Noch keine Scores.
"; return; }
- let html = "";
- entries.forEach(e => {
- let icon = "#" + e.rank;
- if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
- html += `
${icon} ${e.name}${Math.floor(e.score / 10)}
`;
- });
- listEl.innerHTML = html;
- } catch (e) {}
-}
-
-function gameOver(reason) {
- if (isGameOver) return;
- isGameOver = true;
- const finalScoreVal = Math.floor(score / 10);
- const currentHighscore = localStorage.getItem('escape_highscore') || 0;
- if (finalScoreVal > currentHighscore) localStorage.setItem('escape_highscore', finalScoreVal);
- gameOverScreen.style.display = 'flex';
- document.getElementById('finalScore').innerText = finalScoreVal;
- loadLeaderboard();
- drawGame();
+ } catch(e) {
+ console.error("Leaderboard Error:", e);
+ }
}
// ==========================================
-// DER FIXIERTE GAME LOOP
+// 4. GAME LOOP
// ==========================================
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
- // 1. Wenn Assets noch nicht da sind, machen wir gar nichts
if (!isLoaded) return;
- // 2. PHYSIK-LOGIK (Nur wenn Spiel läuft und nicht Game Over)
- // Das hier sorgt dafür, dass der Dino stehen bleibt, wenn wir im Menü sind
if (isGameRunning && !isGameOver) {
-
if (!lastTime) lastTime = timestamp;
const deltaTime = timestamp - lastTime;
lastTime = timestamp;
@@ -254,28 +170,34 @@ function gameLoop(timestamp) {
updateGameLogic();
currentTick++;
score++;
- if (currentTick - lastSentTick >= CHUNK_SIZE) sendChunk();
accumulator -= MS_PER_TICK;
}
+ const alpha = accumulator / MS_PER_TICK;
+
+ // Score im HUD
const scoreEl = document.getElementById('score');
if (scoreEl) scoreEl.innerText = Math.floor(score / 10);
}
- // 3. RENDERING (IMMER!)
- // Das hier war das Problem. Früher stand hier "return" wenn !isGameRunning.
- // Jetzt malen wir immer. Wenn isGameRunning false ist, malt er einfach den Start-Zustand.
- drawGame();
+ drawGame(isGameRunning ? accumulator / MS_PER_TICK : 1.0);
}
+// ==========================================
+// 5. INIT
+// ==========================================
async function initGame() {
try {
- const cRes = await fetch('/api/config'); gameConfig = await cRes.json();
+ const cRes = await fetch('/api/config');
+ gameConfig = await cRes.json();
- // Erst alles laden
await loadAssets();
await loadStartScreenLeaderboard();
+ if (typeof getMuteState === 'function') {
+ updateMuteIcon(getMuteState());
+ }
+
isLoaded = true;
if(loadingText) loadingText.style.display = 'none';
if(startBtn) startBtn.style.display = 'inline-block';
@@ -284,10 +206,7 @@ async function initGame() {
const hsEl = document.getElementById('localHighscore');
if(hsEl) hsEl.innerText = savedHighscore;
- // Loop starten (mit dummy timestamp start)
requestAnimationFrame(gameLoop);
-
- // Initiales Zeichnen erzwingen (damit Hintergrund sofort da ist)
drawGame();
} catch(e) {
@@ -296,4 +215,147 @@ async function initGame() {
}
}
+// Helper: Mini-Leaderboard auf Startseite
+async function loadStartScreenLeaderboard() {
+ try {
+ const listEl = document.getElementById('startLeaderboardList');
+ if (!listEl) return;
+ const res = await fetch('/api/leaderboard');
+ const entries = await res.json();
+
+ if (entries.length === 0) { listEl.innerHTML = "
Keine Scores.
"; return; }
+
+ let html = "";
+ entries.forEach(e => {
+ let icon = "#" + e.rank;
+ if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
+ html += `
${icon} ${e.name}${Math.floor(e.score / 10)}
`;
+ });
+ listEl.innerHTML = html;
+ } 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();
+};
+
+function updateMuteIcon(isMuted) {
+ const btn = document.getElementById('mute-btn');
+ if (btn) {
+ btn.innerText = isMuted ? "🔇" : "🔊";
+ btn.style.color = isMuted ? "#ff4444" : "white";
+ btn.style.borderColor = isMuted ? "#ff4444" : "#555";
+ }
+}
+
+// ==========================================
+// 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) {
+ listEl.innerHTML = "
Keine Codes gespeichert.
";
+ return;
+ }
+
+ // Sortieren nach Score (Höchster zuerst)
+ const sortedClaims = rawClaims
+ .map((item, index) => ({ ...item, originalIndex: index }))
+ .sort((a, b) => b.score - a.score);
+
+ let html = "";
+
+ sortedClaims.forEach(c => {
+ // Icons basierend auf Score
+ let rankIcon = "📄";
+ if (c.score >= 5000) rankIcon = "⭐";
+ if (c.score >= 10000) rankIcon = "🔥";
+ if (c.score >= 20000) rankIcon = "👑";
+
+ html += `
+
+
+ ${rankIcon} ${c.code}
+ (${c.score} Pkt)
+ ${c.name} • ${c.date}
+
+
+
`;
+ });
+
+ 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',
+ headers: {'Content-Type': 'application/json'},
+ body: JSON.stringify({ sessionId: sid, claimCode: code })
+ });
+ } catch(e) {
+ 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';
+}
+
+window.closeModal = function() {
+ const modals = document.querySelectorAll('.modal-overlay');
+ modals.forEach(el => el.style.display = 'none');
+}
+
+// Klick nebendran schließt Modal
+window.onclick = function(event) {
+ if (event.target.classList.contains('modal-overlay')) {
+ closeModal();
+ }
+}
+
initGame();
\ No newline at end of file
diff --git a/static/js/network.js b/static/js/network.js
index 1a9106a..246c7d4 100644
--- a/static/js/network.js
+++ b/static/js/network.js
@@ -1,199 +1,377 @@
-async function sendChunk() {
- const ticksToSend = currentTick - lastSentTick;
- if (ticksToSend <= 0) return;
+// ==========================================
+// NETZWERK LOGIK (WEBSOCKET + RTT SYNC)
+// ==========================================
+/*
+ GLOBALE VARIABLEN (aus state.js):
+ - socket
+ - obstacleBuffer, platformBuffer
+ - currentLatencyMs, pingInterval
+ - isGameRunning, isGameOver
+ - score, currentTick
+*/
- const snapshotobstacles = JSON.parse(JSON.stringify(obstacles));
+function connectGame() {
+ // Alte Verbindung schließen
+ if (socket) {
+ socket.close();
+ }
- const payload = {
- sessionId: sessionID,
- inputs: [...inputLog],
- totalTicks: ticksToSend
+ // 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.");
+
+ // Alles zurücksetzen
+ obstacleBuffer = [];
+ platformBuffer = [];
+ obstacles = [];
+ platforms = [];
+ currentLatencyMs = 0; // Reset Latenz
+
+ isGameRunning = true;
+ isGameOver = false;
+ isLoaded = true;
+
+ // PING LOOP STARTEN (Jede Sekunde messen)
+ pingInterval = setInterval(sendPing, 1000);
+
+ // Game Loop anwerfen
+ requestAnimationFrame(gameLoop);
};
- inputLog = [];
- lastSentTick = currentTick;
+ // --- 2. NACHRICHT VOM SERVER ---
+ socket.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
- try {
- const res = await fetch('/api/validate', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify(payload)
- });
+ // A. PONG (Latenzmessung)
+ if (msg.type === "pong") {
+ const now = Date.now();
+ const sentTime = msg.ts; // Server schickt unseren Timestamp zurück
- const data = await res.json();
+ // Round Trip Time (Hin + Zurück)
+ const rtt = now - sentTime;
- // Update für visuelles Debugging
- if (data.serverObs) {
- serverObstacles = data.serverObs;
+ // One Way Latency (Latenz in eine Richtung)
+ const latency = rtt / 2;
- if (typeof DEBUG_SYNC !== 'undefined' && DEBUG_SYNC) {
- compareState(snapshotobstacles, data.serverObs);
- }
-
- if (data.powerups) {
- const sTick = data.serverTick;
-
- if (lastPowerupTick > sTick) {
+ // 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 {
- godModeLives = data.powerups.godLives;
- hasBat = data.powerups.hasBat;
- bootTicks = data.powerups.bootTicks;
+ // 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 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
+ }
+
+ // 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
+
+ // 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);
+ });
+ }
+
+ if (msg.platforms) {
+ msg.platforms.forEach(p => {
+ p.x -= dynamicCorrection;
+ p.prevX = p.x;
+ platformBuffer.push(p);
+ });
+ }
+
+ if (msg.score !== undefined) score = msg.score;
+
+ // Powerups übernehmen (für Anzeige)
+ if (msg.powerups) {
+ godModeLives = msg.powerups.godLives;
+ hasBat = msg.powerups.hasBat;
+ bootTicks = msg.powerups.bootTicks;
}
}
- // Sync Spawning Timer
- if (data.NextSpawnTick) {
- if (Math.abs(nextSpawnTick - data.nextSpawnTick) > 5) {
- console.log("Sync Spawn Timer:", nextSpawnTick, "->", data.NextSpawnTick);
- nextSpawnTick = data.nextSpawnTick;
+ if (msg.type === "init") {
+ console.log("📩 INIT EMPFANGEN:", msg); // <--- DEBUG LOG
+
+ if (msg.sessionId) {
+ sessionID = msg.sessionId; // Globale Variable setzen
+ console.log("🔑 Session ID gesetzt auf:", sessionID);
+ } else {
+ console.error("❌ INIT FEHLER: Keine sessionId im Paket!", msg);
}
}
- }
+ // C. TOD (Server Authoritative)
+ if (msg.type === "dead") {
+ console.log("💀 Server sagt: Game Over");
- if (data.status === "dead") {
- console.error("💀 SERVER KILL", data);
- gameOver("Vom Server gestoppt");
- } else {
- const sScore = data.verifiedScore;
- // Score Korrektur
- if (Math.abs(score - sScore) > 200) {
- console.warn(`⚠️ SCORE DRIFT: Client=${score} Server=${sScore}`);
- score = sScore;
+ if (msg.score) score = msg.score;
+
+ // Verbindung sauber trennen
+ socket.close();
+ if (pingInterval) clearInterval(pingInterval);
+
+ gameOver("Vom Server gestoppt");
}
+
+ if (msg.type === "debug_sync") {
+
+
+
+ // 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 ? "✅" : "❌";
+
+ console.group(`📊 SYNC REPORT (Tick: ${currentTick} vs Server: ${msg.serverTick})`);
+
+ // --- DER NEUE SPEED CHECK ---
+ console.log(`🚀 SPEED CHECK: ${speedIcon}`);
+ console.log(` Client: ${clientSpeed.toFixed(4)} px/tick (Basis: Tick ${currentTick})`);
+ console.log(` Server: ${serverSpeed.toFixed(4)} px/tick (Basis: Tick ${msg.serverTick})`);
+
+ if (Math.abs(diffSpeed) > 0.01) {
+ 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();
+ }
+
+ } catch (e) {
+ console.error("Fehler beim Verarbeiten der Nachricht:", e);
}
+ };
- } catch (e) {
- console.error("Netzwerkfehler:", e);
+ // --- 3. VERBINDUNG GETRENNT ---
+ socket.onclose = () => {
+ console.log("🔴 WS Verbindung getrennt.");
+ if (pingInterval) clearInterval(pingInterval);
+ };
+
+ socket.onerror = (error) => {
+ console.error("WS Fehler:", error);
+ };
+}
+
+// ==========================================
+// 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
+ }));
}
}
-window.submitScore = async function() {
- const nameInput = document.getElementById('playerNameInput');
- const name = nameInput.value;
- const btn = document.getElementById('submitBtn');
-
- if (!name) return alert("Namen eingeben!");
- btn.disabled = true;
-
- try {
- const res = await fetch('/api/submit-name', {
- method: 'POST',
- headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({ sessionId: sessionID, name: name })
- });
- const data = await res.json();
-
- let myClaims = JSON.parse(localStorage.getItem('escape_claims') || '[]');
- myClaims.push({
- name: name, score: Math.floor(score / 10), code: data.claimCode,
- date: new Date().toLocaleString('de-DE'), sessionId: sessionID
- });
- localStorage.setItem('escape_claims', JSON.stringify(myClaims));
-
- document.getElementById('inputSection').style.display = 'none';
- loadLeaderboard();
- } catch (e) {}
-};
-
-async function loadLeaderboard() {
- const res = await fetch(`/api/leaderboard?sessionId=${sessionID}`);
- const entries = await res.json();
- let html = "
BESTENLISTE
";
- entries.forEach(e => {
- const color = e.isMe ? "yellow" : "white";
- html += `
- #${e.rank} ${e.name}${Math.floor(e.score/10)}
`;
- });
- document.getElementById('leaderboard').innerHTML = html;
-}
-
-async function loadStartScreenLeaderboard() {
- try {
- const listEl = document.getElementById('startLeaderboardList');
- if (!listEl) return;
- const res = await fetch('/api/leaderboard');
- const entries = await res.json();
- if (entries.length === 0) { listEl.innerHTML = "
Noch keine Scores.
"; return; }
- let html = "";
- entries.forEach(e => {
- let icon = "#" + e.rank;
- if (e.rank === 1) icon = "🥇"; if (e.rank === 2) icon = "🥈"; if (e.rank === 3) icon = "🥉";
- html += `
${icon} ${e.name}${Math.floor(e.score / 10)}
`;
- });
- listEl.innerHTML = html;
- } catch (e) {}
-}
-
-function compareState(clientObs, serverObs) {
- // 1. Anzahl prüfen
- if (clientObs.length !== serverObs.length) {
- console.error(`🚨 ANZAHL MISMATCH! Client: ${clientObs.length}, Server: ${serverObs.length}`);
+// ==========================================
+// INPUT SENDEN
+// ==========================================
+function sendInput(type, action) {
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({
+ type: "input",
+ input: action
+ }));
}
+}
+
+// Helper für die Tabelle
+function generateSyncTable(label, clientList, serverList) {
+ if (!serverList) serverList = [];
+
+ console.log(`--- ${label} Analyse (Ping: ${Math.round(currentLatencyMs)}ms) ---`);
const report = [];
- const maxLen = Math.max(clientObs.length, serverObs.length);
- let hasMajorDrift = false;
+ const matchedServerIndices = new Set();
- for (let i = 0; i < maxLen; i++) {
- const cli = clientObs[i];
- const srv = serverObs[i];
+ // 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
- let drift = 0;
- let status = "✅ OK";
+ // Speed Schätzung (gleiche Formel wie in logic.js)
+ let debugSpeed = 15.0 + (score / 1000.0) * 1.5;
+ if (debugSpeed > 36) debugSpeed = 36;
- // Client Objekt vorbereiten
- let cID = "---";
- let cX = 0;
- if (cli) {
- cID = cli.def.id; // Struktur beachten: cli.def.id
- cX = cli.x;
- }
+ const speedPerMs = debugSpeed / msPerTick;
- // Server Objekt vorbereiten
- let sID = "---";
- let sX = 0;
- if (srv) {
- sID = srv.id; // Struktur vom Server: srv.id
- sX = srv.x;
- }
+ // Pixel, die das Objekt wegen Ping weiter "links" sein müsste
+ const latencyPx = currentLatencyMs * speedPerMs;
- // Vergleich
- if (cli && srv) {
- // IDs unterschiedlich? (z.B. Tisch vs Lehrer)
- if (cID !== sID) {
- status = "❌ ID ERROR";
- hasMajorDrift = true;
- } else {
- drift = cX - sX;
- if (Math.abs(drift) > SYNC_TOLERANCE) {
- status = "⚠️ DRIFT";
- hasMajorDrift = true;
- }
+ // 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);
+
+ const isTypeMatch = (label === "Platforms") || (cID === sID);
+
+ // Toleranter Suchradius (500px), falls Drift groß ist
+ if (isTypeMatch && dist < bestDist && dist < 500) {
+ bestDist = dist;
+ bestMatch = sObj;
+ bestSIdx = sIdx;
}
- } else {
- status = "❌ MISSING";
- hasMajorDrift = true;
+ });
+
+ // Datenzeile bauen
+ let serverXRaw = "---";
+ let serverXCorrected = "---";
+ let diffReal = "---";
+ let status = "👻 GHOST (Client only)";
+
+ if (bestMatch) {
+ matchedServerIndices.add(bestSIdx);
+
+ serverXRaw = bestMatch.x;
+ serverXCorrected = bestMatch.x - latencyPx; // Hier rechnen wir den Ping raus
+
+ // 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";
+ else if (absDiff < 150) status = "⚠️ DRIFT";
+ else status = "🔥 BROKEN";
}
- // In Tabelle eintragen
report.push({
- Index: i,
- Status: status,
- "C-ID": cID,
- "S-ID": sID,
- "C-Pos": cX.toFixed(1),
- "S-Pos": sX.toFixed(1),
- "Drift (px)": drift.toFixed(2)
+ "ID": cID,
+ "Client X": Math.round(cObj.x),
+ "Server X (Raw)": Math.round(serverXRaw),
+ "Server X (Sim)": Math.round(serverXCorrected), // Wo es sein sollte
+ "Diff (Real)": typeof diffReal === 'number' ? Math.round(diffReal) : "---",
+ "Status": status
});
- }
+ });
- // Nur loggen, wenn Fehler da sind oder alle 5 Sekunden (Tick 300)
- if (hasMajorDrift || currentTick % 300 === 0) {
- if (hasMajorDrift) console.warn("--- SYNC PROBLEME GEFUNDEN ---");
- else console.log("--- Sync Check (Routine) ---");
+ // 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";
- console.table(report); // Das erstellt eine super lesbare Tabelle im Browser
+ if (sPosCorrected > 850) status = "🔮 FUTURE (Buffer)"; // Noch rechts vom Screen
+ if (sPosCorrected < -100) status = "🗑️ OLD (Server lag)"; // Schon links raus
+
+ report.push({
+ "ID": sObj.id || "?",
+ "Client X": "---",
+ "Server X (Raw)": Math.round(sObj.x),
+ "Server X (Sim)": Math.round(sPosCorrected),
+ "Diff (Real)": "---",
+ "Status": status
+ });
+ }
+ });
+
+ // 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)"];
+ return valA - valB;
+ });
+
+ if (report.length > 0) console.table(report);
+ else console.log("Leer.");
+}
+
+function sendPhysicsSync(y, vy) {
+ if (socket && socket.readyState === WebSocket.OPEN) {
+ socket.send(JSON.stringify({
+ type: "sync",
+ y: y,
+ vy: vy,
+ tick: currentTick
+ }));
}
}
\ No newline at end of file
diff --git a/static/js/particles.js b/static/js/particles.js
new file mode 100644
index 0000000..e222f0a
--- /dev/null
+++ b/static/js/particles.js
@@ -0,0 +1,88 @@
+// Globale Partikel-Liste (muss in state.js bekannt sein oder hier exportiert)
+// Wir nutzen die globale Variable 'particles' (fügen wir gleich in state.js hinzu)
+
+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'
+
+ // 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.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;
+ this.decay = 0.03;
+ this.color = '#ffcc00';
+ 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;
+ this.decay = 0.02;
+ this.color = Math.random() > 0.5 ? '#ff4444' : '#ffaa00';
+ this.size = Math.random() * 6 + 3;
+ }
+ }
+
+ update() {
+ this.x += this.vx;
+ this.y += this.vy;
+
+ // Physik
+ if (this.type !== 'sparkle') this.vy += 0.2; // Schwerkraft für Staub/Explosion
+
+ // Reibung
+ this.vx *= 0.95;
+ this.vy *= 0.95;
+
+ this.life -= this.decay;
+ }
+
+ draw(ctx) {
+ ctx.globalAlpha = this.life;
+ ctx.fillStyle = this.color;
+
+ // Quadratische Partikel (schneller zu zeichnen)
+ ctx.fillRect(this.x, this.y, this.size, this.size);
+
+ ctx.globalAlpha = 1.0;
+ }
+}
+
+// --- API ---
+
+function spawnParticles(x, y, type, count = 5) {
+ for(let i=0; i
= 0; i--) {
+ particles[i].update();
+ if (particles[i].life <= 0) {
+ particles.splice(i, 1);
+ }
+ }
+}
+
+function drawParticles() {
+ particles.forEach(p => p.draw(ctx));
+}
\ No newline at end of file
diff --git a/static/js/render.js b/static/js/render.js
index 0b41bdd..7be7ee5 100644
--- a/static/js/render.js
+++ b/static/js/render.js
@@ -1,113 +1,177 @@
+// ==========================================
+// RESIZE LOGIK (LETTERBOXING)
+// ==========================================
function resize() {
- // 1. INTERNE SPIEL-AUFLÖSUNG ERZWINGEN
- // Das behebt den "Zoom/Nur Ecke sichtbar" Fehler
+ // 1. Interne Auflösung fixieren
canvas.width = GAME_WIDTH; // 800
canvas.height = GAME_HEIGHT; // 400
- // 2. Verfügbaren Platz im Browser berechnen (Minus etwas Rand)
+ // 2. Verfügbaren Platz berechnen
const windowWidth = window.innerWidth - 20;
const windowHeight = window.innerHeight - 20;
- const targetRatio = GAME_WIDTH / GAME_HEIGHT; // 2.0
+ const targetRatio = GAME_WIDTH / GAME_HEIGHT;
const windowRatio = windowWidth / windowHeight;
let finalWidth, finalHeight;
- // 3. Letterboxing berechnen
+ // 3. Skalierung berechnen (Aspect Ratio erhalten)
if (windowRatio < targetRatio) {
- // Screen ist schmaler (z.B. Handy Portrait) -> Breite limitiert
finalWidth = windowWidth;
finalHeight = windowWidth / targetRatio;
} else {
- // Screen ist breiter (z.B. Desktop) -> Höhe limitiert
finalHeight = windowHeight;
finalWidth = finalHeight * targetRatio;
}
- // 4. Größe auf den CONTAINER anwenden
+ // 4. Container Größe setzen (Canvas füllt Container via CSS)
if (container) {
container.style.width = `${Math.floor(finalWidth)}px`;
container.style.height = `${Math.floor(finalHeight)}px`;
}
-
- // Hinweis: Wir setzen KEINE style.width/height auf das Canvas Element selbst.
- // Das Canvas erbt "width: 100%; height: 100%" vom CSS und füllt den Container.
}
-// Event Listener
window.addEventListener('resize', resize);
-
-// Einmal sofort ausführen
resize();
-// --- DRAWING ---
-
-function drawGame() {
+// ==========================================
+// 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;
- }
+ if (currentRawIndex > maxRawBgIndex) maxRawBgIndex = currentRawIndex;
const bgIndex = maxRawBgIndex % bgSprites.length;
-
currentBg = bgSprites[bgIndex];
}
-
-
if (currentBg && currentBg.complete && currentBg.naturalHeight !== 0) {
ctx.drawImage(currentBg, 0, 0, GAME_WIDTH, GAME_HEIGHT);
} else {
- // Fallback
ctx.fillStyle = "#f0f0f0";
ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT);
}
- // --- BODEN ---
- // Halb-transparent, damit er über dem Hintergrund liegt
+ // ===============================================
+ // BODEN
+ // ===============================================
ctx.fillStyle = "rgba(60, 60, 60, 0.8)";
ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50);
- // --- HINDERNISSE ---
- obstacles.forEach(obs => {
- const img = sprites[obs.def.id];
+ // ===============================================
+ // 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;
- // Prüfen ob Bild geladen ist
- if (img && img.complete && img.naturalHeight !== 0) {
- ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height);
- } else {
- // Fallback Farbe (Münzen Gold, Rest aus Config)
- if (obs.def.type === "coin") ctx.fillStyle = "gold";
- else ctx.fillStyle = obs.def.color || "red";
-
- ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height);
- }
-
- if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech);
+ // 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
});
- // --- DEBUG RAHMEN (Server Hitboxen) ---
- // Grün im Spiel, Rot bei Tod
- if (DEBUG_SYNC == true) {
- ctx.strokeStyle = isGameOver ? "red" : "lime";
- ctx.lineWidth = 2;
- serverObstacles.forEach(srvObs => {
- ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h);
- });
+ // ===============================================
+ // 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;
+
+ 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);
- // --- SPIELER ---
- // Y-Position und Höhe anpassen für Ducken
- const drawY = isCrouching ? player.y + 25 : player.y;
+ // Ducken Anpassung
+ const drawY = isCrouching ? rPlayerY + 25 : rPlayerY;
const drawH = isCrouching ? 25 : 50;
if (playerSprite.complete && playerSprite.naturalHeight !== 0) {
@@ -117,7 +181,16 @@ function drawGame() {
ctx.fillRect(player.x, drawY, player.w, drawH);
}
- // --- HUD (Powerup Status oben links) ---
+ // ===============================================
+ // PARTIKEL (Visuelle Effekte)
+ // ===============================================
+ if (typeof drawParticles === 'function') {
+ drawParticles();
+ }
+
+ // ===============================================
+ // HUD (Statusanzeige)
+ // ===============================================
if (isGameRunning && !isGameOver) {
ctx.fillStyle = "black";
ctx.font = "bold 10px monospace";
@@ -128,30 +201,29 @@ function drawGame() {
if(hasBat) statusText += `⚾ BAT `;
if(bootTicks > 0) statusText += `👟 ${(bootTicks/60).toFixed(1)}s`;
- // Drift Info (nur wenn Objekte da sind)
- if (DEBUG_SYNC == true && length > 0 && serverObstacles.length > 0) {
- const drift = Math.abs(obstacles[0].x - serverObstacles[0].x).toFixed(1);
- statusText += ` | Drift: ${drift}px`; // Einkommentieren für Debugging
- }
-
if(statusText !== "") {
ctx.fillText(statusText, 10, 40);
}
}
- // --- GAME OVER OVERLAY ---
+ // ===============================================
+ // GAME OVER OVERLAY
+ // ===============================================
if (isGameOver) {
- // Dunkler Schleier über alles
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT);
}
}
-// Sprechblasen Helper
+// Helper: Sprechblase zeichnen
function drawSpeechBubble(x, y, text) {
- const bX = x-20; const bY = y-40; const bW = 120; const bH = 30;
- ctx.fillStyle="white"; ctx.fillRect(bX,bY,bW,bH);
- ctx.strokeRect(bX,bY,bW,bH);
- ctx.fillStyle="black"; ctx.font="10px Arial"; ctx.textAlign="center";
- ctx.fillText(text, bX+bW/2, bY+20);
+ const bX = x - 20;
+ const bY = y - 40;
+ const bW = 120;
+ const bH = 30;
+
+ ctx.fillStyle = "white"; ctx.fillRect(bX, bY, bW, bH);
+ ctx.strokeStyle = "black"; ctx.lineWidth = 1; ctx.strokeRect(bX, bY, bW, bH);
+ ctx.fillStyle = "black"; ctx.font = "10px Arial"; ctx.textAlign = "center";
+ ctx.fillText(text, bX + bW/2, bY + 20);
}
\ No newline at end of file
diff --git a/static/js/state.js b/static/js/state.js
index 80701ab..cdf9dee 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -1,51 +1,79 @@
-// Globale Status-Variablen
-let gameConfig = null;
-let isLoaded = false;
-let isGameRunning = false;
-let isGameOver = false;
-let sessionID = null;
+// ==========================================
+// GLOBALE STATUS VARIABLEN
+// ==========================================
-let rng = null;
-let score = 0;
-let currentTick = 0;
-let lastSentTick = 0;
-let inputLog = [];
-let isCrouching = false;
+// --- 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
-// Powerups Client State
+// --- NETZWERK & STREAMING (NEU) ---
+let socket = null; // Die WebSocket Verbindung
+let obstacleBuffer = []; // Warteschlange für kommende Hindernisse
+let platformBuffer = []; // Warteschlange für kommende Plattformen
+
+// --- 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 currentBgIndex = 0;
-let maxRawBgIndex = 0;
+// --- HINTERGRUND ---
+let maxRawBgIndex = 0; // Welcher Hintergrund wird gezeigt?
-// Tick Time
+// --- GAME LOOP TIMING ---
let lastTime = 0;
let accumulator = 0;
-let lastPowerupTick = -9999;
-let nextSpawnTick = 0;
-// Grafiken
-let sprites = {};
+// --- GRAFIKEN ---
+let sprites = {}; // Cache für Hindernis-Bilder
let playerSprite = new Image();
-let bgSprite = new Image();
-let bgSprites = [];
-// Spiel-Objekte
+let bgSprites = []; // Array der Hintergrund-Bilder
+
+// --- ENTITIES (Render-Listen) ---
let player = {
- x: 50, y: 300, w: 30, h: 50, color: "red",
- vy: 0, grounded: false
+ x: 50,
+ y: 300,
+ w: 30,
+ h: 50,
+ color: "red",
+ vy: 0,
+ grounded: false,
+ prevY: 300
};
+let particles = [];
+
+// Diese Listen werden von logic.js aus dem Buffer gefüllt und von render.js gezeichnet
let obstacles = [];
-let serverObstacles = [];
+let platforms = [];
-// HTML Elemente (Caching)
+// 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
+
+// --- 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');
-const gameOverScreen = document.getElementById('gameOverScreen');
\ No newline at end of file
+const gameOverScreen = document.getElementById('gameOverScreen');
+const scoreDisplay = document.getElementById('score');
+const highscoreDisplay = document.getElementById('localHighscore');
\ No newline at end of file
diff --git a/static/style.css b/static/style.css
index 3dd2515..ead5f01 100644
--- a/static/style.css
+++ b/static/style.css
@@ -362,4 +362,32 @@ input {
@media screen and (orientation: portrait) {
#rotate-overlay { display: flex; }
#game-container { display: none !important; }
+}
+
+/* ... bestehende Styles ... */
+
+#mute-btn {
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ z-index: 100; /* Über allem */
+ background: rgba(0, 0, 0, 0.5);
+ border: 2px solid #555;
+ color: white;
+ font-size: 20px;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ cursor: pointer;
+ padding: 0;
+ margin: 0; /* Override default button margin */
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 0 10px rgba(0,0,0,0.5);
+}
+
+#mute-btn:hover {
+ background: rgba(255, 255, 255, 0.2);
+ border-color: white;
}
\ No newline at end of file
diff --git a/types.go b/types.go
index 233c878..bfdc1b5 100644
--- a/types.go
+++ b/types.go
@@ -10,14 +10,46 @@ type ObstacleDef struct {
CanTalk bool `json:"canTalk"`
SpeechLines []string `json:"speechLines"`
YOffset float64 `json:"yOffset"`
+ ImgScale float64 `json:"imgScale"`
+ ImgOffsetX float64 `json:"imgOffsetX"`
+ ImgOffsetY float64 `json:"imgOffsetY"`
+}
+
+type ChunkObstacle struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Width float64 `json:"w"`
+ Height float64 `json:"h"`
+ Color string `json:"color"`
+ ImgScale float64 `json:"imgScale"`
+ ImgOffsetX float64 `json:"imgOffsetX"`
+ ImgOffsetY float64 `json:"imgOffsetY"`
+}
+
+type PlatformDef struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Width float64 `json:"w"`
+ Height float64 `json:"h"`
+ Type string `json:"type"`
+}
+
+type ChunkDef struct {
+ ID string `json:"id"`
+ Platforms []PlatformDef `json:"platforms"`
+ Obstacles []ChunkObstacle `json:"obstacles"`
+ TotalWidth int `json:"totalWidth"`
}
type GameConfig struct {
Obstacles []ObstacleDef `json:"obstacles"`
Backgrounds []string `json:"backgrounds"`
+ Chunks []ChunkDef `json:"chunks"`
}
-// Dynamischer State
+// Dynamischer State (Simulation)
type ActiveObstacle struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -27,6 +59,13 @@ type ActiveObstacle struct {
Height float64 `json:"h"`
}
+type ActivePlatform struct {
+ X float64 `json:"x"`
+ Y float64 `json:"y"`
+ Width float64 `json:"w"`
+ Height float64 `json:"h"`
+}
+
// API Requests/Responses
type Input struct {
Tick int `json:"t"`
@@ -49,9 +88,11 @@ type ValidateResponse struct {
Status string `json:"status"`
VerifiedScore int `json:"verifiedScore"`
ServerObs []ActiveObstacle `json:"serverObs"`
+ ServerPlats []ActivePlatform `json:"serverPlats"`
PowerUps PowerUpState `json:"powerups"`
ServerTick int `json:"serverTick"`
NextSpawnTick int `json:"nextSpawnTick"`
+ RngState uint32 `json:"rngState"`
}
type StartResponse struct {
diff --git a/websocket.go b/websocket.go
new file mode 100644
index 0000000..a422e07
--- /dev/null
+++ b/websocket.go
@@ -0,0 +1,308 @@
+package main
+
+import (
+ "log"
+ "math"
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+const (
+ ServerTickRate = 50 * time.Millisecond
+
+ BufferAhead = 60 // Puffergröße (Zukunft)
+ SpawnXStart = 2000.0 // Spawn Abstand
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+// Protokoll
+type WSInputMsg struct {
+ Type string `json:"type"`
+ Input string `json:"input"`
+ Tick int `json:"tick"` // Optional: Client Timestamp für Ping
+ PosY float64 `json:"y"`
+ VelY float64 `json:"vy"`
+}
+
+type WSServerMsg struct {
+ Type string `json:"type"`
+ Obstacles []ActiveObstacle `json:"obstacles"`
+ Platforms []ActivePlatform `json:"platforms"`
+ ServerTick int `json:"serverTick"`
+ Score int `json:"score"`
+ PowerUps PowerUpState `json:"powerups"`
+ SessionID string `json:"sessionId"`
+ Ts int `json:"ts,omitempty"` // Für Pong
+ CurrentSpeed float64 `json:"currentSpeed"`
+}
+
+func handleWebSocket(w http.ResponseWriter, r *http.Request) {
+ conn, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ return
+ }
+ defer conn.Close()
+
+ // 1. Session Init
+ sessionID := "ws-" + time.Now().Format("150405999")
+
+ err = rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
+ "score": 0, // Startwert
+ "is_dead": 0,
+ "created_at": time.Now().Format("02.01.2006 15:04"),
+ }).Err()
+
+ if err != nil {
+ log.Println("Redis Init Fehler:", err)
+ return
+ }
+
+ rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour)
+
+ // Session ID senden
+ conn.WriteJSON(WSServerMsg{Type: "init", SessionID: sessionID})
+
+ state := SimState{
+ SessionID: sessionID,
+ RNG: NewRNG(time.Now().UnixNano()),
+ Score: 0,
+ Ticks: 0,
+ PosY: PlayerYBase,
+ NextSpawnTick: 0,
+ }
+
+ // Channel größer machen, damit bei Lag nichts blockiert
+ inputChan := make(chan WSInputMsg, 100)
+ closeChan := make(chan struct{})
+
+ // LESE-ROUTINE
+ go func() {
+ defer close(closeChan)
+ for {
+ var msg WSInputMsg
+ if err := conn.ReadJSON(&msg); err != nil {
+ return
+ }
+ inputChan <- msg
+ }
+ }()
+
+ // GAME LOOP (High Performance)
+ ticker := time.NewTicker(ServerTickRate)
+ defer ticker.Stop()
+
+ // Input State
+ var pendingJump bool
+ var isCrouching bool
+ generatedHeadTick := 0
+
+ for {
+ select {
+ 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 {
+ case msg := <-inputChan:
+ if msg.Type == "input" {
+ if msg.Input == "JUMP" {
+ pendingJump = true
+ }
+ if msg.Input == "DUCK_START" {
+ isCrouching = true
+ }
+ if msg.Input == "DUCK_END" {
+ isCrouching = false
+ }
+ if msg.Input == "DEATH" {
+ state.IsDead = true
+ }
+
+ }
+ if msg.Type == "ping" {
+ // Sofort Pong zurück (Performance wichtig!)
+ conn.WriteJSON(WSServerMsg{Type: "pong", Ts: msg.Tick})
+ }
+
+ if msg.Type == "sync" {
+
+ diff := math.Abs(state.PosY - msg.PosY)
+
+ if diff < 100.0 {
+ state.PosY = msg.PosY
+ state.VelY = msg.VelY
+ }
+ }
+
+ if msg.Type == "debug" {
+ spd := calculateSpeed(state.Ticks)
+ conn.WriteJSON(WSServerMsg{
+ Type: "debug_sync",
+ ServerTick: state.Ticks,
+ Obstacles: state.Obstacles,
+ Platforms: state.Platforms,
+ Score: state.Score,
+ CurrentSpeed: spd,
+ })
+ 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
+
+ currentSpeed := calculateSpeed(state.Ticks)
+
+ updatePhysics(&state, pendingJump, isCrouching, currentSpeed)
+ pendingJump = false // Jump Trigger reset
+
+ checkCollisions(&state, isCrouching, currentSpeed)
+
+ if state.IsDead {
+ // Score Persistieren
+ rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{
+ "score": state.Score,
+ "is_dead": 1,
+ })
+ rdb.Expire(ctx, "session:"+sessionID, 24*time.Hour)
+
+ conn.WriteJSON(WSServerMsg{Type: "dead", Score: state.Score})
+ return
+ }
+
+ 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++
+ loops++
+ o, p := generateFutureObjects(&state, generatedHeadTick, currentSpeed)
+ if len(o) > 0 {
+ newObs = append(newObs, o...)
+ state.Obstacles = append(state.Obstacles, o...)
+ }
+ if len(p) > 0 {
+ newPlats = append(newPlats, p...)
+ state.Platforms = append(state.Platforms, p...)
+ }
+ }
+
+ // 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",
+ ServerTick: state.Ticks,
+ Score: state.Score,
+ Obstacles: newObs,
+ Platforms: newPlats,
+ PowerUps: PowerUpState{
+ GodLives: state.GodLives, HasBat: state.HasBat, BootTicks: state.BootTicks,
+ },
+ }
+ conn.WriteJSON(msg)
+ }
+ }
+ }
+}
+
+// ... (generateFutureObjects bleibt gleich wie vorher) ...
+func generateFutureObjects(s *SimState, tick int, speed float64) ([]ActiveObstacle, []ActivePlatform) {
+ var createdObs []ActiveObstacle
+ var createdPlats []ActivePlatform
+
+ if s.NextSpawnTick == 0 {
+ s.NextSpawnTick = tick + 50
+ }
+
+ 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})
+ }
+
+ width := chunk.TotalWidth
+ if width == 0 {
+ width = 2000
+ }
+ s.NextSpawnTick = tick + int(float64(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" {
+ pool = append(pool, d)
+ }
+ } else {
+ if d.ID != "principal" {
+ pool = append(pool, d)
+ }
+ }
+ }
+
+ 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 {
+ createdObs = append(createdObs, ActiveObstacle{
+ ID: def.ID, Type: def.Type, X: spawnX, Y: GroundY - def.Height - def.YOffset, Width: def.Width, Height: def.Height,
+ })
+ }
+ }
+ }
+ }
+ }
+ return createdObs, createdPlats
+}