diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e1c8d6a --- /dev/null +++ b/LICENSE @@ -0,0 +1,15 @@ +ISC License + +Copyright (c) 2026 + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index f197435..74d2e7f 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,23 @@ # CubDen Game - Polar Bear Endless Runner -A browser-based 2D Phaser 3 endless runner game featuring a cybernetic polar bear running across frozen Arctic ice while avoiding holes. +A browser-based Phaser 3 endless runner where a cybernetic polar bear sprints across unstable Arctic ice, dodges sliding penguins, and survives accelerating obstacle waves. ## Game Features - **Endless Runner Mechanics**: Polar bear auto-runs forward -- **Jump Controls**: SPACE or UP ARROW to jump over ice holes -- **Pause**: **ESC** to pause / resume (pause overlay) +- **Desktop and Touch Controls**: keyboard and on-screen controls +- **Pause Support**: ESC or on-screen pause button - **Animated Sprite**: 10-frame walking animation -- **Procedural Obstacles**: Ice holes spawn randomly with increasing difficulty -- **Score & Distance Tracking**: Real-time HUD display -- **Arctic Theme**: Snow particles, clouds, mountains, and ice ground +- **Procedural Obstacles**: ice holes and sliding penguins +- **Progression Layer**: score, distance, milestones, best-run tracking, and difficulty tiers +- **Arctic Presentation**: snow particles, parallax clouds, mountains, and drifting ice details ## Commands ```bash pnpm install pnpm dev # Vite dev server +pnpm verify # Security scan + production build pnpm build # Production build pnpm preview # Preview production build ``` @@ -25,17 +26,19 @@ pnpm preview # Preview production build 1. Run `pnpm dev` to start the development server 2. Open `http://localhost:5173/` in your browser -3. Use **SPACE** or **UP ARROW** to jump over ice holes -4. Press **ESC** to pause; press **ESC** again to resume -5. Avoid falling into the dark holes in the ice -6. Press **R** to restart after game over +3. Use **SPACE**, **W**, or **UP ARROW** to jump on desktop +4. On mobile or narrow screens, use the on-screen **Jump** and **Pause** buttons +5. Press **ESC** to pause or resume on desktop +6. Avoid ice holes and penguins while speed increases over time +7. Press **ENTER** or **R** to restart after game over ## Project Structure - `index.html` - Main HTML file - `src/polar-bear-main.js` - Game entry point - `src/scenes/PolarBearScene.js` - Main game scene -- `src/constants/polar-bear.js` - Game constants +- `src/ui/TouchControls.js` - Mobile control bindings +- `scripts/security-scan.mjs` - Lightweight repository scan for suspicious code patterns - `assets/polarbear-game/` - Game assets (sprites, etc.) ## Tech Stack @@ -43,3 +46,8 @@ pnpm preview # Preview production build - Phaser 3.80+ - Vite 5 - ES Modules + +## Release Notes + +- Current package version: `0.1.0` +- Public repo hygiene includes a pre-commit security scan, CI verification workflow, and documented security handling guidance diff --git a/SECURITY.md b/SECURITY.md index 8a51e88..c182ad6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,11 +1,18 @@ -# Security Notes +# Security Policy -## Immediate Remediation +## Reporting -- Remove any browser-incompatible or obfuscated payloads from tracked source files. -- Treat unexpected `createRequire`, `global[...]`, encoded payloads, and hand-obfuscated functions as a stop-ship event. +- If you find a security issue, report it privately to the maintainers instead of opening a public issue. +- Include a short description, affected files or features, reproduction steps, and impact. -## Local Controls +## Scope + +- Unexpected obfuscated code in tracked source files +- Browser game code importing Node-only modules +- Supply-chain or build-script changes that introduce unsafe behavior +- Client-side behaviors that expose users to script injection or malicious redirects + +## Local Verification - Run `pnpm run security:scan` before commit or release. - Run `pnpm run verify` before pushing. @@ -17,8 +24,8 @@ - Reject browser game code that imports Node-only modules. - Review unusual changes to build config, package scripts, and scene files with extra scrutiny. -## Recovery Guidance +## Response Guidance -- If suspicious code is found again, remove it from the working tree first. +- Remove suspicious code from the working tree first. - Identify the introducing commit with `git blame` and `git log`. -- Rewrite local history so the malicious commit is no longer reachable. +- Rewrite history when needed so malicious commits are no longer reachable from active branch refs. diff --git a/changelog.txt b/changelog.txt index 08f0e29..2f0c67c 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,12 @@ CHANGELOG — cubden-game (Polar Bear Endless Runner) +## [0.1.0] — 2026-04-05 +- Added responsive layout and touch controls +- Added best-run tracking, milestones, and named difficulty tiers +- Fixed pause-state timing and animation suspension +- Hardened animation registration, collision tuning, and background motion +- Updated public repo documentation and security policy + ## [0.0.4] — 2026-03-27 - Version bump diff --git a/docs/DEV-TASKS_04-05.md b/docs/DEV-TASKS_04-05.md index 30cd71d..b7ae59f 100644 --- a/docs/DEV-TASKS_04-05.md +++ b/docs/DEV-TASKS_04-05.md @@ -1,28 +1,12 @@ # Development Tasklist (04-05) -This task list is based on direct codebase review and one build verification run. It focuses on clear improvement opportunities rather than forcing fixes where the game is already acceptable. - -## Highest Priority - -### Major Bug Fix - -#### 1. Remove browser-incompatible and obfuscated code from the main scene -- Priority: Critical -- Why it matters: The game does not currently build for production. -- Evidence: - - `src/scenes/PolarBearScene.js` imports `createRequire` from Node's `module` package at lines 8-10. - - The same file has a large block of obfuscated code appended after the class at line 130. - - `pnpm build` fails with: `"createRequire" is not exported by "__vite-browser-external"`. -- Task: - - Remove the Node-specific import and any injected obfuscated payload from the scene file. - - Rebuild and confirm Vite produces a clean production bundle. - - Review the repo for any similar injected code patterns before shipping. +This task list is based on direct codebase review after the security cleanup. It focuses on practical gameplay, usability, and maintainability improvements rather than forcing unnecessary fixes. ## Feature Improvements ### Major Feature Improvement -#### 2. Add responsive layout and mobile-friendly controls +#### 1. ✅ Add responsive layout and mobile-friendly controls - Priority: High - Why it matters: The game is locked to a desktop-sized fixed canvas and keyboard-only input, which limits reach and usability. - Evidence: @@ -36,7 +20,7 @@ This task list is based on direct codebase review and one build verification run ### Minor Feature Improvement -#### 3. Add a simple progression layer: best score, milestones, and difficulty tiers +#### 2. ✅ Add a simple progression layer: best score, milestones, and difficulty tiers - Priority: Medium - Why it matters: The core loop works, but there is little long-term motivation beyond surviving longer. - Evidence: @@ -49,9 +33,23 @@ This task list is based on direct codebase review and one build verification run ## Smaller Fixes +### Major Bug Fix + +#### 3. ✅ Freeze all gameplay timers and obstacle animations during pause +- Priority: High +- Why it matters: Pause should fully suspend game state. If timers or animations continue running, players can lose invincibility time or see inconsistent behavior after resuming. +- Evidence: + - `src/ui/PauseMenu.js:21-46` pauses physics and only pauses the polar bear animation. + - `src/ui/HUD.js:81-87` uses `scene.time.delayedCall(...)` for invincibility, which should not be allowed to expire while paused. + - `src/entities/Penguins.js:36` starts a looping penguin animation that is not explicitly paused. +- Task: + - Pause and resume the scene clock or equivalent timers along with physics. + - Pause active non-player animations when the game is paused. + - Verify pause behavior while invincibility is active. + ### Minor Bug Fixes -#### 4. Prevent animation key re-registration issues on restart +#### 4. ✅ Prevent animation key re-registration issues on restart - Priority: Medium - Why it matters: Restarting the scene can re-run animation creation and cause duplicate-key warnings or unstable behavior. - Evidence: @@ -61,19 +59,7 @@ This task list is based on direct codebase review and one build verification run - Task: - Register animations once, or check whether an animation key already exists before creating it. -#### 5. Freeze all gameplay timers and obstacle animations during pause -- Priority: Medium -- Why it matters: The pause system pauses physics, but not all scene activity is guaranteed to stop. -- Evidence: - - `src/ui/PauseMenu.js:21-46` pauses physics and only pauses the polar bear animation. - - `src/ui/HUD.js:81-87` uses `scene.time.delayedCall(...)` for invincibility, which should not be allowed to expire while paused. - - `src/entities/Penguins.js:36` starts a looping penguin animation that is not explicitly paused. -- Task: - - Pause and resume the scene clock or equivalent timers along with physics. - - Pause active non-player animations when the game is paused. - - Verify pause behavior while invincibility is active. - -#### 6. Tighten collision logic to reduce unfair hits +#### 5. ✅ Tighten collision logic to reduce unfair hits - Priority: Low - Why it matters: Collision checks are currently hand-tuned and may feel inconsistent as art sizes or speeds change. - Evidence: @@ -86,7 +72,7 @@ This task list is based on direct codebase review and one build verification run ## Nice-to-Have Polish -#### 7. Improve endless-runner readability with stronger world motion +#### 6. ✅ Improve endless-runner readability with stronger world motion - Priority: Low - Why it matters: Clouds move, but mountains, ground details, and decorative ice mostly stay static, which weakens the feeling of motion. - Evidence: @@ -98,5 +84,5 @@ This task list is based on direct codebase review and one build verification run ## Notes -- I did not find evidence that the overall game loop is fundamentally broken beyond the confirmed production build failure. -- Because of that, the backlog should prioritize shipping stability first, then usability and replay value. +- I did not find evidence that the overall core loop is critically broken after the recent cleanup. +- The highest-value backlog items now are pause-state correctness, responsiveness, and replay-value improvements. diff --git a/index.html b/index.html index 307e043..f3d87ed 100644 --- a/index.html +++ b/index.html @@ -5,49 +5,201 @@ Polar Bear Game - CubCloud AI -
-
-

