diff --git a/config.go b/config.go index 8a87528..221fee7 100644 --- a/config.go +++ b/config.go @@ -9,13 +9,14 @@ import ( ) const ( - Gravity = 0.6 - JumpPower = -12.0 - GroundY = 350.0 - PlayerHeight = 50.0 - PlayerYBase = GroundY - PlayerHeight - GameSpeed = 5.0 - GameWidth = 800.0 + Gravity = 0.6 + JumpPower = -12.0 + HighJumpPower = -16.0 + GroundY = 350.0 + PlayerHeight = 50.0 + PlayerYBase = GroundY - PlayerHeight + BaseSpeed = 5.0 + GameWidth = 800.0 ) // Globale Variablen @@ -37,12 +38,25 @@ func getEnv(key, fallback string) string { func initGameConfig() { defaultConfig = GameConfig{ Obstacles: []ObstacleDef{ - {ID: "desk", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"}, - {ID: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!", "Nachsitzen!"}}, - {ID: "trashcan", Width: 25, Height: 35, Color: "#555", Image: "trash.png"}, - {ID: "eraser", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0}, + // --- HINDERNISSE --- + {ID: "desk", Type: "obstacle", Width: 40, Height: 30, Color: "#8B4513", Image: "desk.png"}, + {ID: "teacher", Type: "teacher", Width: 30, Height: 60, Color: "#000080", Image: "teacher.png", CanTalk: true, SpeechLines: []string{"Halt!", "Handy weg!"}}, + {ID: "trashcan", Type: "obstacle", Width: 25, Height: 35, Color: "#555", Image: "trash.png"}, + {ID: "eraser", Type: "obstacle", Width: 30, Height: 20, Color: "#fff", Image: "eraser.png", YOffset: 45.0}, + + // --- BOSS OBJEKTE (Kommen häufiger im Bosskampf) --- + {ID: "principal", Type: "teacher", Width: 40, Height: 70, Color: "#000", Image: "principal.png", CanTalk: true, SpeechLines: []string{"EXMATRIKULATION!"}}, + + // --- COINS --- + {ID: "coin", Type: "coin", Width: 20, Height: 20, Color: "gold", Image: "coin.png", YOffset: 20.0}, + + // --- POWERUPS --- + {ID: "p_god", Type: "powerup", Width: 30, Height: 30, Color: "cyan", Image: "powerup_god.png", YOffset: 20.0}, // Godmode + {ID: "p_bat", Type: "powerup", Width: 30, Height: 30, Color: "red", Image: "powerup_bat.png", YOffset: 20.0}, // Schläger + {ID: "p_boot", Type: "powerup", Width: 30, Height: 30, Color: "lime", Image: "powerup_boot.png", YOffset: 20.0}, // Boots }, - Backgrounds: []string{"background.jpg"}, + // Mehrere Hintergründe für Level-Wechsel + Backgrounds: []string{"gym-background.jpg", "school-background.jpg", "school2-background.jpg"}, } - log.Println("✅ Config geladen") + log.Println("✅ Config mit Powerups geladen") } diff --git a/simulation.go b/simulation.go index 637e829..ebc0ab1 100644 --- a/simulation.go +++ b/simulation.go @@ -3,23 +3,18 @@ package main import ( "encoding/json" "fmt" - "log" - "math" "strconv" ) -// Führt die Physik-Simulation durch und prüft auf Cheats func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[string]string) (bool, int, []ActiveObstacle) { - - // State parsen posY := parseOr(vals["pos_y"], PlayerYBase) velY := parseOr(vals["vel_y"], 0.0) score := int(parseOr(vals["score"], 0)) rngStateVal, _ := strconv.ParseInt(vals["rng_state"], 10, 64) - // Anti-Cheat State laden - lastJumpDist := parseOr(vals["ac_last_dist"], 0.0) - suspicionScore := int(parseOr(vals["ac_suspicion"], 0)) + godLives := int(parseOr(vals["p_god_lives"], 0)) + hasBat := vals["p_has_bat"] == "1" + bootTicks := int(parseOr(vals["p_boot_ticks"], 0)) rng := NewRNG(rngStateVal) @@ -30,24 +25,20 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st obstacles = []ActiveObstacle{} } - // --- ANTI-CHEAT STUFE 2: SPAM SCHUTZ --- - jumpCount := 0 - for _, inp := range inputs { - if inp.Act == "JUMP" { - jumpCount++ - } - } - if jumpCount > 8 { // Wer mehr als 8x pro Sekunde springt, ist ein Bot - log.Printf("🤖 BOT ALARM (Spam): %s sprang %d mal!", sessionID, jumpCount) - return true, score, obstacles // Player Dead - } - playerDead := false - // --- SIMULATION LOOP --- for i := 0; i < totalTicks; i++ { + currentSpeed := BaseSpeed + (float64(score)/500.0)*0.5 + if currentSpeed > 12.0 { + currentSpeed = 12.0 + } + + currentJumpPower := JumpPower + if bootTicks > 0 { + currentJumpPower = HighJumpPower + bootTicks-- + } - // A. INPUT didJump := false isCrouching := false for _, inp := range inputs { @@ -61,39 +52,7 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } } - // Physik Check isGrounded := posY >= PlayerYBase-1.0 - - if didJump && isGrounded && !isCrouching { - velY = JumpPower - - // --- ANTI-CHEAT STUFE 3: HEURISTIK (Perfektes Springen) --- - // Wir messen den Abstand zum nächsten Hindernis beim Absprung - nextObsDist := -1.0 - for _, o := range obstacles { - if o.X > 50.0 { // Erstes Hindernis vor uns - nextObsDist = o.X - 50.0 - break - } - } - - if nextObsDist > 0 { - // Bot-Check: Springt er immer exakt bei "75.5" Pixel Abstand? - diff := math.Abs(nextObsDist - lastJumpDist) - if diff < 1.0 { - // Abstand ist fast identisch zum letzten Sprung -> Verdächtig - suspicionScore++ - } else { - // Menschliche Varianz -> Reset (oder verringern) - if suspicionScore > 0 { - suspicionScore-- - } - } - lastJumpDist = nextObsDist - } - } - - // ... (Restliche Physik wie gehabt) ... currentHeight := PlayerHeight if isCrouching { currentHeight = PlayerHeight / 2 @@ -102,9 +61,12 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } } + if didJump && isGrounded && !isCrouching { + velY = currentJumpPower + } + velY += Gravity posY += velY - if posY > PlayerYBase { posY = PlayerYBase velY = 0 @@ -115,62 +77,92 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st hitboxY = posY + (PlayerHeight - currentHeight) } - // B. OBSTACLES nextObstacles := []ActiveObstacle{} rightmostX := 0.0 for _, obs := range obstacles { - obs.X -= GameSpeed + obs.X -= currentSpeed - if obs.X+obs.Width < 50.0 { + if obs.X+obs.Width < -50.0 { continue } - // Hitbox - paddingX := 5.0 - paddingY_Top := 5.0 - paddingY_Bottom := 5.0 - - pLeft, pRight := 50.0+paddingX, 50.0+30.0-paddingX - pTop, pBottom := hitboxY+paddingY_Top, hitboxY+currentHeight-paddingY_Bottom - - oLeft, oRight := obs.X+paddingX, obs.X+obs.Width-paddingX - oTop, oBottom := obs.Y+paddingY_Top, obs.Y+obs.Height-paddingY_Bottom - - if pRight > oLeft && pLeft < oRight && pBottom > oTop && pTop < oBottom { - playerDead = true + paddingX := 10.0 + paddingY_Top := 10.0 + if obs.Type == "teacher" { + paddingY_Top = 25.0 } - if obs.X+obs.Width > -100 { - nextObstacles = append(nextObstacles, obs) - if obs.X+obs.Width > rightmostX { - rightmostX = obs.X + obs.Width + 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 + continue + } + if godLives > 0 { + godLives-- + continue + } + playerDead = true } } + + nextObstacles = append(nextObstacles, obs) + if obs.X+obs.Width > rightmostX { + rightmostX = obs.X + obs.Width + } } obstacles = nextObstacles - // C. SPAWNING if rightmostX < GameWidth-10.0 { - rawGap := 400.0 + rng.NextRange(0, 500) - gap := float64(int(rawGap)) + gap := float64(int(400.0 + rng.NextRange(0, 500))) spawnX := rightmostX + gap if spawnX < GameWidth { spawnX = GameWidth } + isBossPhase := (score % 1500) > 1200 var possibleDefs []ObstacleDef + for _, d := range defaultConfig.Obstacles { - if d.ID == "eraser" { - if score >= 500 { + if isBossPhase { + if d.ID == "principal" || d.ID == "trashcan" { possibleDefs = append(possibleDefs, d) } } else { + if d.ID == "principal" { + continue + } + if d.ID == "eraser" && score < 500 { + continue + } possibleDefs = append(possibleDefs, d) } } def := rng.PickDef(possibleDefs) + if def != nil && def.CanTalk { if rng.NextFloat() > 0.7 { rng.NextFloat() @@ -178,14 +170,21 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } if def != nil { - spawnY := GroundY - def.Height - def.YOffset - obstacles = append(obstacles, ActiveObstacle{ - ID: def.ID, - X: spawnX, - Y: spawnY, - Width: def.Width, - Height: def.Height, - }) + 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, + }) + } } } @@ -196,23 +195,21 @@ func simulateChunk(sessionID string, inputs []Input, totalTicks int, vals map[st } } - // Ban Hammer für Bots - if suspicionScore > 8 { - log.Printf("🤖 BOT ALARM (Heuristik): %s springt zu perfekt!", sessionID) - playerDead = true + obsJson, _ := json.Marshal(obstacles) + batStr := "0" + if hasBat { + batStr = "1" } - // State speichern - obsJson, _ := json.Marshal(obstacles) rdb.HSet(ctx, "session:"+sessionID, map[string]interface{}{ - "score": score, - "pos_y": fmt.Sprintf("%f", posY), - "vel_y": fmt.Sprintf("%f", velY), - "rng_state": rng.State, - "obstacles": string(obsJson), - // Anti-Cheat Daten mitspeichern - "ac_last_dist": fmt.Sprintf("%f", lastJumpDist), - "ac_suspicion": suspicionScore, + "score": score, + "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, }) return playerDead, score, obstacles diff --git a/static/assets/gym-background.jpg b/static/assets/gym-background.jpg new file mode 100644 index 0000000..e4dd3ab Binary files /dev/null and b/static/assets/gym-background.jpg differ diff --git a/static/assets/background.jpg b/static/assets/school-background.jpg similarity index 100% rename from static/assets/background.jpg rename to static/assets/school-background.jpg diff --git a/static/assets/school2-background.jpg b/static/assets/school2-background.jpg new file mode 100644 index 0000000..dc6a800 Binary files /dev/null and b/static/assets/school2-background.jpg differ diff --git a/static/js/config.js b/static/js/config.js index f82f1a3..89597f9 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -3,8 +3,9 @@ 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 GAME_SPEED = 5; +const BASE_SPEED = 5; const CHUNK_SIZE = 60; const TARGET_FPS = 60; const MS_PER_TICK = 1000 / TARGET_FPS; diff --git a/static/js/logic.js b/static/js/logic.js index 1f7a1c3..b8a6c8e 100644 --- a/static/js/logic.js +++ b/static/js/logic.js @@ -1,12 +1,23 @@ function updateGameLogic() { - if (isCrouching) { - inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" }); - } + // 1. Speed Berechnung (Sync mit Server!) + let currentSpeed = BASE_SPEED + (score / 500.0) * 0.5; + if (currentSpeed > 12.0) currentSpeed = 12.0; + + // 2. Input & Sprung + if (isCrouching) inputLog.push({ t: currentTick - lastSentTick, act: "DUCK" }); const originalHeight = 50; const crouchHeight = 25; player.h = isCrouching ? crouchHeight : originalHeight; let drawY = isCrouching ? player.y + (originalHeight - crouchHeight) : player.y; + // Jump Power Check + let jumpP = JUMP_POWER; + if (bootTicks > 0) { + jumpP = HIGH_JUMP_POWER; + bootTicks--; + } + + // Physik player.vy += GRAVITY; if (isCrouching && !player.grounded) player.vy += 2.0; player.y += player.vy; @@ -14,16 +25,45 @@ function updateGameLogic() { if (player.y + originalHeight >= GROUND_Y) { player.y = GROUND_Y - originalHeight; player.vy = 0; player.grounded = true; } else { player.grounded = false; } + + + // 3. Obstacles + let nextObstacles = []; + let rightmostX = 0; - let nextObstacles = []; let rightmostX = 0; for (let obs of obstacles) { - obs.x -= GAME_SPEED; + obs.x -= currentSpeed; + const playerHitbox = { x: player.x, y: drawY, w: player.w, h: player.h }; if (checkCollision(playerHitbox, obs)) { - player.color = "darkred"; - if (!isGameOver) { sendChunk(); gameOver("Kollision"); } + // TYPE CHECK + if (obs.def.type === "coin") { + score += 2000; + continue; + } + 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; + continue; + } + else { + // HINDERNIS + if (hasBat && obs.def.type === "teacher") { + hasBat = false; + continue; // Zerstört! + } + if (godModeLives > 0) { + godModeLives--; + continue; // Überlebt! + } + + player.color = "darkred"; + if (!isGameOver) { sendChunk(); gameOver("Kollision"); } + } } + if (obs.x + obs.def.width > -100) { nextObstacles.push(obs); if (obs.x + obs.def.width > rightmostX) rightmostX = obs.x + obs.def.width; @@ -31,23 +71,43 @@ function updateGameLogic() { } obstacles = nextObstacles; - // Spawning + // 4. Spawning (Sync mit Go!) if (rightmostX < GAME_WIDTH - 10 && gameConfig) { const gap = Math.floor(400 + rng.nextRange(0, 500)); let spawnX = rightmostX + gap; if (spawnX < GAME_WIDTH) spawnX = GAME_WIDTH; + const isBossPhase = (score % 1500) > 1200; + let possibleObs = []; gameConfig.obstacles.forEach(def => { - if (def.id === "eraser") { if (score >= 500) possibleObs.push(def); } else possibleObs.push(def); + if (isBossPhase) { + if (def.id === "principal" || def.id === "trashcan") possibleObs.push(def); + } else { + if (def.id === "principal") return; + if (def.id === "eraser" && score < 500) return; + possibleObs.push(def); + } }); - const def = rng.pick(possibleObs); + let def = rng.pick(possibleObs); + + // Speech Sync let speech = null; - if (def && def.canTalk) { if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); } + if (def && def.canTalk) { + if (rng.nextFloat() > 0.7) speech = rng.pick(def.speechLines); + } + + // Powerup Rarity Sync (Muss exakt wie Go sein: 10% Chance) + if (def && def.type === "powerup") { + if (rng.nextFloat() > 0.1) def = null; + } if (def) { const yOffset = def.yOffset || 0; - obstacles.push({ x: spawnX, y: GROUND_Y - def.height - yOffset, def: def, speech: speech }); + obstacles.push({ + x: spawnX, y: GROUND_Y - def.height - yOffset, + def: def, speech: speech + }); } } } diff --git a/static/js/render.js b/static/js/render.js index d6985a6..860c1aa 100644 --- a/static/js/render.js +++ b/static/js/render.js @@ -44,46 +44,53 @@ resize(); // --- DRAWING --- function drawGame() { - // Alles löschen ctx.clearRect(0, 0, GAME_WIDTH, GAME_HEIGHT); - // Background + // --- BACKGROUND --- + // Hier war der Check schon drin, das ist gut if (bgSprite.complete && bgSprite.naturalHeight !== 0) { - // Hintergrundbild exakt auf 800x400 skalieren ctx.drawImage(bgSprite, 0, 0, GAME_WIDTH, GAME_HEIGHT); } else { - // Fallback Farbe ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); } - // Boden ctx.fillStyle = "rgba(60, 60, 60, 0.8)"; ctx.fillRect(0, GROUND_Y, GAME_WIDTH, 50); - // Hindernisse + // --- HINDERNISSE (HIER WAR DER FEHLER) --- obstacles.forEach(obs => { const img = sprites[obs.def.id]; - if (img) { + + // FIX: Wir prüfen jetzt strikt, ob das Bild wirklich bereit ist + if (img && img.complete && img.naturalHeight !== 0) { ctx.drawImage(img, obs.x, obs.y, obs.def.width, obs.def.height); } else { - ctx.fillStyle = obs.def.color; + // Fallback: Wenn Bild fehlt/kaputt -> Farbiges Rechteck + // Wir prüfen auf Typ Coin, damit Coins gold sind, auch wenn Bild fehlt + if (obs.def.type === "coin") ctx.fillStyle = "gold"; + else ctx.fillStyle = obs.def.color; + ctx.fillRect(obs.x, obs.y, obs.def.width, obs.def.height); } + if(obs.speech) drawSpeechBubble(obs.x, obs.y, obs.speech); }); - - // Debug Rahmen (Server Hitboxen) + + /* + // --- DEBUG --- ctx.strokeStyle = isGameOver ? "red" : "lime"; ctx.lineWidth = 2; serverObstacles.forEach(srvObs => { ctx.strokeRect(srvObs.x, srvObs.y, srvObs.w, srvObs.h); }); + */ - // Spieler + // --- PLAYER --- const drawY = isCrouching ? player.y + 25 : player.y; const drawH = isCrouching ? 25 : 50; + // Hier war der Check auch schon korrekt if (playerSprite.complete && playerSprite.naturalHeight !== 0) { ctx.drawImage(playerSprite, player.x, drawY, player.w, drawH); } else { @@ -91,13 +98,31 @@ function drawGame() { ctx.fillRect(player.x, drawY, player.w, drawH); } - // Game Over Overlay (Dunkelheit) + // --- POWERUP UI (Oben Links) --- + if (isGameRunning && !isGameOver) { + ctx.fillStyle = "black"; + ctx.font = "bold 10px monospace"; + ctx.textAlign = "left"; + let statusText = ""; + if(godModeLives > 0) statusText += `🛡️ x${godModeLives} `; + if(hasBat) statusText += `⚾ BAT `; + if(bootTicks > 0) statusText += `👟 ${(bootTicks/60).toFixed(1)}s`; + + // Drift Anzeige + if (obstacles.length > 0 && serverObstacles.length > 0) { + const drift = Math.abs(obstacles[0].x - serverObstacles[0].x).toFixed(1); + statusText += ` | Drift: ${drift}px`; + } + ctx.fillText(statusText, 10, 40); + } + if (isGameOver) { ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.fillRect(0,0,GAME_WIDTH, GAME_HEIGHT); } } +// Sprechblasen Helper 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); diff --git a/static/js/state.js b/static/js/state.js index 373c95f..4326cbd 100644 --- a/static/js/state.js +++ b/static/js/state.js @@ -12,6 +12,14 @@ let lastSentTick = 0; let inputLog = []; let isCrouching = false; +// Powerups Client State +let godModeLives = 0; +let hasBat = false; +let bootTicks = 0; + +// Hintergrund +let currentBgIndex = 0; + // Tick Time let lastTime = 0; let accumulator = 0; diff --git a/types.go b/types.go index 1c22b24..64ee69c 100644 --- a/types.go +++ b/types.go @@ -2,6 +2,7 @@ package main type ObstacleDef struct { ID string `json:"id"` + Type string `json:"type"` Width float64 `json:"width"` Height float64 `json:"height"` Color string `json:"color"` @@ -19,6 +20,7 @@ type GameConfig struct { // Dynamischer State type ActiveObstacle struct { ID string `json:"id"` + Type string `json:"type"` X float64 `json:"x"` Y float64 `json:"y"` Width float64 `json:"w"` @@ -41,6 +43,7 @@ type ValidateResponse struct { Status string `json:"status"` VerifiedScore int `json:"verifiedScore"` ServerObs []ActiveObstacle `json:"serverObs"` + ActivePowerup string `json:"activePowerup"` } type StartResponse struct {