Compare commits

..

5 Commits

Author SHA1 Message Date
6f5b4d83f7 Phase B: new content — daily challenge, enemies, platform, settings, stats
B1 Seeded RNG + Daily Challenge
- utils/random.js wraps Phaser seedable RND (Between/frac are NOT seedable)
- All gameplay spawning (PlatformManager, Platform, Enemy) uses seeded rng
- GameScene reads mode/seed in init and seeds the run; daily shows a HUD badge
  and keeps a per-day best (daily_<YYYYMMDD>); MenuScene DAILY button;
  GameOver RETRY preserves mode and shows today's best
- Verified: same seed -> identical layout, different seed -> different

B2 New content
- Enemy mev_bot: homing chaser that eases toward the player (unlock >1500)
- Platform reorg: phantom, semi-transparent, vanishes shortly after landing
  (unlock >600); no power-ups on breaking/reorg; SPAWN_RATES + UNLOCK config
- Verified spawn distribution at high difficulty includes all new types

B3 Settings
- SoundManager gains volume (persisted); MenuScene SETTINGS overlay with
  volume stepper, particle-quality Low/High toggle, two-step reset progress

B4 Stats
- StatsManager tracks lifetime games/jumps/stomps/blocks/best combo, flushed
  at game over; MenuScene STATS overlay; hooks in GameScene/ScoreManager

B5 Difficulty tuning via UNLOCK thresholds and rebalanced spawn rates

Functionally verified in-browser via eval (no console errors, deterministic
daily, content spawns, particles emit). Visual screenshot unavailable in the
headless preview because the hidden tab pauses Phaser's loop.
2026-05-29 13:29:34 +07:00
fc1f12bb7e Phase A: stability and performance
A1 Pooled particle system
- New ParticleManager owns 9 reusable Phaser emitters created once per scene
  (jump, explosion, powerup, puff, sparks, flame, wind, spring, speedline)
- BootScene generates reusable white textures (px_square/soft/streak/ring)
- GameScene burst helpers + EffectsManager flow effects now delegate to the
  pool instead of allocating rectangles + tweens every frame
- Quality auto-detect (low on mobile/small screens) cuts particle counts

A2 Fix high-speed landing tunneling
- Cap downward velocity at PHYSICS.maxFallSpeed (boosts unaffected)
- platformCollisionFilter now uses a swept deltaY one-way check so a fast
  fall can never be wrongly rejected or passed through

A3 Safe storage
- New utils/storage.js wraps localStorage with in-memory fallback so private
  mode / quota errors cannot crash the game; all access routed through it

A4-A6 Lifecycle and robustness
- Global error / unhandledrejection guard recovers to the menu
- GameScene auto-pauses on tab hidden and cleans up the cross-scene listener
  on shutdown; fps pacing + disableContextMenu in game config
- Moving platforms use a proper dynamic body instead of destroy()+new Body

Verified in-browser: menu + game load with zero console errors, all emitters
active, normal bouncing works, fall speed capped.
2026-05-29 13:10:08 +07:00
1062b2855a Doodle-jump camera and remove player shadow
- Camera now uses a trigger line at 42% from top. Player rises/falls
  freely below the line; camera only scrolls up past it, never down.
- Remove playerShadow graphics that followed the hero around.
- Ignore .vercel-cli-config (CLI auth cache used as Windows workaround).
2026-05-23 20:43:49 +07:00
07b670fb09 Fix camera judder on fast ascent
Root cause: startFollow used lerpY=0.05 + roundPixels=true. At rocket
velocity (~1400 px/s) the camera lagged ~466px behind the player, then
snapped in chunks every frame, giving the impression of low FPS / shake.

Fix:
- Replace startFollow with manual upward-only camera tracking that
  matches the player exactly (doodle-jump standard behavior)
- Disable camera roundPixels so antialiasing actually smooths motion
- Round bg tilePositionY to integer to avoid grid texture shimmer
- Slightly thin out boost effects (flame 3->2/frame, speed line 40->65ms)
  to keep frame budget headroom on weaker devices
2026-05-23 18:50:58 +07:00
de1a3fcf56 Boost effects rewrite — flame, wind, speed lines, burst, puff
- EffectsManager handles all powerup visual feedback in one place
- Replace circle trail with proper effects per type:
  * Rocket: yellow->red flame particles from feet + occasional bright spark
  * Propeller: white/blue wind streaks around player
  * Spring: gold/green spark trail while ascending fast
- Boost start burst: two expanding rings + radial sparks + camera shake
  (orange/gold for rocket, cyan for propeller, green/gold for spring)
- Boost end puff: gray smoke cloud spreading from player
- Screen edge speed lines during boost (alternate left/right edges)
- Player no longer tilts with horizontal input during boost; rocket
  stays rigid upright with tiny lean, propeller has gentle sinusoidal wobble
2026-05-23 18:43:53 +07:00
19 changed files with 981 additions and 189 deletions

7
.gitignore vendored
View File

@@ -35,3 +35,10 @@ rocket_preview.png
# Agent notes — local only, useful for AI sessions but not for public repo # Agent notes — local only, useful for AI sessions but not for public repo
AGENTS.md AGENTS.md
# Vercel CLI auth cache (workaround for Windows EXDEV issue)
.vercel-cli-config/
# Local dev tooling
.claude/
dev-server.log

View File

