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.
This commit is contained in:
2026-05-29 13:10:08 +07:00
parent 1062b2855a
commit fc1f12bb7e
13 changed files with 503 additions and 289 deletions

4
.gitignore vendored
View File

@@ -38,3 +38,7 @@ AGENTS.md
# Vercel CLI auth cache (workaround for Windows EXDEV issue) # Vercel CLI auth cache (workaround for Windows EXDEV issue)
.vercel-cli-config/ .vercel-cli-config/
# Local dev tooling
.claude/
dev-server.log

View File

@@ -50,9 +50,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

@@ -4,7 +4,10 @@ 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,12 +16,10 @@ 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 = Phaser.Math.Between(50, 120) * (Phaser.Math.RND.frac() < 0.5 ? 1 : -1);
this.body.immovable = true;
this.moveSpeed = Phaser.Math.Between(50, 120) * (Math.random() < 0.5 ? 1 : -1);
this.moveRange = Phaser.Math.Between(60, 160); this.moveRange = Phaser.Math.Between(60, 160);
} else if (type === 'breaking') { } else if (type === 'breaking') {
this.setTint(0x999999); this.setTint(0x999999);

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

@@ -1,8 +1,8 @@
import { GAME_WIDTH, GAME_HEIGHT } from '../config/game.config.js'; /**
* Drives WHEN boost visual effects play (state machine over the player's
const FLAME_COLORS = [0xffd700, 0xffaa00, 0xff6600, 0xff3300, 0xaa0000]; * power-up state). All actual particle allocation is delegated to the pooled
const WIND_COLORS = [0xffffff, 0xbbddff, 0x66aaff, 0x4477cc]; * ParticleManager (scene.particles), so this class never creates GameObjects.
*/
export class EffectsManager { export class EffectsManager {
constructor(scene) { constructor(scene) {
this.scene = scene; this.scene = scene;
@@ -11,228 +11,57 @@ export class EffectsManager {
this.springTrailUntil = 0; this.springTrailUntil = 0;
} }
get pm() {
return this.scene.particles;
}
update(player, delta) { update(player, delta) {
if (!player || !player.active) return; const pm = this.pm;
if (!pm) return;
if (!player || !player.active || player.state === 'dead') {
pm.stopFlow();
return;
}
const state = player.state; const state = player.state;
const now = this.scene.time.now; const now = this.scene.time.now;
const feetY = player.y + player.displayHeight / 2 - 6;
if ((state === 'rocket' || state === 'propeller') && Math.abs(player.body.velocity.y) > 150) { if (state === 'rocket' && Math.abs(player.body.velocity.y) > 150) {
if (state === 'rocket') this._spawnFlame(player); pm.flameAt(player.x, feetY);
else this._spawnWind(player); this._speedLines(delta);
} else if (state === 'propeller' && Math.abs(player.body.velocity.y) > 150) {
this.speedLineTimer += delta; pm.windAt(player.x, player.y);
if (this.speedLineTimer > 65) { this._speedLines(delta);
this.speedLineTimer = 0;
this._spawnSpeedLine();
}
} else if (state === 'normal' && now < this.springTrailUntil && player.body.velocity.y < -500) { } else if (state === 'normal' && now < this.springTrailUntil && player.body.velocity.y < -500) {
this._spawnSpringSpark(player); pm.springAt(player.x, feetY);
} else {
pm.stopFlow();
} }
if (this.lastState !== state) { if (this.lastState !== state) {
if ((this.lastState === 'rocket' || this.lastState === 'propeller') && state === 'normal') { if ((this.lastState === 'rocket' || this.lastState === 'propeller') && state === 'normal') {
this._spawnEndPuff(player); pm.puff(player.x, player.y);
} }
this.lastState = state; this.lastState = state;
} }
} }
_speedLines(delta) {
this.speedLineTimer += delta;
if (this.speedLineTimer > 65) {
this.speedLineTimer = 0;
this.pm.speedLine();
}
}
startBoost(player, type) { startBoost(player, type) {
this._spawnBurst(player, type); if (this.pm) this.pm.boostBurst(player.x, player.y, type);
if (type === 'spring') { if (type === 'spring') {
this.springTrailUntil = this.scene.time.now + 700; this.springTrailUntil = this.scene.time.now + 700;
} else { } else {
this.lastState = type; this.lastState = type;
} }
} }
_spawnSpringSpark(player) {
const baseY = player.y + player.displayHeight / 2 - 4;
for (let i = 0; i < 2; i++) {
const jitter = Phaser.Math.Between(-12, 12);
const color = Phaser.Utils.Array.GetRandom([0xffd700, 0xfff066, 0x22c55e, 0xa3e635]);
const size = Phaser.Math.Between(4, 9);
const p = this.scene.add.rectangle(player.x + jitter, baseY, size, size, color, 0.9).setDepth(-2);
this.scene.tweens.add({
targets: p,
x: p.x + Phaser.Math.Between(-25, 25),
y: p.y + Phaser.Math.Between(40, 90),
scaleX: 0,
scaleY: 0,
alpha: 0,
angle: Phaser.Math.Between(-180, 180),
duration: Phaser.Math.Between(400, 600),
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
}
_spawnFlame(player) {
const baseY = player.y + player.displayHeight / 2 - 6;
const count = 2;
for (let i = 0; i < count; i++) {
const jitter = Phaser.Math.Between(-9, 9);
const color = Phaser.Utils.Array.GetRandom(FLAME_COLORS);
const size = Phaser.Math.Between(8, 16);
const p = this.scene.add.rectangle(player.x + jitter, baseY, size, size, color, 0.95)
.setDepth(-2);
this.scene.tweens.add({
targets: p,
x: p.x + Phaser.Math.Between(-12, 12),
y: p.y + Phaser.Math.Between(35, 80),
scaleX: 0,
scaleY: 0,
alpha: 0,
angle: Phaser.Math.Between(-180, 180),
duration: Phaser.Math.Between(380, 650),
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
// Spark
if (Math.random() < 0.4) {
const spark = this.scene.add.circle(player.x + Phaser.Math.Between(-5, 5), baseY, 2, 0xffffaa, 1)
.setDepth(-1);
this.scene.tweens.add({
targets: spark,
x: spark.x + Phaser.Math.Between(-25, 25),
y: spark.y + Phaser.Math.Between(50, 110),
alpha: 0,
duration: 500,
onComplete: () => spark.destroy(),
});
}
}
_spawnWind(player) {
const count = 2;
for (let i = 0; i < count; i++) {
const angle = Phaser.Math.Between(0, 360);
const radius = Phaser.Math.Between(20, 40);
const startX = player.x + Math.cos(angle * Math.PI / 180) * radius;
const startY = player.y + Math.sin(angle * Math.PI / 180) * radius * 0.5;
const color = Phaser.Utils.Array.GetRandom(WIND_COLORS);
const len = Phaser.Math.Between(6, 14);
const p = this.scene.add.rectangle(startX, startY, len, 2, color, 0.75)
.setDepth(-2);
this.scene.tweens.add({
targets: p,
x: p.x + Phaser.Math.Between(-15, 15),
y: p.y + Phaser.Math.Between(30, 80),
alpha: 0,
scaleX: 0.3,
duration: Phaser.Math.Between(300, 550),
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
}
_spawnSpeedLine() {
const cam = this.scene.cameras.main;
const side = Math.random() < 0.5 ? 'left' : 'right';
const x = side === 'left'
? Phaser.Math.Between(0, 40)
: Phaser.Math.Between(GAME_WIDTH - 40, GAME_WIDTH);
const y = cam.scrollY + Phaser.Math.Between(0, GAME_HEIGHT);
const len = Phaser.Math.Between(30, 70);
const line = this.scene.add.rectangle(x, y, 2, len, 0xffffff, 0.45).setDepth(-1);
this.scene.tweens.add({
targets: line,
y: line.y + Phaser.Math.Between(150, 280),
alpha: 0,
scaleY: 0.5,
duration: Phaser.Math.Between(280, 450),
ease: 'Quad.easeIn',
onComplete: () => line.destroy(),
});
}
_spawnBurst(player, type) {
const cx = player.x;
const cy = player.y;
let colorRing, colorSpark;
if (type === 'rocket') { colorRing = 0xff6600; colorSpark = 0xffd700; }
else if (type === 'spring') { colorRing = 0x22c55e; colorSpark = 0xffd700; }
else { colorRing = 0x88ccff; colorSpark = 0xffffff; }
// Expanding ring
const ring = this.scene.add.circle(cx, cy, 14, undefined, 0)
.setStrokeStyle(4, colorRing, 0.9)
.setDepth(-1);
this.scene.tweens.add({
targets: ring,
scale: 6,
alpha: 0,
duration: 380,
ease: 'Quad.easeOut',
onComplete: () => ring.destroy(),
});
// Second slower ring
const ring2 = this.scene.add.circle(cx, cy, 10, undefined, 0)
.setStrokeStyle(3, colorSpark, 0.7)
.setDepth(-1);
this.scene.tweens.add({
targets: ring2,
scale: 4,
alpha: 0,
duration: 520,
ease: 'Quad.easeOut',
onComplete: () => ring2.destroy(),
});
// Radial sparks
const sparkCount = type === 'rocket' ? 16 : 10;
for (let i = 0; i < sparkCount; i++) {
const angle = (i / sparkCount) * Math.PI * 2 + Phaser.Math.FloatBetween(-0.1, 0.1);
const dist = Phaser.Math.Between(50, 110);
const p = this.scene.add.rectangle(cx, cy, 5, 5, colorSpark, 1).setDepth(0);
this.scene.tweens.add({
targets: p,
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist,
scaleX: 0,
scaleY: 0,
alpha: 0,
duration: 500,
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
// Brief camera punch
if (type === 'rocket') {
this.scene.cameras.main.shake(120, 0.006);
} else if (type === 'spring') {
this.scene.cameras.main.shake(60, 0.002);
} else {
this.scene.cameras.main.shake(80, 0.003);
}
}
_spawnEndPuff(player) {
const cx = player.x;
const cy = player.y;
for (let i = 0; i < 8; i++) {
const angle = Phaser.Math.FloatBetween(0, Math.PI * 2);
const dist = Phaser.Math.Between(30, 60);
const size = Phaser.Math.Between(8, 16);
const gray = Phaser.Utils.Array.GetRandom([0x888888, 0xaaaaaa, 0x666666]);
const p = this.scene.add.rectangle(cx, cy, size, size, gray, 0.6).setDepth(-2);
this.scene.tweens.add({
targets: p,
x: cx + Math.cos(angle) * dist,
y: cy + Math.sin(angle) * dist - 15,
scaleX: 1.5,
scaleY: 1.5,
alpha: 0,
duration: Phaser.Math.Between(500, 800),
ease: 'Quad.easeOut',
onComplete: () => p.destroy(),
});
}
}
} }

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

@@ -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) {
@@ -191,16 +192,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

@@ -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

@@ -5,7 +5,9 @@ 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 { EffectsManager } from '../managers/EffectsManager.js';
import { ParticleManager } from '../managers/ParticleManager.js';
import { sound } from '../managers/SoundManager.js'; import { sound } from '../managers/SoundManager.js';
import { storage, KEYS } from '../utils/storage.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 {
@@ -25,6 +27,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();
@@ -74,15 +78,36 @@ export class GameScene extends Scene {
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);
// 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. // Camera: latch player at trigger line going up, free movement otherwise.
const targetScrollY = this.player.y - this.cameraTriggerY; const targetScrollY = this.player.y - this.cameraTriggerY;
if (targetScrollY < this.cameras.main.scrollY) { if (targetScrollY < this.cameras.main.scrollY) {
@@ -119,7 +144,12 @@ export class GameScene extends Scene {
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) {
@@ -284,7 +314,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;
@@ -331,7 +361,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();
@@ -420,52 +450,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

@@ -2,6 +2,7 @@ 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 { createButton } from '../utils/ui.js'; import { createButton } from '../utils/ui.js';
import { storage, KEYS } from '../utils/storage.js';
export class MenuScene extends Scene { export class MenuScene extends Scene {
constructor() { constructor() {
@@ -111,7 +112,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 +133,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);

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 (_) {}
},
};