Polar Bear Endless Runner

-

🐻‍❄️ The polar bear auto-runs forward across the frozen Arctic ice!

-

SPACE or UP ARROW = Jump over ice holes

-

ESC = Pause / resume

-

Avoid falling into the dark holes in the ice. Difficulty increases as you go further!

-

Press R to restart after game over.

-
+
+
+

Polar Bear Endless Runner

+

Guide the cybernetic polar bear across unstable Arctic ice. Jump the holes, dodge sliding penguins, and keep the run alive as the pace ramps up.

+
+ +
+
+ +
+ + +
diff --git a/package.json b/package.json index 017deff..9186c7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cubden-game", - "version": "0.0.4", + "version": "0.1.0", "description": "Polar Bear Endless Runner Game", "type": "module", "scripts": { diff --git a/src/entities/IceHoles.js b/src/entities/IceHoles.js index 14cc95b..6883612 100644 --- a/src/entities/IceHoles.js +++ b/src/entities/IceHoles.js @@ -1,3 +1,8 @@ +const BEAR_HOLE_RADIUS_FACTOR = 0.25; +const GROUND_CONTACT_TOLERANCE = 25; +const JUMP_CLEARANCE_HEIGHT = 50; +const BASE_HOLE_TRIGGER_RANGE = 72; + export function spawnHole(scene) { const holeX = scene.nextHoleX; const holeWidth = Phaser.Math.Between(60, 90); @@ -103,19 +108,20 @@ export function updateHoles(scene) { // onGameOver callback keeps IceHoles decoupled from HUD export function checkHoleCollision(scene, onGameOver) { if (!scene.polarBear || scene.gameOver || scene.invincible) return; - const bearBodyRadius = (scene.polarBear.displayWidth || 80) * 0.25; + const bearBodyRadius = (scene.polarBear.displayWidth || 80) * BEAR_HOLE_RADIUS_FACTOR; + for (const hole of scene.holes) { - const distX = Math.abs(scene.polarBear.x - hole.x); - const distY = Math.abs(scene.polarBear.y - hole.y); const dist = Phaser.Math.Distance.Between( scene.polarBear.x, scene.polarBear.y, hole.x, hole.y ); - const isOnGround = scene.polarBear.y >= scene.groundY - 25; - const isAboveHole = scene.polarBear.y < scene.groundY - 50; - const isHoleInRange = hole.x >= scene.polarBear.x - 100 && hole.x <= scene.polarBear.x + 100; + const isOnGround = scene.polarBear.y >= scene.groundY - GROUND_CONTACT_TOLERANCE; + const isAboveHole = scene.polarBear.y < scene.groundY - JUMP_CLEARANCE_HEIGHT; + const triggerRange = Math.max(BASE_HOLE_TRIGGER_RANGE, hole.width * 0.65); + const isHoleInRange = Math.abs(scene.polarBear.x - hole.x) <= triggerRange; + if (dist < (hole.radius + bearBodyRadius) && isOnGround && !isAboveHole && isHoleInRange) { onGameOver(); return; diff --git a/src/entities/Penguins.js b/src/entities/Penguins.js index bcf952b..b60cdee 100644 --- a/src/entities/Penguins.js +++ b/src/entities/Penguins.js @@ -1,3 +1,8 @@ +const PENGUIN_BODY_WIDTH = 44; +const PENGUIN_BODY_HEIGHT = 40; +const PENGUIN_BODY_OFFSET_X = 28; +const PENGUIN_BODY_OFFSET_Y = 34; + export function createPenguins(scene) { // tweak: y-position of penguins on the ice surface (relative to groundY) scene.PENGUIN_Y = scene.groundY - 20; // tweak: raise (lower number) or lower (higher number) @@ -8,13 +13,14 @@ export function createPenguins(scene) { scene.penguins = scene.physics.add.group(); - scene.anims.create({ - key: 'penguin_slide', - // tweak: end frame index to match total frames in spritesheet (0-based) - frames: scene.anims.generateFrameNumbers('penguin', { start: 0, end: 7 }), - frameRate: 10, // tweak: animation speed (fps) - repeat: -1 - }); + if (!scene.anims.exists('penguin_slide')) { + scene.anims.create({ + key: 'penguin_slide', + frames: scene.anims.generateFrameNumbers('penguin', { start: 0, end: 7 }), + frameRate: 10, + repeat: -1 + }); + } } export function spawnPenguin(scene) { @@ -27,6 +33,8 @@ export function spawnPenguin(scene) { penguin.setScale(scene.PENGUIN_SCALE); penguin.setDepth(scene.PENGUIN_DEPTH); penguin.body.setAllowGravity(false); // penguins slide on ice, no gravity needed + penguin.body.setSize(PENGUIN_BODY_WIDTH, PENGUIN_BODY_HEIGHT); + penguin.body.setOffset(PENGUIN_BODY_OFFSET_X, PENGUIN_BODY_OFFSET_Y); } penguin.setPosition(spawnX, scene.PENGUIN_Y); @@ -38,14 +46,14 @@ export function spawnPenguin(scene) { export function checkPenguinCollision(scene, onHit) { if (!scene.polarBear || scene.gameOver || scene.invincible) return; - const bearHalfW = (scene.polarBear.displayWidth || 80) * 0.35; - const bearHalfH = (scene.polarBear.displayHeight || 80) * 0.35; + const bearBody = scene.polarBear.body; + for (const penguin of scene.penguins.getChildren()) { if (!penguin.active) continue; - const penguinHalfW = (penguin.displayWidth || 40) * 0.4; - const penguinHalfH = (penguin.displayHeight || 40) * 0.4; - const overlapX = Math.abs(scene.polarBear.x - penguin.x) < bearHalfW + penguinHalfW; - const overlapY = Math.abs(scene.polarBear.y - penguin.y) < bearHalfH + penguinHalfH; + const penguinBody = penguin.body; + const overlapX = bearBody.x < penguinBody.x + penguinBody.width && bearBody.x + bearBody.width > penguinBody.x; + const overlapY = bearBody.y < penguinBody.y + penguinBody.height && bearBody.y + bearBody.height > penguinBody.y; + if (overlapX && overlapY) { onHit(); return; diff --git a/src/entities/PolarBear.js b/src/entities/PolarBear.js index a4d68ae..d920b1d 100644 --- a/src/entities/PolarBear.js +++ b/src/entities/PolarBear.js @@ -1,16 +1,21 @@ export function createPolarBear(scene) { - scene.anims.create({ - key: 'polarBear_walk', - frames: scene.anims.generateFrameNumbers('polarBear', { start: 0, end: 9 }), - frameRate: 10, - repeat: -1 - }); - scene.anims.create({ - key: 'polarBear_idle', - frames: [{ key: 'polarBear', frame: 0 }], - frameRate: 1, - repeat: -1 - }); + if (!scene.anims.exists('polarBear_walk')) { + scene.anims.create({ + key: 'polarBear_walk', + frames: scene.anims.generateFrameNumbers('polarBear', { start: 0, end: 9 }), + frameRate: 10, + repeat: -1 + }); + } + + if (!scene.anims.exists('polarBear_idle')) { + scene.anims.create({ + key: 'polarBear_idle', + frames: [{ key: 'polarBear', frame: 0 }], + frameRate: 1, + repeat: -1 + }); + } scene.polarBear = scene.physics.add.sprite(400, scene.groundY - 20, 'polarBear', 0); scene.polarBear.setScale(0.4); diff --git a/src/polar-bear-main.js b/src/polar-bear-main.js index 4d2cc85..43284b3 100644 --- a/src/polar-bear-main.js +++ b/src/polar-bear-main.js @@ -1,5 +1,6 @@ import Phaser from 'phaser'; import PolarBearScene from './scenes/PolarBearScene.js'; +import { bindTouchControls } from './ui/TouchControls.js'; const config = { type: Phaser.AUTO, @@ -8,6 +9,10 @@ const config = { parent: 'game-container', backgroundColor: '#FFFFFF', pixelArt: true, + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH + }, physics: { default: 'arcade', arcade: { @@ -18,4 +23,6 @@ const config = { scene: [PolarBearScene] }; -new Phaser.Game(config); +const game = new Phaser.Game(config); + +bindTouchControls(game); diff --git a/src/scenes/PolarBearScene.js b/src/scenes/PolarBearScene.js index 513adb2..7d5f023 100644 --- a/src/scenes/PolarBearScene.js +++ b/src/scenes/PolarBearScene.js @@ -1,11 +1,21 @@ import { createPolarBear, handleJump } from '../entities/PolarBear.js'; import { createPenguins, spawnPenguin, updatePenguins, checkPenguinCollision } from '../entities/Penguins.js'; import { spawnHole, updateHoles, checkHoleCollision } from '../entities/IceHoles.js'; -import { createHUD, loseLife } from '../ui/HUD.js'; +import { createHUD, loseLife, saveProgress, showMilestone, syncBestRunStats } from '../ui/HUD.js'; import { createPauseOverlay, togglePause } from '../ui/PauseMenu.js'; +import { controlState, consumePauseToggle } from '../ui/TouchControls.js'; import { createBackgroundElements, updateBackgroundElements, createIceGround } from '../world/Background.js'; import { createSnowParticles, updateSnowParticles } from '../world/SnowParticles.js'; +const DIFFICULTY_TIERS = [ + { minSpeed: 0, label: 'Calm Drift' }, + { minSpeed: 220, label: 'Frozen Run' }, + { minSpeed: 250, label: 'Whiteout Rush' }, + { minSpeed: 285, label: 'Blizzard Sprint' } +]; + +const MILESTONE_THRESHOLDS = [100, 250, 500, 1000, 1500]; + export default class PolarBearScene extends Phaser.Scene { constructor() { super({ key: 'PolarBear' }); @@ -32,6 +42,7 @@ export default class PolarBearScene extends Phaser.Scene { this.distance = 0; this.baseSpeed = 200; this.currentSpeed = this.baseSpeed; + this.difficultyLabel = DIFFICULTY_TIERS[0].label; this.holeSpawnTimer = 0; this.holeSpawnInterval = 2000; this.nextHoleX = 1200; @@ -39,6 +50,23 @@ export default class PolarBearScene extends Phaser.Scene { this.snowParticles = []; this.isJumping = false; this.groundY = 450; + this.storageKey = 'cubden-polar-bear-progress'; + this.bestScore = 0; + this.bestDistance = 0; + this.lastSavedBestScore = 0; + this.lastSavedBestDistance = 0; + this.nextMilestoneIndex = 0; + + try { + const savedProgress = JSON.parse(window.localStorage.getItem(this.storageKey) || '{}'); + this.bestScore = savedProgress.bestScore || 0; + this.bestDistance = savedProgress.bestDistance || 0; + this.lastSavedBestScore = this.bestScore; + this.lastSavedBestDistance = this.bestDistance; + } catch { + this.bestScore = 0; + this.bestDistance = 0; + } const bg = this.add.graphics(); bg.fillStyle(0xffffff, 1); @@ -65,6 +93,10 @@ export default class PolarBearScene extends Phaser.Scene { } update() { + if (consumePauseToggle()) { + togglePause(this); + } + if (this.gameOver || this.isPaused) return; const deltaTime = this.game.loop.delta; @@ -73,7 +105,7 @@ export default class PolarBearScene extends Phaser.Scene { this.distance += scrollSpeed * 0.01; this.score += scrollSpeed * 0.1; - if (this.spaceKey.isDown || this.cursors.up.isDown || this.wasdKeys.W.isDown) { + if (this.spaceKey.isDown || this.cursors.up.isDown || this.wasdKeys.W.isDown || controlState.jumpPressed) { handleJump(this); } @@ -100,12 +132,22 @@ export default class PolarBearScene extends Phaser.Scene { this.currentSpeed = Math.min(this.baseSpeed * 1.5, this.currentSpeed + 2); } + const difficultyTier = + [...DIFFICULTY_TIERS].reverse().find((tier) => this.currentSpeed >= tier.minSpeed) || DIFFICULTY_TIERS[0]; + this.difficultyLabel = difficultyTier.label; + this.penguinSpawnTimer += deltaTime; if (this.penguinSpawnTimer >= this.penguinSpawnInterval) { spawnPenguin(this); this.penguinSpawnTimer = 0; } + const roundedDistance = Math.floor(this.distance); + if (this.nextMilestoneIndex < MILESTONE_THRESHOLDS.length && roundedDistance >= MILESTONE_THRESHOLDS[this.nextMilestoneIndex]) { + showMilestone(this, `${MILESTONE_THRESHOLDS[this.nextMilestoneIndex]}m Milestone`); + this.nextMilestoneIndex += 1; + } + checkHoleCollision(this, () => loseLife(this)); checkPenguinCollision(this, () => loseLife(this)); if (this.gameOver) return; @@ -117,5 +159,13 @@ export default class PolarBearScene extends Phaser.Scene { this.scoreText.setText('Score: ' + Math.floor(this.score)); this.distanceText.setText('Distance: ' + Math.floor(this.distance) + 'm'); + this.difficultyText.setText(`Tier: ${this.difficultyLabel}`); + syncBestRunStats(this); + + if (this.bestScore !== this.lastSavedBestScore || this.bestDistance !== this.lastSavedBestDistance) { + saveProgress(this); + this.lastSavedBestScore = this.bestScore; + this.lastSavedBestDistance = this.bestDistance; + } } } diff --git a/src/ui/HUD.js b/src/ui/HUD.js index 6cc0fc1..bd7ce14 100644 --- a/src/ui/HUD.js +++ b/src/ui/HUD.js @@ -19,6 +19,26 @@ export function createHUD(scene) { }); scene.distanceText.setDepth(1000); + scene.bestText = scene.add.text(16, 74, 'Best: 0 / 0m', { + fontSize: '18px', + color: '#E8F7FF', + fontFamily: 'Arial', + fontStyle: 'bold', + stroke: '#000000', + strokeThickness: 3 + }); + scene.bestText.setDepth(1000); + + scene.difficultyText = scene.add.text(16, 102, 'Tier: Calm Drift', { + fontSize: '18px', + color: '#D6F4FF', + fontFamily: 'Arial', + fontStyle: 'bold', + stroke: '#000000', + strokeThickness: 3 + }); + scene.difficultyText.setDepth(1000); + scene.heartIcons = []; const heartSize = 32; const heartSpacing = 8; @@ -30,7 +50,7 @@ export function createHUD(scene) { scene.heartIcons.push(heart); } - scene.controlsText = scene.add.text(400, 570, 'SPACE / UP = Jump · ESC = Pause', { + scene.controlsText = scene.add.text(400, 570, 'SPACE / UP / TAP = Jump · ESC / PAUSE = Pause', { fontSize: '16px', color: '#FFFFFF', fontFamily: 'Arial', @@ -41,6 +61,18 @@ export function createHUD(scene) { scene.controlsText.setOrigin(0.5); scene.controlsText.setDepth(1000); + scene.milestoneText = scene.add.text(400, 90, '', { + fontSize: '24px', + color: '#FFF7B2', + fontFamily: 'Arial', + fontStyle: 'bold', + stroke: '#16354A', + strokeThickness: 5 + }); + scene.milestoneText.setOrigin(0.5); + scene.milestoneText.setDepth(1001); + scene.milestoneText.setVisible(false); + scene.gameOverText = scene.add.text(400, 250, '', { fontSize: '48px', color: '#FF0000', @@ -87,9 +119,52 @@ export function loseLife(scene) { }); } +export function syncBestRunStats(scene) { + const currentScore = Math.floor(scene.score); + const currentDistance = Math.floor(scene.distance); + + if (currentScore > scene.bestScore) { + scene.bestScore = currentScore; + } + + if (currentDistance > scene.bestDistance) { + scene.bestDistance = currentDistance; + } + + scene.bestText.setText(`Best: ${scene.bestScore} / ${scene.bestDistance}m`); +} + +export function showMilestone(scene, label) { + if (scene.milestoneTimer) { + scene.milestoneTimer.remove(false); + } + + scene.milestoneText.setText(label); + scene.milestoneText.setVisible(true); + scene.milestoneTimer = scene.time.delayedCall(1800, () => { + scene.milestoneText.setVisible(false); + }); +} + +export function saveProgress(scene) { + try { + window.localStorage.setItem( + scene.storageKey, + JSON.stringify({ + bestScore: scene.bestScore, + bestDistance: scene.bestDistance + }) + ); + } catch { + // Ignore storage failures so the game still runs in restrictive environments. + } +} + export function endGame(scene) { scene.gameOver = true; scene.polarBear.setVelocity(0, 0); + syncBestRunStats(scene); + saveProgress(scene); scene.gameOverText.setText('Game Over!'); scene.gameOverText.setVisible(true); scene.restartText.setVisible(true); diff --git a/src/ui/PauseMenu.js b/src/ui/PauseMenu.js index 9503d74..37df2ea 100644 --- a/src/ui/PauseMenu.js +++ b/src/ui/PauseMenu.js @@ -28,6 +28,24 @@ export function createPauseOverlay(scene) { scene.pauseHint.setVisible(false); } +function setPenguinAnimationsPaused(scene, paused) { + if (!scene.penguins) { + return; + } + + scene.penguins.getChildren().forEach((penguin) => { + if (!penguin.active || !penguin.anims?.isPlaying) { + return; + } + + if (paused) { + penguin.anims.pause(); + } else { + penguin.anims.resume(); + } + }); +} + export function togglePause(scene) { if (scene.gameOver) return; if (scene.isPaused) { @@ -40,9 +58,11 @@ export function togglePause(scene) { export function pauseGame(scene) { scene.isPaused = true; scene.physics.pause(); + scene.time.paused = true; if (scene.polarBear?.anims) { scene.polarBear.anims.pause(); } + setPenguinAnimationsPaused(scene, true); scene.pauseOverlay.setVisible(true); scene.pauseTitle.setVisible(true); scene.pauseHint.setVisible(true); @@ -50,10 +70,12 @@ export function pauseGame(scene) { export function resumeGame(scene) { scene.isPaused = false; + scene.time.paused = false; scene.physics.resume(); if (scene.polarBear?.anims) { scene.polarBear.anims.resume(); } + setPenguinAnimationsPaused(scene, false); scene.pauseOverlay.setVisible(false); scene.pauseTitle.setVisible(false); scene.pauseHint.setVisible(false); diff --git a/src/ui/TouchControls.js b/src/ui/TouchControls.js new file mode 100644 index 0000000..3b959fb --- /dev/null +++ b/src/ui/TouchControls.js @@ -0,0 +1,79 @@ +export const controlState = { + jumpPressed: false, + pauseQueued: false +}; + +function setPressed(button, pressed) { + button.classList.toggle('is-pressed', pressed); +} + +function bindHoldButton(button, onChange) { + const start = (event) => { + event.preventDefault(); + onChange(true); + setPressed(button, true); + }; + + const end = (event) => { + event.preventDefault(); + onChange(false); + setPressed(button, false); + }; + + button.addEventListener('pointerdown', start); + button.addEventListener('pointerup', end); + button.addEventListener('pointerleave', end); + button.addEventListener('pointercancel', end); + button.addEventListener('contextmenu', (event) => event.preventDefault()); +} + +export function bindTouchControls(game) { + const controlsRoot = document.getElementById('touch-controls'); + const jumpButton = document.getElementById('jump-button'); + const pauseButton = document.getElementById('pause-button'); + + if (!controlsRoot || !jumpButton || !pauseButton) { + return; + } + + bindHoldButton(jumpButton, (pressed) => { + controlState.jumpPressed = pressed; + }); + + pauseButton.addEventListener('pointerdown', (event) => { + event.preventDefault(); + controlState.pauseQueued = true; + setPressed(pauseButton, true); + }); + pauseButton.addEventListener('pointerup', (event) => { + event.preventDefault(); + setPressed(pauseButton, false); + }); + pauseButton.addEventListener('pointerleave', () => { + setPressed(pauseButton, false); + }); + pauseButton.addEventListener('pointercancel', () => { + setPressed(pauseButton, false); + }); + + const syncControlsVisibility = () => { + const useTouchControls = window.matchMedia('(pointer: coarse)').matches || window.innerWidth <= 900; + controlsRoot.hidden = !useTouchControls; + }; + + syncControlsVisibility(); + window.addEventListener('resize', syncControlsVisibility); + + game.events.once('destroy', () => { + window.removeEventListener('resize', syncControlsVisibility); + }); +} + +export function consumePauseToggle() { + if (!controlState.pauseQueued) { + return false; + } + + controlState.pauseQueued = false; + return true; +} diff --git a/src/world/Background.js b/src/world/Background.js index 402d69e..e84e57f 100644 --- a/src/world/Background.js +++ b/src/world/Background.js @@ -29,10 +29,16 @@ export function createBackgroundElements(scene) { mountain.closePath(); mountain.fillPath(); mountain.setDepth(1); - mountains.push({ sprite: mountain, x: mountainX }); + mountains.push({ + sprite: mountain, + x: mountainX, + width: 150, + speed: 10 + i * 2 + }); } scene.mountains = mountains; + const iceFloes = []; for (let i = 0; i < 8; i++) { const iceX = Phaser.Math.Between(0, 800); const iceY = Phaser.Math.Between(300, 450); @@ -44,7 +50,15 @@ export function createBackgroundElements(scene) { ice.strokeRect(0, 0, iceSize, iceSize * 0.3); ice.setPosition(iceX, iceY); ice.setDepth(1); + iceFloes.push({ + sprite: ice, + x: iceX, + y: iceY, + width: iceSize, + speed: Phaser.Math.Between(20, 38) + }); } + scene.iceFloes = iceFloes; } export function updateBackgroundElements(scene) { @@ -59,6 +73,28 @@ export function updateBackgroundElements(scene) { cloud.sprite.x = cloud.x; }); } + + if (scene.mountains) { + scene.mountains.forEach((mountain) => { + mountain.x -= scrollSpeed * mountain.speed * 0.05; + if (mountain.x + mountain.width < -80) { + mountain.x = 920; + } + mountain.sprite.x = mountain.x; + }); + } + + if (scene.iceFloes) { + scene.iceFloes.forEach((ice) => { + ice.x -= scrollSpeed * ice.speed * 0.08; + if (ice.x + ice.width < -40) { + ice.x = 840; + ice.y = Phaser.Math.Between(300, 450); + ice.sprite.y = ice.y; + } + ice.sprite.x = ice.x; + }); + } } export function createIceGround(scene) {