@@ -13,9 +13,10 @@ export const PLATFORM_GAP_MIN = 60;
export const PLATFORM_GAP_MAX = 120; export const PLATFORM_GAP_MAX = 120;
export const SPAWN_RATES = { export const SPAWN_RATES = {
stable: 0.60, stable: 0.55,
moving: 0.20, moving: 0.18,
breaking: 0.15, breaking: 0.12,
reorg: 0.10,
genesis: 0.05, genesis: 0.05,
}; };
@@ -37,6 +38,13 @@ export const DIFFICULTY = {
maxEnemyRate: 0.30, maxEnemyRate: 0.30,
}; };
// Height (px climbed) at which harder content starts appearing.
export const UNLOCK = {
reorg: 600,
failedTx: 800,
mevBot: 1500,
};
export const SCORE = { export const SCORE = {
basePoints: 10, basePoints: 10,
genesisBonus: 50, genesisBonus: 50,
@@ -50,9 +58,8 @@ export const SCORE = {
export const PHYSICS = { export const PHYSICS = {
stompTolerance: 12, stompTolerance: 12,
coyoteTime: 110, landTolerance: 10,
jumpBufferTime: 130, maxFallSpeed: 950, // terminal velocity cap — prevents landing tunneling
variableJumpCutoff: 0.45,
}; };
export const POWERUP_DURATION = { export const POWERUP_DURATION = {

View File

@@ -1,5 +1,6 @@
import { Physics } from 'phaser'; import { Physics } from 'phaser';
import { GAME_WIDTH } from '../config/game.config.js'; import { GAME_WIDTH } from '../config/game.config.js';
import { rng } from '../utils/random.js';
export class Enemy extends Physics.Arcade.Sprite { export class Enemy extends Physics.Arcade.Sprite {
constructor(scene, x, y, type = 'bug') { constructor(scene, x, y, type = 'bug') {
@@ -14,27 +15,38 @@ export class Enemy extends Physics.Arcade.Sprite {
this.setScale(0.7); this.setScale(0.7);
this.body.setSize(this.width * 0.7, this.height * 0.7); this.body.setSize(this.width * 0.7, this.height * 0.7);
this.body.setOffset(this.width * 0.15, this.height * 0.15); this.body.setOffset(this.width * 0.15, this.height * 0.15);
this.speed = Phaser.Math.Between(60, 140) * (Math.random() < 0.5 ? 1 : -1); this.speed = rng.between(60, 140) * rng.sign();
this.startX = x; this.startX = x;
this.patrolRange = Phaser.Math.Between(80, 200); this.patrolRange = rng.between(80, 200);
} else if (type === 'failed_tx') { } else if (type === 'failed_tx') {
this.setScale(0.55); this.setScale(0.55);
this.setTint(0xef4444); this.setTint(0xef4444);
this.body.setSize(this.width * 0.6, this.height * 0.6); this.body.setSize(this.width * 0.6, this.height * 0.6);
this.body.setOffset(this.width * 0.2, this.height * 0.2); this.body.setOffset(this.width * 0.2, this.height * 0.2);
this.fallSpeed = Phaser.Math.Between(80, 140); this.fallSpeed = rng.between(80, 140);
this.driftAmplitude = Phaser.Math.Between(20, 60); this.driftAmplitude = rng.between(20, 60);
this.driftFreq = Phaser.Math.FloatBetween(0.001, 0.003); this.driftFreq = rng.realBetween(0.001, 0.003);
this.spawnTime = scene.time.now; this.spawnTime = scene.time.now;
this.spawnX = x; this.spawnX = x;
} else if (type === 'mev_bot') {
// Homing chaser: eases toward the player's X. Stompable like the rest.
this.setScale(0.6);
this.setTint(0x22d3ee);
this.body.setSize(this.width * 0.65, this.height * 0.65);
this.body.setOffset(this.width * 0.18, this.height * 0.18);
this.homeSpeed = rng.between(70, 120);
this.bobAmp = rng.between(4, 10);
this.baseY = y;
this.spawnTime = scene.time.now;
} }
} }
preUpdate(time, delta) { preUpdate(time, delta) {
super.preUpdate(time, delta); super.preUpdate(time, delta);
const dt = delta / 1000;
if (this.enemyType === 'bug') { if (this.enemyType === 'bug') {
this.x += this.speed * (delta / 1000); this.x += this.speed * dt;
if (Math.abs(this.x - this.startX) > this.patrolRange) { if (Math.abs(this.x - this.startX) > this.patrolRange) {
this.speed *= -1; this.speed *= -1;
this.setFlipX(this.speed < 0); this.setFlipX(this.speed < 0);
@@ -42,12 +54,21 @@ export class Enemy extends Physics.Arcade.Sprite {
if (this.x < -60) this.x = GAME_WIDTH + 60; if (this.x < -60) this.x = GAME_WIDTH + 60;
if (this.x > GAME_WIDTH + 60) this.x = -60; if (this.x > GAME_WIDTH + 60) this.x = -60;
} else if (this.enemyType === 'failed_tx') { } else if (this.enemyType === 'failed_tx') {
const dt = delta / 1000;
this.y += this.fallSpeed * dt; this.y += this.fallSpeed * dt;
const t = time - this.spawnTime; const t = time - this.spawnTime;
this.x = this.spawnX + Math.sin(t * this.driftFreq) * this.driftAmplitude; this.x = this.spawnX + Math.sin(t * this.driftFreq) * this.driftAmplitude;
this.x = Phaser.Math.Clamp(this.x, 30, GAME_WIDTH - 30); this.x = Phaser.Math.Clamp(this.x, 30, GAME_WIDTH - 30);
this.setAngle(Math.sin(t * 0.005) * 15); this.setAngle(Math.sin(t * 0.005) * 15);
} else if (this.enemyType === 'mev_bot') {
const player = this.scene.player;
if (player && player.active) {
const dir = Math.sign(player.x - this.x);
this.x += dir * this.homeSpeed * dt;
this.setFlipX(dir < 0);
}
this.x = Phaser.Math.Clamp(this.x, 24, GAME_WIDTH - 24);
const t = time - this.spawnTime;
this.y = this.baseY + Math.sin(t * 0.004) * this.bobAmp;
} }
} }
} }

View File

@@ -1,10 +1,14 @@
import { Physics } from 'phaser'; import { Physics } from 'phaser';
import { rng } from '../utils/random.js';
export class Platform extends Physics.Arcade.Sprite { export class Platform extends Physics.Arcade.Sprite {
constructor(scene, x, y, type = 'stable') { constructor(scene, x, y, type = 'stable') {
super(scene, x, y, 'platform'); super(scene, x, y, 'platform');
scene.add.existing(this); scene.add.existing(this);
scene.physics.add.existing(this, true);
// Moving platforms need a dynamic body; everything else is static.
const isMoving = type === 'moving';
scene.physics.add.existing(this, !isMoving);
this.platformType = type; this.platformType = type;
this.breakingState = 0; this.breakingState = 0;
@@ -13,15 +17,25 @@ export class Platform extends Physics.Arcade.Sprite {
this.startX = x; this.startX = x;
this.glowTween = null; this.glowTween = null;
if (type === 'moving') { if (isMoving) {
this.body.destroy(); this.body.setAllowGravity(false);
this.body = new Phaser.Physics.Arcade.Body(scene.physics.world, this); this.body.setImmovable(true);
this.body.allowGravity = false; this.moveSpeed = rng.between(50, 120) * rng.sign();
this.body.immovable = true; this.moveRange = rng.between(60, 160);
this.moveSpeed = Phaser.Math.Between(50, 120) * (Math.random() < 0.5 ? 1 : -1);
this.moveRange = Phaser.Math.Between(60, 160);
} else if (type === 'breaking') { } else if (type === 'breaking') {
this.setTint(0x999999); this.setTint(0x999999);
} else if (type === 'reorg') {
// Phantom "reorg" platform: semi-transparent, vanishes shortly after use.
this.setTint(0x38bdf8);
this.setAlpha(0.55);
this.glowTween = scene.tweens.add({
targets: this,
alpha: 0.3,
duration: 700,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
} else if (type === 'genesis') { } else if (type === 'genesis') {
this.setTint(0xffd700); this.setTint(0xffd700);
this.genesisGlow = scene.add.ellipse(x, y + 10, 100, 30, 0xffd700, 0.25) this.genesisGlow = scene.add.ellipse(x, y + 10, 100, 30, 0xffd700, 0.25)
@@ -64,6 +78,26 @@ export class Platform extends Physics.Arcade.Sprite {
}); });
return true; return true;
} }
if (this.platformType === 'reorg') {
// Allow this bounce, then vanish after a short grace window.
if (this.breakingState > 0) return true;
this.breakingState = 1;
if (this.glowTween) { this.glowTween.stop(); this.glowTween = null; }
this.setAlpha(0.55);
this.scene.time.delayedCall(300, () => {
this.breakingState = 2;
if (this.body) this.disableBody(true, false);
this.scene.tweens.add({
targets: this,
alpha: 0,
scaleX: 0.2,
scaleY: 0.2,
duration: 200,
onComplete: () => this.destroy(),
});
});
return true;
}
return true; return true;
} }

View File

@@ -34,6 +34,24 @@ export class Player extends Physics.Arcade.Sprite {
if (this.x < -this.width / 2) this.x = GAME_WIDTH + this.width / 2; if (this.x < -this.width / 2) this.x = GAME_WIDTH + this.width / 2;
if (this.x > GAME_WIDTH + this.width / 2) this.x = -this.width / 2; if (this.x > GAME_WIDTH + this.width / 2) this.x = -this.width / 2;
if (this.state === 'rocket') {
this.rocketTimer -= delta;
this.setVelocityY(ROCKET_VELOCITY);
if (velocityX < 0) this.setFlipX(true);
else if (velocityX > 0) this.setFlipX(false);
// Rocket: rigid upright with tiny lean toward movement
this.setAngle(velocityX === 0 ? 0 : (velocityX < 0 ? -3 : 3));
if (this.rocketTimer <= 0) this.endPowerUp();
} else if (this.state === 'propeller') {
this.propellerTimer -= delta;
this.setVelocityY(PROPELLER_VELOCITY);
if (velocityX < 0) this.setFlipX(true);
else if (velocityX > 0) this.setFlipX(false);
// Propeller: gentle sinusoidal wobble
const wobble = Math.sin(time / 80) * 4;
this.setAngle(wobble + (velocityX < 0 ? -2 : velocityX > 0 ? 2 : 0));
if (this.propellerTimer <= 0) this.endPowerUp();
} else {
if (velocityX < 0) { if (velocityX < 0) {
this.setFlipX(true); this.setFlipX(true);
this.setAngle(-5); this.setAngle(-5);
@@ -43,15 +61,6 @@ export class Player extends Physics.Arcade.Sprite {
} else { } else {
this.setAngle(0); this.setAngle(0);
} }
if (this.state === 'propeller') {
this.propellerTimer -= delta;
this.setVelocityY(PROPELLER_VELOCITY);
if (this.propellerTimer <= 0) this.endPowerUp();
} else if (this.state === 'rocket') {
this.rocketTimer -= delta;
this.setVelocityY(ROCKET_VELOCITY);
if (this.rocketTimer <= 0) this.endPowerUp();
} }
} }

View File

@@ -22,9 +22,33 @@ const config = {
debug: false, debug: false,
}, },
}, },
fps: {
target: 60,
min: 30,
},
disableContextMenu: true,
scene: [BootScene, MenuScene, GameScene, GameOverScene], scene: [BootScene, MenuScene, GameScene, GameOverScene],
pixelArt: false, pixelArt: false,
antialias: true, antialias: true,
}; };
window.game = new Game(config); const game = new Game(config);
window.game = game;
// Global crash guard: if an uncaught error happens during gameplay, recover to
// the menu instead of leaving a frozen canvas.
function recoverToMenu() {
try {
if (!game || !game.scene) return;
const inGame = game.scene.isActive('GameScene');
if (inGame) {
game.scene.stop('GameScene');
game.scene.start('MenuScene');
}
} catch (_) {
/* swallow — last-resort guard */
}
}
window.addEventListener('error', recoverToMenu);
window.addEventListener('unhandledrejection', recoverToMenu);

View File

@@ -1,6 +1,5 @@
import { sound } from './SoundManager.js'; import { sound } from './SoundManager.js';
import { storage, KEYS } from '../utils/storage.js';
const STORAGE_KEY = 'naddie_achievements_v1';
const DEFS = [ const DEFS = [
{ id: 'genesis_block', title: 'Genesis Block', desc: 'Land on a gold platform' }, { id: 'genesis_block', title: 'Genesis Block', desc: 'Land on a gold platform' },
@@ -33,18 +32,11 @@ export class AchievementsManager {
} }
_load() { _load() {
try { return storage.getJSON(KEYS.achievements, {}) || {};
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : {};
} catch (_) {
return {};
}
} }
_save() { _save() {
try { storage.setJSON(KEYS.achievements, this.unlocked);
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.unlocked));
} catch (_) {}
} }
isUnlocked(id) { isUnlocked(id) {

View File

@@ -0,0 +1,67 @@
/**
* Drives WHEN boost visual effects play (state machine over the player's
* power-up state). All actual particle allocation is delegated to the pooled
* ParticleManager (scene.particles), so this class never creates GameObjects.
*/
export class EffectsManager {
constructor(scene) {
this.scene = scene;
this.speedLineTimer = 0;
this.lastState = 'normal';
this.springTrailUntil = 0;
}
get pm() {
return this.scene.particles;
}
update(player, delta) {
const pm = this.pm;
if (!pm) return;
if (!player || !player.active || player.state === 'dead') {
pm.stopFlow();
return;
}
const state = player.state;
const now = this.scene.time.now;
const feetY = player.y + player.displayHeight / 2 - 6;
if (state === 'rocket' && Math.abs(player.body.velocity.y) > 150) {
pm.flameAt(player.x, feetY);
this._speedLines(delta);
} else if (state === 'propeller' && Math.abs(player.body.velocity.y) > 150) {
pm.windAt(player.x, player.y);
this._speedLines(delta);
} else if (state === 'normal' && now < this.springTrailUntil && player.body.velocity.y < -500) {
pm.springAt(player.x, feetY);
} else {
pm.stopFlow();
}
if (this.lastState !== state) {
if ((this.lastState === 'rocket' || this.lastState === 'propeller') && state === 'normal') {
pm.puff(player.x, player.y);
}
this.lastState = state;
}
}
_speedLines(delta) {
this.speedLineTimer += delta;
if (this.speedLineTimer > 65) {
this.speedLineTimer = 0;
this.pm.speedLine();
}
}
startBoost(player, type) {
if (this.pm) this.pm.boostBurst(player.x, player.y, type);
if (type === 'spring') {
this.springTrailUntil = this.scene.time.now + 700;
} else {
this.lastState = type;
}
}
}

View File

@@ -0,0 +1,225 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../config/game.config.js';
import { storage, KEYS } from '../utils/storage.js';
/**
* Owns a fixed set of pooled Phaser particle emitters, created once per scene.
* All hot-path particle effects (jump, explosion, boost flame/wind, etc.) reuse
* these emitters instead of allocating GameObjects + tweens every frame.
*/
export class ParticleManager {
constructor(scene) {
this.scene = scene;
this.quality = ParticleManager.resolveQuality(scene);
this.low = this.quality === 'low';
this.emitters = {};
this._build();
}
static resolveQuality(scene) {
const saved = storage.getItem(KEYS.particleQuality, null);
if (saved === 'low' || saved === 'high') return saved;
const d = scene.sys.game.device;
const small = Math.min(window.innerWidth, window.innerHeight) < 500;
return (d.os.android || d.os.iOS || small) ? 'low' : 'high';
}
_q(high, low) {
return this.low ? low : high;
}
_build() {
const add = (key, texture, config, depth) => {
const e = this.scene.add.particles(0, 0, texture, { emitting: false, ...config });
e.setDepth(depth);
this.emitters[key] = e;
return e;
};
// --- Burst emitters (manual explode) ---
add('jump', 'px_square', {
lifespan: 260,
speed: { min: 40, max: 130 },
angle: { min: 250, max: 290 },
scale: { start: 0.9, end: 0 },
alpha: { start: 0.9, end: 0 },
rotate: { min: -180, max: 180 },
tint: [0xa855f7, 0xc084fc, 0x8b5cf6],
frequency: -1,
}, -2);
add('explosion', 'px_square', {
lifespan: 500,
speed: { min: 60, max: 170 },
angle: { min: 0, max: 360 },
scale: { start: 1.1, end: 0 },
alpha: { start: 1, end: 0 },
rotate: { min: -180, max: 180 },
tint: [0xef4444, 0xf97316, 0xfca5a5],
frequency: -1,
}, 1);
add('powerup', 'px_square', {
lifespan: 600,
speed: { min: 50, max: 150 },
angle: { min: 0, max: 360 },
scale: { start: 1, end: 0 },
alpha: { start: 1, end: 0 },
tint: [0xffd700, 0xa855f7, 0x22c55e],
frequency: -1,
}, 1);
add('puff', 'px_soft', {
lifespan: { min: 500, max: 800 },
speed: { min: 30, max: 70 },
angle: { min: 0, max: 360 },
scale: { start: 1.2, end: 2.2 },
alpha: { start: 0.55, end: 0 },
tint: [0x888888, 0xaaaaaa, 0x666666],
frequency: -1,
}, -2);
add('sparks', 'px_square', {
lifespan: 500,
speed: { min: 70, max: 150 },
angle: { min: 0, max: 360 },
scale: { start: 1, end: 0 },
alpha: { start: 1, end: 0 },
frequency: -1,
}, 1);
// --- Flow emitters (toggle start/stop, follow a position) ---
add('flame', 'px_soft', {
lifespan: { min: 360, max: 620 },
speedY: { min: 120, max: 260 },
speedX: { min: -45, max: 45 },
scale: { start: this._q(1.2, 0.9), end: 0 },
alpha: { start: 0.95, end: 0 },
tint: [0xffd700, 0xffaa00, 0xff6600, 0xff3300, 0xaa0000],
blendMode: 'ADD',
frequency: this._q(28, 55),
quantity: 1,
}, -2);
add('wind', 'px_streak', {
lifespan: { min: 300, max: 540 },
speedY: { min: 30, max: 80 },
speedX: { min: -25, max: 25 },
scale: { start: 1, end: 0.3 },
alpha: { start: 0.7, end: 0 },
tint: [0xffffff, 0xbbddff, 0x66aaff, 0x4477cc],
blendMode: 'ADD',
frequency: this._q(40, 80),
quantity: 1,
}, -2);
add('spring', 'px_square', {
lifespan: { min: 400, max: 620 },
speedY: { min: 60, max: 130 },
speedX: { min: -35, max: 35 },
scale: { start: 1, end: 0 },
alpha: { start: 0.9, end: 0 },
rotate: { min: -180, max: 180 },
tint: [0xffd700, 0xfff066, 0x22c55e, 0xa3e635],
frequency: this._q(35, 70),
quantity: 1,
}, -2);
add('speedline', 'px_streak', {
lifespan: { min: 280, max: 450 },
speedY: { min: 150, max: 300 },
scaleX: { start: 1, end: 1 },
scaleY: { start: 3.5, end: 1.5 },
alpha: { start: 0.45, end: 0 },
tint: 0xffffff,
frequency: -1,
}, -1);
}
// --- Bursts ---
burstJump(x, y) {
this.emitters.jump.explode(this._q(8, 4), x, y);
}
burstExplosion(x, y) {
this.emitters.explosion.explode(this._q(14, 7), x, y);
}
burstPowerup(x, y) {
this.emitters.powerup.explode(this._q(12, 6), x, y);
}
puff(x, y) {
this.emitters.puff.explode(this._q(8, 4), x, y);
}
// --- Boost flow control (called each frame while active) ---
flameAt(x, y) {
const e = this.emitters.flame;
e.setPosition(x, y);
if (!e.emitting) e.start();
}
windAt(x, y) {
const e = this.emitters.wind;
e.setPosition(x, y);
if (!e.emitting) e.start();
}
springAt(x, y) {
const e = this.emitters.spring;
e.setPosition(x, y);
if (!e.emitting) e.start();
}
stopFlow() {
if (this.emitters.flame.emitting) this.emitters.flame.stop();
if (this.emitters.wind.emitting) this.emitters.wind.stop();
if (this.emitters.spring.emitting) this.emitters.spring.stop();
}
speedLine() {
if (this.low) return;
const cam = this.scene.cameras.main;
const left = Math.random() < 0.5;
const x = left ? Phaser.Math.Between(0, 40) : Phaser.Math.Between(GAME_WIDTH - 40, GAME_WIDTH);
const y = cam.scrollY + Phaser.Math.Between(0, GAME_HEIGHT);
this.emitters.speedline.emitParticleAt(x, y, 1);
}
// --- Boost start burst: rings (rare, 2 sprites) + spark explode ---
boostBurst(x, y, type) {
let ringColor, sparkColor;
if (type === 'rocket') { ringColor = 0xff6600; sparkColor = 0xffd700; }
else if (type === 'spring') { ringColor = 0x22c55e; sparkColor = 0xffd700; }
else { ringColor = 0x88ccff; sparkColor = 0xffffff; }
this._ring(x, y, ringColor, 0.6, 380);
this._ring(x, y, sparkColor, 0.45, 520);
this.emitters.sparks.setParticleTint(sparkColor);
this.emitters.sparks.explode(this._q(type === 'rocket' ? 16 : 10, 6), x, y);
const shake = type === 'rocket' ? 0.006 : type === 'spring' ? 0.002 : 0.003;
const dur = type === 'rocket' ? 120 : type === 'spring' ? 60 : 80;
this.scene.cameras.main.shake(dur, shake);
}
_ring(x, y, color, alpha, duration) {
const ring = this.scene.add.image(x, y, 'px_ring').setTint(color).setAlpha(alpha).setDepth(-1).setScale(0.4);
this.scene.tweens.add({
targets: ring,
scale: 3,
alpha: 0,
duration,
ease: 'Quad.easeOut',
onComplete: () => ring.destroy(),
});
}
destroy() {
Object.values(this.emitters).forEach((e) => {
if (e && e.destroy) e.destroy();
});
this.emitters = {};
}
}

View File

@@ -3,9 +3,10 @@ import { Spring } from '../entities/Spring.js';
import { PropellerHat } from '../entities/PropellerHat.js'; import { PropellerHat } from '../entities/PropellerHat.js';
import { Rocket } from '../entities/Rocket.js'; import { Rocket } from '../entities/Rocket.js';
import { Enemy } from '../entities/Enemy.js'; import { Enemy } from '../entities/Enemy.js';
import { rng } from '../utils/random.js';
import { import {
GAME_WIDTH, PLATFORM_GAP_MIN, PLATFORM_GAP_MAX, GAME_WIDTH, PLATFORM_GAP_MIN, PLATFORM_GAP_MAX,
SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY, SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY, UNLOCK,
} from '../config/game.config.js'; } from '../config/game.config.js';
export class PlatformManager { export class PlatformManager {
@@ -45,31 +46,36 @@ export class PlatformManager {
Math.floor(difficultyLevel / 1000) * DIFFICULTY.gapIncreasePer1000, Math.floor(difficultyLevel / 1000) * DIFFICULTY.gapIncreasePer1000,
DIFFICULTY.maxGap - DIFFICULTY.initialGap DIFFICULTY.maxGap - DIFFICULTY.initialGap
); );
const gap = Phaser.Math.Between( const gap = rng.between(
PLATFORM_GAP_MIN + gapIncrease, PLATFORM_GAP_MIN + gapIncrease,
Math.min(PLATFORM_GAP_MAX + gapIncrease, DIFFICULTY.maxGap) Math.min(PLATFORM_GAP_MAX + gapIncrease, DIFFICULTY.maxGap)
); );
this.highestY -= gap; this.highestY -= gap;
const x = Phaser.Math.Between(60, GAME_WIDTH - 60); const x = rng.between(60, GAME_WIDTH - 60);
const rand = Math.random(); const rand = rng.frac();
let type; let type;
if (rand < SPAWN_RATES.stable) type = 'stable'; if (rand < SPAWN_RATES.stable) type = 'stable';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving) type = 'moving'; else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving) type = 'moving';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking) type = 'breaking'; else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking) type = 'breaking';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking + SPAWN_RATES.reorg) type = 'reorg';
else type = 'genesis'; else type = 'genesis';
// Phantom reorg platforms only appear once the climb gets serious.
if (type === 'reorg' && difficultyLevel < UNLOCK.reorg) type = 'stable';
const platform = new Platform(this.scene, x, this.highestY, type); const platform = new Platform(this.scene, x, this.highestY, type);
this.platforms.add(platform); this.platforms.add(platform);
if (type !== 'breaking') { // No power-ups on platforms that disappear under you.
if (type !== 'breaking' && type !== 'reorg') {
this.maybeSpawnPowerUp(x, this.highestY - 25); this.maybeSpawnPowerUp(x, this.highestY - 25);
} }
this.maybeSpawnEnemy(x, this.highestY, difficultyLevel); this.maybeSpawnEnemy(x, this.highestY, difficultyLevel);
} }
maybeSpawnPowerUp(platformX, y) { maybeSpawnPowerUp(platformX, y) {
const rand = Math.random(); const rand = rng.frac();
let type = null; let type = null;
if (rand < POWERUP_RATES.spring) type = 'spring'; if (rand < POWERUP_RATES.spring) type = 'spring';
else if (rand < POWERUP_RATES.spring + POWERUP_RATES.propeller) type = 'propeller'; else if (rand < POWERUP_RATES.spring + POWERUP_RATES.propeller) type = 'propeller';
@@ -77,7 +83,7 @@ export class PlatformManager {
if (!type) return; if (!type) return;
const x = Phaser.Math.Clamp(platformX + Phaser.Math.Between(-15, 15), 30, GAME_WIDTH - 30); const x = Phaser.Math.Clamp(platformX + rng.between(-15, 15), 30, GAME_WIDTH - 30);
if (type === 'spring') { if (type === 'spring') {
this.powerups.add(new Spring(this.scene, x, y)); this.powerups.add(new Spring(this.scene, x, y));
} else if (type === 'propeller') { } else if (type === 'propeller') {
@@ -93,15 +99,13 @@ export class PlatformManager {
DIFFICULTY.maxEnemyRate DIFFICULTY.maxEnemyRate
); );
const totalRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate); const totalRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate);
if (Math.random() >= totalRate) return; if (rng.frac() >= totalRate) return;
// Failed-Tx type unlocks at higher difficulty const type = this._rollEnemyType(difficultyLevel);
const failedTxAllowed = difficultyLevel > 800;
const type = failedTxAllowed && Math.random() < 0.30 ? 'failed_tx' : 'bug';
const offset = Phaser.Math.Between(-60, 60); const offset = rng.between(-60, 60);
const ex = Phaser.Math.Clamp(platformX + offset, 50, GAME_WIDTH - 50); const ex = Phaser.Math.Clamp(platformX + offset, 50, GAME_WIDTH - 50);
const ey = platformY - Phaser.Math.Between(60, 130); const ey = platformY - rng.between(60, 130);
const minDist = 150; const minDist = 150;
let tooClose = false; let tooClose = false;
@@ -115,6 +119,19 @@ export class PlatformManager {
this.enemies.add(new Enemy(this.scene, ex, ey, type)); this.enemies.add(new Enemy(this.scene, ex, ey, type));
} }
_rollEnemyType(difficultyLevel) {
const r = rng.frac();
if (difficultyLevel >= UNLOCK.mevBot) {
if (r < 0.25) return 'mev_bot';
if (r < 0.50) return 'failed_tx';
return 'bug';
}
if (difficultyLevel >= UNLOCK.failedTx) {
return r < 0.30 ? 'failed_tx' : 'bug';
}
return 'bug';
}
getPlatforms() { getPlatforms() {
return this.platforms; return this.platforms;
} }

View File

@@ -1,4 +1,5 @@
import { SCORE } from '../config/game.config.js'; import { SCORE } from '../config/game.config.js';
import { storage, KEYS } from '../utils/storage.js';
export class ScoreManager { export class ScoreManager {
constructor(scene) { constructor(scene) {
@@ -131,6 +132,9 @@ export class ScoreManager {
if (this.scene.achievements) { if (this.scene.achievements) {
this.scene.achievements.onPlatformLand(platformType, this.comboMultiplier); this.scene.achievements.onPlatformLand(platformType, this.comboMultiplier);
} }
if (this.scene.stats) {
this.scene.stats.onCombo(this.comboMultiplier);
}
if (this.combo > 1) { if (this.combo > 1) {
this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`); this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`);
@@ -191,16 +195,14 @@ export class ScoreManager {
} }
updateBestDisplay() { updateBestDisplay() {
const raw = localStorage.getItem('naddie_best_score'); const best = storage.getInt(KEYS.best, 0);
const best = raw ? (parseInt(raw, 10) || 0) : 0;
this.hudBest.setText(`Best: ${best}`); this.hudBest.setText(`Best: ${best}`);
} }
saveBest() { saveBest() {
const raw = localStorage.getItem('naddie_best_score'); const best = storage.getInt(KEYS.best, 0);
const best = raw ? (parseInt(raw, 10) || 0) : 0;
if (this.score > best) { if (this.score > best) {
localStorage.setItem('naddie_best_score', String(this.score)); storage.setItem(KEYS.best, this.score);
return true; return true;
} }
return false; return false;

View File

@@ -1,12 +1,15 @@
import { storage, KEYS } from '../utils/storage.js';
const MAX_GAIN = 0.4;
class SoundManager { class SoundManager {
constructor() { constructor() {
this.ctx = null; this.ctx = null;
this.master = null; this.master = null;
this.muted = false;
this.musicNodes = null; this.musicNodes = null;
const raw = localStorage.getItem('naddie_muted'); this.muted = storage.getItem(KEYS.muted, '0') === '1';
this.muted = raw === '1'; this.volume = Phaser.Math.Clamp(storage.getFloat(KEYS.volume, 1), 0, 1);
} }
init() { init() {
@@ -15,7 +18,7 @@ class SoundManager {
if (!AC) return; if (!AC) return;
this.ctx = new AC(); this.ctx = new AC();
this.master = this.ctx.createGain(); this.master = this.ctx.createGain();
this.master.gain.value = 0.4; this.master.gain.value = this._effectiveGain();
this.master.connect(this.ctx.destination); this.master.connect(this.ctx.destination);
} }
@@ -25,18 +28,34 @@ class SoundManager {
} }
} }
_effectiveGain() {
return this.muted ? 0 : MAX_GAIN * this.volume;
}
_applyGain() {
if (this.master) this.master.gain.value = this._effectiveGain();
}
setMuted(value) { setMuted(value) {
this.muted = value; this.muted = value;
localStorage.setItem('naddie_muted', value ? '1' : '0'); storage.setItem(KEYS.muted, value ? '1' : '0');
if (this.master) { this._applyGain();
this.master.gain.value = value ? 0 : 0.4;
}
} }
isMuted() { isMuted() {
return this.muted; return this.muted;
} }
setVolume(value) {
this.volume = Phaser.Math.Clamp(value, 0, 1);
storage.setItem(KEYS.volume, this.volume);
this._applyGain();
}
getVolume() {
return this.volume;
}
_beep(freq, duration, type = 'square', volume = 0.3) { _beep(freq, duration, type = 'square', volume = 0.3) {
if (!this.ctx || this.muted) return; if (!this.ctx || this.muted) return;
const osc = this.ctx.createOscillator(); const osc = this.ctx.createOscillator();

View File

@@ -0,0 +1,52 @@
import { storage, KEYS } from '../utils/storage.js';
const DEFAULTS = {
gamesPlayed: 0,
totalJumps: 0,
totalStomps: 0,
totalBlocks: 0,
bestCombo: 0,
};
/**
* Persistent lifetime stats. One instance per run accumulates a snapshot and
* flushes it into the stored totals at game over.
*/
export class StatsManager {
constructor() {
this.run = { jumps: 0, stomps: 0, bestCombo: 0 };
}
static load() {
const saved = storage.getJSON(KEYS.stats, null);
return { ...DEFAULTS, ...(saved || {}) };
}
static reset() {
storage.setJSON(KEYS.stats, { ...DEFAULTS });
}
onJump() {
this.run.jumps += 1;
}
onStomp() {
this.run.stomps += 1;
}
onCombo(multiplier) {
if (multiplier > this.run.bestCombo) this.run.bestCombo = multiplier;
}
/** Merge this run into lifetime totals. Call once at game over. */
flush(blockHeight) {
const t = StatsManager.load();
t.gamesPlayed += 1;
t.totalJumps += this.run.jumps;
t.totalStomps += this.run.stomps;
t.totalBlocks += blockHeight || 0;
t.bestCombo = Math.max(t.bestCombo, this.run.bestCombo);
storage.setJSON(KEYS.stats, t);
return t;
}
}

View File

@@ -18,9 +18,46 @@ export class BootScene extends Scene {
create() { create() {
this.createBackgrounds(); this.createBackgrounds();
this.createParticleTextures();
this.scene.start('MenuScene'); this.scene.start('MenuScene');
} }
createParticleTextures() {
// All white so emitters can tint per-use. Keep textures tiny.
// Solid square (jump dust, explosion, sparks, puff)
const sq = this.make.graphics({ x: 0, y: 0, add: false });
sq.fillStyle(0xffffff, 1);
sq.fillRect(0, 0, 8, 8);
sq.generateTexture('px_square', 8, 8);
sq.destroy();
// Soft round dot (flame core, glow sparks)
const soft = this.make.graphics({ x: 0, y: 0, add: false });
soft.fillStyle(0xffffff, 0.35);
soft.fillCircle(8, 8, 8);
soft.fillStyle(0xffffff, 0.7);
soft.fillCircle(8, 8, 5);
soft.fillStyle(0xffffff, 1);
soft.fillCircle(8, 8, 2.5);
soft.generateTexture('px_soft', 16, 16);
soft.destroy();
// Thin streak (wind, speed lines)
const streak = this.make.graphics({ x: 0, y: 0, add: false });
streak.fillStyle(0xffffff, 1);
streak.fillRect(0, 0, 3, 18);
streak.generateTexture('px_streak', 3, 18);
streak.destroy();
// Ring (boost burst shockwave)
const ring = this.make.graphics({ x: 0, y: 0, add: false });
ring.lineStyle(4, 0xffffff, 1);
ring.strokeCircle(32, 32, 28);
ring.generateTexture('px_ring', 64, 64);
ring.destroy();
}
createBackgrounds() { createBackgrounds() {
const gridW = 512; const gridW = 512;
const gridH = 512; const gridH = 512;

View File

@@ -1,6 +1,7 @@
import { Scene } from 'phaser'; import { Scene } from 'phaser';
import { sound } from '../managers/SoundManager.js'; import { sound } from '../managers/SoundManager.js';
import { createButton } from '../utils/ui.js'; import { createButton } from '../utils/ui.js';
import { todaySeed } from '../utils/random.js';
export class GameOverScene extends Scene { export class GameOverScene extends Scene {
constructor() { constructor() {
@@ -11,6 +12,17 @@ export class GameOverScene extends Scene {
this.finalScore = data.score || 0; this.finalScore = data.score || 0;
this.blockHeight = data.blockHeight || 0; this.blockHeight = data.blockHeight || 0;
this.isNewBest = data.isNewBest || false; this.isNewBest = data.isNewBest || false;
this.mode = data.mode || 'normal';
this.dailyBest = data.dailyBest;
}
retry() {
sound.click();
if (this.mode === 'daily') {
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
} else {
this.scene.start('GameScene', { mode: 'normal' });
}
} }
create() { create() {
@@ -49,7 +61,15 @@ export class GameOverScene extends Scene {
color: '#a855f7', color: '#a855f7',
}).setOrigin(0.5); }).setOrigin(0.5);
createButton(this, width / 2, height * 0.72, 'RETRY', () => this.scene.start('GameScene')); if (this.mode === 'daily') {
this.add.text(width / 2, height * 0.665, `DAILY · Today's Best: ${this.dailyBest ?? this.finalScore}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: '#ffd700',
}).setOrigin(0.5);
}
createButton(this, width / 2, height * 0.72, 'RETRY', () => this.retry());
createButton(this, width / 2, height * 0.82, 'MAIN MENU', () => this.scene.start('MenuScene')); createButton(this, width / 2, height * 0.82, 'MAIN MENU', () => this.scene.start('MenuScene'));
this.add.text(width / 2, height * 0.92, 'ON-CHAIN SUBMIT — COMING SOON', { this.add.text(width / 2, height * 0.92, 'ON-CHAIN SUBMIT — COMING SOON', {
@@ -59,13 +79,7 @@ export class GameOverScene extends Scene {
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
this.input.keyboard.once('keydown-ENTER', () => { this.input.keyboard.once('keydown-ENTER', () => this.retry());
sound.click(); this.input.keyboard.once('keydown-SPACE', () => this.retry());
this.scene.start('GameScene');
});
this.input.keyboard.once('keydown-SPACE', () => {
sound.click();
this.scene.start('GameScene');
});
} }
} }

View File

@@ -4,7 +4,12 @@ import { Platform } from '../entities/Platform.js';
import { PlatformManager } from '../managers/PlatformManager.js'; import { PlatformManager } from '../managers/PlatformManager.js';
import { ScoreManager } from '../managers/ScoreManager.js'; import { ScoreManager } from '../managers/ScoreManager.js';
import { AchievementsManager } from '../managers/AchievementsManager.js'; import { AchievementsManager } from '../managers/AchievementsManager.js';
import { EffectsManager } from '../managers/EffectsManager.js';
import { ParticleManager } from '../managers/ParticleManager.js';
import { StatsManager } from '../managers/StatsManager.js';
import { sound } from '../managers/SoundManager.js'; import { sound } from '../managers/SoundManager.js';
import { storage, KEYS } from '../utils/storage.js';
import { rng } from '../utils/random.js';
import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS } from '../config/game.config.js'; import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS } from '../config/game.config.js';
export class GameScene extends Scene { export class GameScene extends Scene {
@@ -12,10 +17,18 @@ export class GameScene extends Scene {
super({ key: 'GameScene' }); super({ key: 'GameScene' });
} }
init(data) {
this.mode = (data && data.mode) || 'normal';
this.seed = (data && data.seed) || `${Date.now()}-${Math.random()}`;
}
create() { create() {
sound.init(); sound.init();
sound.resume(); sound.resume();
// Seed gameplay RNG so a given seed produces a reproducible run (Daily).
rng.seed(this.seed);
this.bg = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'gridBg') this.bg = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'gridBg')
.setScrollFactor(0) .setScrollFactor(0)
.setDepth(-10); .setDepth(-10);
@@ -24,6 +37,8 @@ export class GameScene extends Scene {
this.bgTint = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000033, 0) this.bgTint = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000033, 0)
.setScrollFactor(0).setDepth(-9); .setScrollFactor(0).setDepth(-9);
this.particles = new ParticleManager(this);
this.createGweiParticles(); this.createGweiParticles();
this.cursors = this.input.keyboard.createCursorKeys(); this.cursors = this.input.keyboard.createCursorKeys();
@@ -40,14 +55,11 @@ export class GameScene extends Scene {
this.player = new Player(this, GAME_WIDTH / 2, startY - 140); this.player = new Player(this, GAME_WIDTH / 2, startY - 140);
this.lastJumpY = this.player.y; this.lastJumpY = this.player.y;
this.playerShadow = this.add.graphics(); this.effects = new EffectsManager(this);
this.playerShadow.setDepth(-4);
this.trailPoints = [];
this.trailGraphics = this.add.graphics().setDepth(-3);
this.platformManager = new PlatformManager(this); this.platformManager = new PlatformManager(this);
this.achievements = new AchievementsManager(this); this.achievements = new AchievementsManager(this);
this.stats = new StatsManager(this);
this.scoreManager = new ScoreManager(this); this.scoreManager = new ScoreManager(this);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -60,7 +72,10 @@ export class GameScene extends Scene {
this.physics.add.overlap(this.player, this.platformManager.getEnemies(), this.handleEnemy, null, this); this.physics.add.overlap(this.player, this.platformManager.getEnemies(), this.handleEnemy, null, this);
this.cameras.main.setBounds(0, -999999, GAME_WIDTH, 999999 + GAME_HEIGHT); this.cameras.main.setBounds(0, -999999, GAME_WIDTH, 999999 + GAME_HEIGHT);
this.cameras.main.startFollow(this.player, true, 0, 0.05, 0, 180); // Doodle-jump camera: free movement below the trigger line, camera only
// scrolls up when player rises above it, and never moves down.
this.cameraTriggerY = GAME_HEIGHT * 0.42; // ~358 of 854 (just above middle)
this.cameras.main.setRoundPixels(false);
this.isGameOver = false; this.isGameOver = false;
this.isPaused = false; this.isPaused = false;
@@ -72,18 +87,53 @@ export class GameScene extends Scene {
this.createPauseUI(); this.createPauseUI();
this.createMuteButton(); this.createMuteButton();
if (this.mode === 'daily') {
this.add.text(GAME_WIDTH / 2, 18, `DAILY · ${this.seed}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#ffd700',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(300);
}
this.escKey.on('down', () => this.togglePause()); this.escKey.on('down', () => this.togglePause());
// Auto-pause when the tab/window is hidden — avoids a delta-spike teleport
// on refocus. Player must resume manually (not auto-resumed).
this._onHidden = () => {
if (!this.isGameOver && !this.isPaused) this.togglePause();
};
this.game.events.on('hidden', this._onHidden);
this.events.once('shutdown', this.cleanup, this);
this.maybeShowTutorial(); this.maybeShowTutorial();
this.showTouchIndicators(); this.showTouchIndicators();
} }
cleanup() {
if (this._onHidden) {
this.game.events.off('hidden', this._onHidden);
this._onHidden = null;
}
}
update(time, delta) { update(time, delta) {
if (this.isGameOver || this.isPaused) return; if (this.isGameOver || this.isPaused) return;
this.player.update(this.cursors, this.wasd, this.touchLeft, this.touchRight, time, delta); this.player.update(this.cursors, this.wasd, this.touchLeft, this.touchRight, time, delta);
this.bg.tilePositionY = this.cameras.main.scrollY * 0.3; // Cap downward speed so the player can never tunnel through a thin platform
// in a single physics step (does not affect upward boost velocities).
if (this.player.body.velocity.y > PHYSICS.maxFallSpeed) {
this.player.setVelocityY(PHYSICS.maxFallSpeed);
}
// Camera: latch player at trigger line going up, free movement otherwise.
const targetScrollY = this.player.y - this.cameraTriggerY;
if (targetScrollY < this.cameras.main.scrollY) {
this.cameras.main.scrollY = targetScrollY;
}
this.bg.tilePositionY = Math.round(this.cameras.main.scrollY * 0.3);
this.minScrollY = Math.min(this.minScrollY, this.cameras.main.scrollY); this.minScrollY = Math.min(this.minScrollY, this.cameras.main.scrollY);
const killLine = this.minScrollY + GAME_HEIGHT; const killLine = this.minScrollY + GAME_HEIGHT;
@@ -107,48 +157,18 @@ export class GameScene extends Scene {
this.lastJumpY = this.player.y; this.lastJumpY = this.player.y;
} }
this.updatePlayerShadow(); this.effects.update(this.player, delta);
this.updateTrail();
}
updatePlayerShadow() {
this.playerShadow.clear();
if (!this.player.active) return;
const alpha = Math.max(0.05, 0.25 - (Math.abs(this.player.body.velocity.y) / 1200));
this.playerShadow.fillStyle(0x000000, alpha);
this.playerShadow.fillCircle(this.player.x, this.player.y + 42, 16);
}
updateTrail() {
if (!this.player.active) return;
const isFast = this.player.state === 'rocket' || this.player.state === 'propeller';
if (isFast && Math.abs(this.player.body.velocity.y) > 200) {
this.trailPoints.push({ x: this.player.x, y: this.player.y, alpha: 0.5, scale: 1 });
if (this.trailPoints.length > 15) this.trailPoints.shift();
}
for (let i = this.trailPoints.length - 1; i >= 0; i--) {
const point = this.trailPoints[i];
point.alpha -= 0.04;
point.scale -= 0.03;
if (point.alpha <= 0 || point.scale <= 0) {
this.trailPoints.splice(i, 1);
}
}
this.trailGraphics.clear();
for (const point of this.trailPoints) {
const color = this.player.state === 'rocket' ? 0xff4444 : 0x44aaff;
this.trailGraphics.fillStyle(color, point.alpha);
this.trailGraphics.fillCircle(point.x, point.y, 8 * point.scale);
}
} }
platformCollisionFilter(player, platform) { platformCollisionFilter(player, platform) {
if (!platform || !platform.body) return false; if (!platform || !platform.body) return false;
if (typeof platform.isBroken === 'function' && platform.isBroken()) return false; if (typeof platform.isBroken === 'function' && platform.isBroken()) return false;
return player.body.velocity.y > 0 && player.y < platform.y + 8; if (player.body.velocity.y <= 0) return false;
// Swept one-way check: only bounce if the player's feet were at/above the
// platform top on the PREVIOUS step (i.e. genuinely landing from above).
// Using deltaY makes this correct at any fall speed.
const prevBottom = player.body.bottom - player.body.deltaY();
return prevBottom <= platform.body.top + PHYSICS.landTolerance;
} }
handlePlatformCollision(player, platform) { handlePlatformCollision(player, platform) {
@@ -161,6 +181,7 @@ export class GameScene extends Scene {
this.lastJumpY = player.y; this.lastJumpY = player.y;
if (player.jump()) { if (player.jump()) {
sound.jump(); sound.jump();
if (this.stats) this.stats.onJump();
} }
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
} }
@@ -171,17 +192,22 @@ export class GameScene extends Scene {
const px = powerup.x; const px = powerup.x;
const py = powerup.y; const py = powerup.y;
const name = powerup.constructor.name.toLowerCase(); const name = powerup.constructor.name.toLowerCase();
const isSpring = name === 'spring'; const map = { spring: 'spring', propellerhat: 'propeller', rocket: 'rocket' };
const kind = map[name] || name;
const consumed = powerup.onPlayerTouch(player); const consumed = powerup.onPlayerTouch(player);
if (consumed) { if (consumed) {
this.createPowerupParticles(px, py); this.createPowerupParticles(px, py);
this.flashScreen(); this.flashScreen();
if (isSpring) sound.spring(); if (kind === 'spring') {
else sound.powerup(); sound.spring();
if (this.achievements) { this.effects.startBoost(player, 'spring');
const map = { spring: 'spring', propellerhat: 'propeller', rocket: 'rocket' }; } else if (kind === 'propeller' || kind === 'rocket') {
this.achievements.onPowerup(map[name] || name); sound.powerup();
this.effects.startBoost(player, kind);
} else {
sound.powerup();
} }
if (this.achievements) this.achievements.onPowerup(kind);
} }
} }
} }
@@ -208,6 +234,7 @@ export class GameScene extends Scene {
sound.stomp(); sound.stomp();
this.scoreManager.addPoints(SCORE.stompBonus); this.scoreManager.addPoints(SCORE.stompBonus);
if (this.achievements) this.achievements.onEnemyStomp(); if (this.achievements) this.achievements.onEnemyStomp();
if (this.stats) this.stats.onStomp();
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
return; return;
} }
@@ -230,9 +257,24 @@ export class GameScene extends Scene {
const score = this.scoreManager.score; const score = this.scoreManager.score;
const blockHeight = this.scoreManager.blockHeight; const blockHeight = this.scoreManager.blockHeight;
if (this.stats) this.stats.flush(blockHeight);
// Daily challenge keeps its own per-day best.
let dailyBest = null;
if (this.mode === 'daily') {
const key = KEYS.dailyPrefix + this.seed;
dailyBest = storage.getInt(key, 0);
if (score > dailyBest) {
dailyBest = score;
storage.setItem(key, score);
}
}
this.time.delayedCall(1500, () => { this.time.delayedCall(1500, () => {
this.scoreManager.destroy(); this.scoreManager.destroy();
this.scene.start('GameOverScene', { score, blockHeight, isNewBest }); this.scene.start('GameOverScene', {
score, blockHeight, isNewBest, mode: this.mode, dailyBest,
});
}); });
} }
@@ -308,7 +350,7 @@ export class GameScene extends Scene {
} }
maybeShowTutorial() { maybeShowTutorial() {
if (localStorage.getItem('naddie_tutorial_seen') === '1') return; if (storage.getItem(KEYS.tutorialSeen, '0') === '1') return;
const { width, height } = this.scale; const { width, height } = this.scale;
this.isPaused = true; this.isPaused = true;
@@ -355,7 +397,7 @@ export class GameScene extends Scene {
startBtn.on('pointerdown', () => { startBtn.on('pointerdown', () => {
sound.click(); sound.click();
localStorage.setItem('naddie_tutorial_seen', '1'); storage.setItem(KEYS.tutorialSeen, '1');
container.destroy(); container.destroy();
this.isPaused = false; this.isPaused = false;
this.physics.resume(); this.physics.resume();
@@ -444,52 +486,14 @@ export class GameScene extends Scene {
} }
createJumpParticles(x, y) { createJumpParticles(x, y) {
for (let i = 0; i < 8; i++) { this.particles.burstJump(x, y);
const size = Phaser.Math.Between(4, 7);
const p = this.add.rectangle(x, y, size, size, 0xa855f7).setAlpha(0.9);
this.tweens.add({
targets: p,
x: x + Phaser.Math.Between(-35, 35),
y: y + Phaser.Math.Between(10, 45),
alpha: 0,
scale: 0,
angle: Phaser.Math.Between(-90, 90),
duration: 250,
onComplete: () => p.destroy(),
});
}
} }
createPowerupParticles(x, y) { createPowerupParticles(x, y) {
for (let i = 0; i < 10; i++) { this.particles.burstPowerup(x, y);
const color = [0xffd700, 0xa855f7, 0x22c55e][Phaser.Math.Between(0, 2)];
const p = this.add.rectangle(x, y, 5, 5, color).setAlpha(1);
this.tweens.add({
targets: p,
x: x + Phaser.Math.Between(-50, 50),
y: y + Phaser.Math.Between(-50, 50),
alpha: 0,
scale: 0,
duration: 600,
onComplete: () => p.destroy(),
});
}
} }
createExplosion(x, y) { createExplosion(x, y) {
for (let i = 0; i < 12; i++) { this.particles.burstExplosion(x, y);
const p = this.add.rectangle(x, y, 6, 6, 0xef4444).setAlpha(1);
const angle = Phaser.Math.Between(0, 360);
const dist = Phaser.Math.Between(30, 80);
this.tweens.add({
targets: p,
x: x + Math.cos(angle * Math.PI / 180) * dist,
y: y + Math.sin(angle * Math.PI / 180) * dist,
alpha: 0,
scale: 0,
duration: 500,
onComplete: () => p.destroy(),
});
}
} }
} }

View File

@@ -1,7 +1,11 @@
import { Scene } from 'phaser'; import { Scene } from 'phaser';
import { sound } from '../managers/SoundManager.js'; import { sound } from '../managers/SoundManager.js';
import { AchievementsManager } from '../managers/AchievementsManager.js'; import { AchievementsManager } from '../managers/AchievementsManager.js';
import { StatsManager } from '../managers/StatsManager.js';
import { ParticleManager } from '../managers/ParticleManager.js';
import { createButton } from '../utils/ui.js'; import { createButton } from '../utils/ui.js';
import { storage, KEYS } from '../utils/storage.js';
import { todaySeed } from '../utils/random.js';
export class MenuScene extends Scene { export class MenuScene extends Scene {
constructor() { constructor() {
@@ -31,13 +35,13 @@ export class MenuScene extends Scene {
this.tweens.add({ targets: preview, y: height * 0.44 - 15, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: preview, y: height * 0.44 - 15, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: preview, angle: 5, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); this.tweens.add({ targets: preview, angle: 5, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
createButton(this, width / 2, height * 0.62, 'START GAME', () => this.startGame(), { createButton(this, width / 2, height * 0.585, 'START GAME', () => this.startGame(), {
width: 280, height: 56, fontSize: '15px', width: 280, height: 54, fontSize: '15px',
}); });
this.add.text(width / 2, height * 0.68, 'ENTER / SPACE / TAP', { this.add.text(width / 2, height * 0.635, 'ENTER / SPACE / TAP', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '10px', fontSize: '9px',
color: '#888', color: '#888',
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
@@ -45,16 +49,26 @@ export class MenuScene extends Scene {
this.input.keyboard.on('keydown-ENTER', () => this.startGame()); this.input.keyboard.on('keydown-ENTER', () => this.startGame());
this.input.keyboard.on('keydown-SPACE', () => this.startGame()); this.input.keyboard.on('keydown-SPACE', () => this.startGame());
createButton(this, width / 2 - 75, height * 0.78, 'BOARD', () => this.showLeaderboard(), { createButton(this, width / 2, height * 0.69, 'DAILY CHALLENGE', () => this.startDaily(), {
width: 140, height: 44, fontSize: '11px', width: 280, height: 46, fontSize: '12px', bgColor: 0x854d0e, hoverColor: 0xa16207, strokeColor: 0xffd700,
}); });
createButton(this, width / 2 + 75, height * 0.78, 'BADGES', () => this.showAchievements(), {
width: 140, height: 44, fontSize: '11px', createButton(this, width / 2 - 75, height * 0.775, 'BOARD', () => this.showLeaderboard(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 + 75, height * 0.775, 'BADGES', () => this.showAchievements(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 - 75, height * 0.85, 'STATS', () => this.showStats(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 + 75, height * 0.85, 'SETTINGS', () => this.showSettings(), {
width: 140, height: 42, fontSize: '11px',
}); });
this.createMuteButton(); this.createMuteButton();
this.add.text(width / 2, height * 0.94, 'Web3 integration coming soon', { this.add.text(width / 2, height * 0.95, 'Web3 integration coming soon', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '9px', fontSize: '9px',
color: '#444', color: '#444',
@@ -71,7 +85,14 @@ export class MenuScene extends Scene {
sound.init(); sound.init();
sound.resume(); sound.resume();
sound.click(); sound.click();
this.scene.start('GameScene'); this.scene.start('GameScene', { mode: 'normal' });
}
startDaily() {
sound.init();
sound.resume();
sound.click();
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
} }
createMuteButton() { createMuteButton() {
@@ -111,7 +132,7 @@ export class MenuScene extends Scene {
{ rank: 1, addr: '0xMonad...Dev', score: 99999 }, { rank: 1, addr: '0xMonad...Dev', score: 99999 },
{ rank: 2, addr: '0xAlice...xyz', score: 87500 }, { rank: 2, addr: '0xAlice...xyz', score: 87500 },
{ rank: 3, addr: '0xBob...abc', score: 74200 }, { rank: 3, addr: '0xBob...abc', score: 74200 },
{ rank: 4, addr: '0xYou', score: parseInt(localStorage.getItem('naddie_best_score') || '0', 10) }, { rank: 4, addr: '0xYou', score: storage.getInt(KEYS.best, 0) },
]; ];
mock.forEach((entry, i) => { mock.forEach((entry, i) => {
const y = height * 0.32 + i * 55; const y = height * 0.32 + i * 55;
@@ -132,7 +153,7 @@ export class MenuScene extends Scene {
showAchievements() { showAchievements() {
const { width, height } = this.scale; const { width, height } = this.scale;
const all = AchievementsManager.getAll(); const all = AchievementsManager.getAll();
const unlocked = JSON.parse(localStorage.getItem('naddie_achievements_v1') || '{}'); const unlocked = storage.getJSON(KEYS.achievements, {}) || {};
const elements = []; const elements = [];
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100); const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100);
@@ -182,6 +203,109 @@ export class MenuScene extends Scene {
}); });
} }
_openModal(titleText, panelH = 480) {
const { width, height } = this.scale;
const elements = [];
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100)
.setInteractive();
const panel = this.add.rectangle(width / 2, height / 2, 420, panelH, 0x1a0533).setStrokeStyle(3, 0xa855f7).setDepth(101);
const title = this.add.text(width / 2, height / 2 - panelH / 2 + 34, titleText, {
fontFamily: '"Press Start 2P", monospace', fontSize: '15px', color: '#d8b4fe',
}).setOrigin(0.5).setDepth(102);
const close = this.add.text(width / 2 + 185, height / 2 - panelH / 2 + 22, 'X', {
fontFamily: '"Press Start 2P", monospace', fontSize: '18px', color: '#fff',
}).setOrigin(0.5).setDepth(102).setInteractive({ useHandCursor: true });
elements.push(overlay, panel, title, close);
const api = {
elements,
add: (obj) => { obj.setDepth(102); elements.push(obj); return obj; },
close: () => { sound.click(); elements.forEach((e) => e.destroy()); },
};
close.on('pointerdown', api.close);
return api;
}
showStats() {
const { width, height } = this.scale;
const s = StatsManager.load();
const m = this._openModal('STATS', 420);
const rows = [
['Games played', s.gamesPlayed],
['Total jumps', s.totalJumps],
['Enemies stomped', s.totalStomps],
['Blocks climbed', s.totalBlocks],
['Best combo', `x${(s.bestCombo || 1).toFixed(1)}`],
['Best score', storage.getInt(KEYS.best, 0)],
];
rows.forEach((row, i) => {
const y = height / 2 - 100 + i * 42;
m.add(this.add.text(width / 2 - 170, y, row[0], {
fontFamily: '"Press Start 2P", monospace', fontSize: '10px', color: '#d8b4fe',
}).setOrigin(0, 0.5));
m.add(this.add.text(width / 2 + 170, y, String(row[1]), {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#ffd700',
}).setOrigin(1, 0.5));
});
}
showSettings() {
const { width, height } = this.scale;
const m = this._openModal('SETTINGS', 460);
const cx = width / 2;
let baseY = height / 2 - 130;
// Volume stepper
m.add(this.add.text(cx, baseY, 'VOLUME', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const volText = this.add.text(cx, baseY + 34, `${Math.round(sound.getVolume() * 100)}%`, {
fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#ffd700',
}).setOrigin(0.5);
m.add(volText);
const stepVol = (delta) => {
sound.init();
sound.setVolume(Math.round((sound.getVolume() + delta) * 10) / 10);
if (sound.isMuted() && sound.getVolume() > 0) sound.setMuted(false);
volText.setText(`${Math.round(sound.getVolume() * 100)}%`);
sound.click();
};
m.add(createButton(this, cx - 90, baseY + 34, '-', () => stepVol(-0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
m.add(createButton(this, cx + 90, baseY + 34, '+', () => stepVol(0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
// Particle quality toggle
baseY += 96;
m.add(this.add.text(cx, baseY, 'PARTICLE QUALITY', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const currentQ = () => storage.getItem(KEYS.particleQuality, ParticleManager.resolveQuality(this));
const qBtn = createButton(this, cx, baseY + 34, currentQ().toUpperCase(), () => {
const next = currentQ() === 'low' ? 'high' : 'low';
storage.setItem(KEYS.particleQuality, next);
qBtn.label.setText(next.toUpperCase());
sound.click();
}, { width: 160, height: 38, fontSize: '12px' });
m.add(qBtn.bg); m.add(qBtn.label);
// Reset progress (two-step confirm)
baseY += 96;
let armed = false;
const resetBtn = createButton(this, cx, baseY + 10, 'RESET PROGRESS', () => {
if (!armed) {
armed = true;
resetBtn.label.setText('TAP AGAIN!');
resetBtn.bg.setFillStyle(0x7f1d1d);
return;
}
StatsManager.reset();
storage.removeItem(KEYS.best);
storage.removeItem(KEYS.achievements);
resetBtn.label.setText('DONE');
sound.click();
}, { width: 240, height: 42, fontSize: '12px', bgColor: 0x581c87, hoverColor: 0x7e22ce });
m.add(resetBtn.bg); m.add(resetBtn.label);
}
createAmbientParticles() { createAmbientParticles() {
const { width, height } = this.scale; const { width, height } = this.scale;
for (let i = 0; i < 40; i++) { for (let i = 0; i < 40; i++) {

45
src/utils/random.js Normal file
View File

@@ -0,0 +1,45 @@
/**
* Thin wrapper over Phaser's seedable RandomDataGenerator (Phaser.Math.RND).
* IMPORTANT: Phaser.Math.Between / FloatBetween use Math.random and are NOT
* seedable — gameplay spawning must use these helpers so a seed fully
* determines a run (Daily Challenge). Cosmetic randomness (particles) may keep
* Math.random.
*/
export const rng = {
seed(value) {
Phaser.Math.RND.sow([String(value)]);
},
/** Random float [0, 1). */
frac() {
return Phaser.Math.RND.frac();
},
/** Integer in [min, max] inclusive. */
between(min, max) {
return Phaser.Math.RND.between(min, max);
},
/** Float in [min, max). */
realBetween(min, max) {
return Phaser.Math.RND.realInRange(min, max);
},
/** Random element of an array. */
pick(arr) {
return Phaser.Math.RND.pick(arr);
},
/** +1 or -1. */
sign() {
return Phaser.Math.RND.frac() < 0.5 ? 1 : -1;
},
};
/** Returns today's date as a YYYY-MM-DD string (local time) for daily seeds. */
export function todaySeed() {
const d = new Date();
const mm = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
return `${d.getFullYear()}-${mm}-${dd}`;
}

92
src/utils/storage.js Normal file
View File

@@ -0,0 +1,92 @@
/**
* Safe localStorage wrapper. Falls back to an in-memory map when storage is
* unavailable (private mode, quota exceeded, sandboxed iframe) so the game
* never crashes on a storage call.
*/
export const KEYS = {
best: 'naddie_best_score',
muted: 'naddie_muted',
volume: 'naddie_volume',
tutorialSeen: 'naddie_tutorial_seen',
achievements: 'naddie_achievements_v1',
particleQuality: 'naddie_particle_quality',
stats: 'naddie_stats_v1',
dailyPrefix: 'naddie_daily_',
};
const memory = new Map();
let available = null;
function isAvailable() {
if (available !== null) return available;
try {
const testKey = '__naddie_test__';
window.localStorage.setItem(testKey, '1');
window.localStorage.removeItem(testKey);
available = true;
} catch (_) {
available = false;
}
return available;
}
export const storage = {
getItem(key, fallback = null) {
try {
if (isAvailable()) {
const v = window.localStorage.getItem(key);
return v === null ? fallback : v;
}
} catch (_) {}
return memory.has(key) ? memory.get(key) : fallback;
},
setItem(key, value) {
const str = String(value);
try {
if (isAvailable()) {
window.localStorage.setItem(key, str);
return;
}
} catch (_) {}
memory.set(key, str);
},
removeItem(key) {
try {
if (isAvailable()) window.localStorage.removeItem(key);
} catch (_) {}
memory.delete(key);
},
getInt(key, fallback = 0) {
const raw = this.getItem(key, null);
if (raw === null) return fallback;
const n = parseInt(raw, 10);
return Number.isFinite(n) ? n : fallback;
},
getFloat(key, fallback = 0) {
const raw = this.getItem(key, null);
if (raw === null) return fallback;
const n = parseFloat(raw);
return Number.isFinite(n) ? n : fallback;
},
getJSON(key, fallback = null) {
const raw = this.getItem(key, null);
if (raw === null) return fallback;
try {
return JSON.parse(raw);
} catch (_) {
return fallback;
}
},
setJSON(key, obj) {
try {
this.setItem(key, JSON.stringify(obj));
} catch (_) {}
},
};