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 auth cache (workaround for Windows EXDEV issue)
|
||||||
.vercel-cli-config/
|
.vercel-cli-config/
|
||||||
|
|
||||||
|
# Local dev tooling
|
||||||
|
.claude/
|
||||||
|
dev-server.log
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
26
src/main.js
26
src/main.js
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
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 { 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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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