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
+
+
+
+ +
+
+ + +
+ +
+
🔴 Hitbox (Physik)
+
+
+
+
+ + + + +
+ + +
+
+ +
+
+ 🖼️ 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 +}