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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -38,3 +38,7 @@ AGENTS.md
|
||||
|
||||
# Vercel CLI auth cache (workaround for Windows EXDEV issue)
|
||||
.vercel-cli-config/
|
||||
|
||||
# Local dev tooling
|
||||
.claude/
|
||||
dev-server.log
|
||||
|
||||
@@ -50,9 +50,8 @@ export const SCORE = {
|
||||
|
||||
export const PHYSICS = {
|
||||
stompTolerance: 12,
|
||||
coyoteTime: 110,
|
||||
jumpBufferTime: 130,
|
||||
variableJumpCutoff: 0.45,
|
||||
landTolerance: 10,
|
||||
maxFallSpeed: 950, // terminal velocity cap — prevents landing tunneling
|
||||
};
|
||||
|
||||
export const POWERUP_DURATION = {
|
||||
|
||||
@@ -4,7 +4,10 @@ export class Platform extends Physics.Arcade.Sprite {
|
||||
constructor(scene, x, y, type = 'stable') {
|
||||
super(scene, x, y, 'platform');
|
||||
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.breakingState = 0;
|
||||
@@ -13,12 +16,10 @@ export class Platform extends Physics.Arcade.Sprite {
|
||||
this.startX = x;
|
||||
this.glowTween = null;
|
||||
|
||||
if (type === 'moving') {
|
||||
this.body.destroy();
|
||||
this.body = new Phaser.Physics.Arcade.Body(scene.physics.world, this);
|
||||
this.body.allowGravity = false;
|
||||
this.body.immovable = true;
|
||||
this.moveSpeed = Phaser.Math.Between(50, 120) * (Math.random() < 0.5 ? 1 : -1);
|
||||
if (isMoving) {
|
||||
this.body.setAllowGravity(false);
|
||||
this.body.setImmovable(true);
|
||||
this.moveSpeed = Phaser.Math.Between(50, 120) * (Phaser.Math.RND.frac() < 0.5 ? 1 : -1);
|
||||
this.moveRange = Phaser.Math.Between(60, 160);
|
||||
} else if (type === 'breaking') {
|
||||
this.setTint(0x999999);
|
||||
|
||||
26
src/main.js
26
src/main.js
@@ -22,9 +22,33 @@ const config = {
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
fps: {
|
||||
target: 60,
|
||||
min: 30,
|
||||
},
|
||||
disableContextMenu: true,
|
||||
scene: [BootScene, MenuScene, GameScene, GameOverScene],
|
||||
pixelArt: false,
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { sound } from './SoundManager.js';
|
||||
|
||||
const STORAGE_KEY = 'naddie_achievements_v1';
|
||||
import { storage, KEYS } from '../utils/storage.js';
|
||||
|
||||
const DEFS = [
|
||||
{ id: 'genesis_block', title: 'Genesis Block', desc: 'Land on a gold platform' },
|
||||
@@ -33,18 +32,11 @@ export class AchievementsManager {
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
return storage.getJSON(KEYS.achievements, {}) || {};
|
||||
}
|
||||
|
||||
_save() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.unlocked));
|
||||
} catch (_) {}
|
||||
storage.setJSON(KEYS.achievements, this.unlocked);
|
||||
}
|
||||
|
||||
isUnlocked(id) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { GAME_WIDTH, GAME_HEIGHT } from '../config/game.config.js';
|
||||
|
||||
const FLAME_COLORS = [0xffd700, 0xffaa00, 0xff6600, 0xff3300, 0xaa0000];
|
||||
const WIND_COLORS = [0xffffff, 0xbbddff, 0x66aaff, 0x4477cc];
|
||||
|
||||
/**
|
||||
* 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;
|
||||
@@ -11,228 +11,57 @@ export class EffectsManager {
|
||||
this.springTrailUntil = 0;
|
||||
}
|
||||
|
||||
get pm() {
|
||||
return this.scene.particles;
|
||||
}
|
||||
|
||||
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 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') this._spawnFlame(player);
|
||||
else this._spawnWind(player);
|
||||
|
||||
this.speedLineTimer += delta;
|
||||
if (this.speedLineTimer > 65) {
|
||||
this.speedLineTimer = 0;
|
||||
this._spawnSpeedLine();
|
||||
}
|
||||
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) {
|
||||
this._spawnSpringSpark(player);
|
||||
pm.springAt(player.x, feetY);
|
||||
} else {
|
||||
pm.stopFlow();
|
||||
}
|
||||
|
||||
if (this.lastState !== state) {
|
||||
if ((this.lastState === 'rocket' || this.lastState === 'propeller') && state === 'normal') {
|
||||
this._spawnEndPuff(player);
|
||||
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) {
|
||||
this._spawnBurst(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;
|
||||
}
|
||||
}
|
||||
|
||||
_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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
225
src/managers/ParticleManager.js
Normal file
225
src/managers/ParticleManager.js
Normal 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 = {};
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SCORE } from '../config/game.config.js';
|
||||
import { storage, KEYS } from '../utils/storage.js';
|
||||
|
||||
export class ScoreManager {
|
||||
constructor(scene) {
|
||||
@@ -191,16 +192,14 @@ export class ScoreManager {
|
||||
}
|
||||
|
||||
updateBestDisplay() {
|
||||
const raw = localStorage.getItem('naddie_best_score');
|
||||
const best = raw ? (parseInt(raw, 10) || 0) : 0;
|
||||
const best = storage.getInt(KEYS.best, 0);
|
||||
this.hudBest.setText(`Best: ${best}`);
|
||||
}
|
||||
|
||||
saveBest() {
|
||||
const raw = localStorage.getItem('naddie_best_score');
|
||||
const best = raw ? (parseInt(raw, 10) || 0) : 0;
|
||||
const best = storage.getInt(KEYS.best, 0);
|
||||
if (this.score > best) {
|
||||
localStorage.setItem('naddie_best_score', String(this.score));
|
||||
storage.setItem(KEYS.best, this.score);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { storage, KEYS } from '../utils/storage.js';
|
||||
|
||||
const MAX_GAIN = 0.4;
|
||||
|
||||
class SoundManager {
|
||||
constructor() {
|
||||
this.ctx = null;
|
||||
this.master = null;
|
||||
this.muted = false;
|
||||
this.musicNodes = null;
|
||||
|
||||
const raw = localStorage.getItem('naddie_muted');
|
||||
this.muted = raw === '1';
|
||||
this.muted = storage.getItem(KEYS.muted, '0') === '1';
|
||||
this.volume = Phaser.Math.Clamp(storage.getFloat(KEYS.volume, 1), 0, 1);
|
||||
}
|
||||
|
||||
init() {
|
||||
@@ -15,7 +18,7 @@ class SoundManager {
|
||||
if (!AC) return;
|
||||
this.ctx = new AC();
|
||||
this.master = this.ctx.createGain();
|
||||
this.master.gain.value = 0.4;
|
||||
this.master.gain.value = this._effectiveGain();
|
||||
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) {
|
||||
this.muted = value;
|
||||
localStorage.setItem('naddie_muted', value ? '1' : '0');
|
||||
if (this.master) {
|
||||
this.master.gain.value = value ? 0 : 0.4;
|
||||
}
|
||||
storage.setItem(KEYS.muted, value ? '1' : '0');
|
||||
this._applyGain();
|
||||
}
|
||||
|
||||
isMuted() {
|
||||
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) {
|
||||
if (!this.ctx || this.muted) return;
|
||||
const osc = this.ctx.createOscillator();
|
||||
|
||||
@@ -18,9 +18,46 @@ export class BootScene extends Scene {
|
||||
|
||||
create() {
|
||||
this.createBackgrounds();
|
||||
this.createParticleTextures();
|
||||
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() {
|
||||
const gridW = 512;
|
||||
const gridH = 512;
|
||||
|
||||
@@ -5,7 +5,9 @@ import { PlatformManager } from '../managers/PlatformManager.js';
|
||||
import { ScoreManager } from '../managers/ScoreManager.js';
|
||||
import { AchievementsManager } from '../managers/AchievementsManager.js';
|
||||
import { EffectsManager } from '../managers/EffectsManager.js';
|
||||
import { ParticleManager } from '../managers/ParticleManager.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';
|
||||
|
||||
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)
|
||||
.setScrollFactor(0).setDepth(-9);
|
||||
|
||||
this.particles = new ParticleManager(this);
|
||||
|
||||
this.createGweiParticles();
|
||||
|
||||
this.cursors = this.input.keyboard.createCursorKeys();
|
||||
@@ -74,15 +78,36 @@ export class GameScene extends Scene {
|
||||
|
||||
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.showTouchIndicators();
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if (this._onHidden) {
|
||||
this.game.events.off('hidden', this._onHidden);
|
||||
this._onHidden = null;
|
||||
}
|
||||
}
|
||||
|
||||
update(time, delta) {
|
||||
if (this.isGameOver || this.isPaused) return;
|
||||
|
||||
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.
|
||||
const targetScrollY = this.player.y - this.cameraTriggerY;
|
||||
if (targetScrollY < this.cameras.main.scrollY) {
|
||||
@@ -119,7 +144,12 @@ export class GameScene extends Scene {
|
||||
platformCollisionFilter(player, platform) {
|
||||
if (!platform || !platform.body) 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) {
|
||||
@@ -284,7 +314,7 @@ export class GameScene extends Scene {
|
||||
}
|
||||
|
||||
maybeShowTutorial() {
|
||||
if (localStorage.getItem('naddie_tutorial_seen') === '1') return;
|
||||
if (storage.getItem(KEYS.tutorialSeen, '0') === '1') return;
|
||||
|
||||
const { width, height } = this.scale;
|
||||
this.isPaused = true;
|
||||
@@ -331,7 +361,7 @@ export class GameScene extends Scene {
|
||||
|
||||
startBtn.on('pointerdown', () => {
|
||||
sound.click();
|
||||
localStorage.setItem('naddie_tutorial_seen', '1');
|
||||
storage.setItem(KEYS.tutorialSeen, '1');
|
||||
container.destroy();
|
||||
this.isPaused = false;
|
||||
this.physics.resume();
|
||||
@@ -420,52 +450,14 @@ export class GameScene extends Scene {
|
||||
}
|
||||
|
||||
createJumpParticles(x, y) {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
this.particles.burstJump(x, y);
|
||||
}
|
||||
|
||||
createPowerupParticles(x, y) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
this.particles.burstPowerup(x, y);
|
||||
}
|
||||
|
||||
createExplosion(x, y) {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
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(),
|
||||
});
|
||||
}
|
||||
this.particles.burstExplosion(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Scene } from 'phaser';
|
||||
import { sound } from '../managers/SoundManager.js';
|
||||
import { AchievementsManager } from '../managers/AchievementsManager.js';
|
||||
import { createButton } from '../utils/ui.js';
|
||||
import { storage, KEYS } from '../utils/storage.js';
|
||||
|
||||
export class MenuScene extends Scene {
|
||||
constructor() {
|
||||
@@ -111,7 +112,7 @@ export class MenuScene extends Scene {
|
||||
{ rank: 1, addr: '0xMonad...Dev', score: 99999 },
|
||||
{ rank: 2, addr: '0xAlice...xyz', score: 87500 },
|
||||
{ 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) => {
|
||||
const y = height * 0.32 + i * 55;
|
||||
@@ -132,7 +133,7 @@ export class MenuScene extends Scene {
|
||||
showAchievements() {
|
||||
const { width, height } = this.scale;
|
||||
const all = AchievementsManager.getAll();
|
||||
const unlocked = JSON.parse(localStorage.getItem('naddie_achievements_v1') || '{}');
|
||||
const unlocked = storage.getJSON(KEYS.achievements, {}) || {};
|
||||
|
||||
const elements = [];
|
||||
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100);
|
||||
|
||||
92
src/utils/storage.js
Normal file
92
src/utils/storage.js
Normal 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 (_) {}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user