Compare commits

...

15 Commits

Author SHA1 Message Date
be1933f3ba New mechanics: coins + magnet + Gas Limit shield
Coins: collectible  dropped in clusters (lines/arcs) above platforms; collected on overlap, banked into a persistent wallet at game over, shown in HUD and on the GameOver screen. Magnet power-up pulls nearby coins toward the player for 6s (HUD duration bar). Gas Limit shield power-up grants a cyan aura that absorbs one enemy hit instead of dying, then breaks. New procedural textures (coin/magnet/shield), spawn weights, durations, sound, and stats (coins collected, GWEI wallet) wired through ScoreManager/StatsManager/Settings. Verified via in-browser sim: coins spawn, magnet pull 100px to 10px, collect increments GWEI, shield absorbs a hit (no game over) while no shield = death.
2026-06-01 03:01:01 +07:00
7c6a792212 Fix jittery player animation on fast vertical movement
Camera was repositioned in update() (before the physics step synced the sprite), so it lagged the player by one frame; on fast ascent/rocket that read as the character trembling and breaking up. Move camera follow to a POST_UPDATE (lateUpdate) handler that runs after the sprite position is synced. Verified on-screen jitter while the camera is latched is now 0px (was ~one frame of vertical speed). Also prevent squash-and-stretch tweens from stacking on rapid bounces (stop any in-flight squash before starting a new one and on powerup/death transitions), which had made the sprite scale pop.
2026-06-01 01:53:33 +07:00
9c07c52d34 Fix moving-platform and enemy jitter at boundaries
Moving platforms reversed velocity whenever |x-startX|>range, which stays true for several frames after the flip, so direction flipped every frame and the platform vibrated in place. Now reverse only when past the edge AND still heading outward. Same directional guard for enemy 'bug' patrol. Added a 6px deadzone to 'mev_bot' so it stops shaking when aligned with the player. Verified: a moving platform now flips ~4x over 360 frames and travels the full range symmetrically.
2026-06-01 01:40:44 +07:00
22dec51e93 Revert "Selectable backgrounds with live preview in settings"
This reverts commit 8cfe0b8a30.
2026-06-01 01:37:15 +07:00
d7ae3a5c7e Revert "Beautiful Canvas2D backgrounds (gradients, glows, bokeh)"
This reverts commit b7fba447dc.
2026-06-01 01:37:15 +07:00
b7fba447dc Beautiful Canvas2D backgrounds (gradients, glows, bokeh)
Replace the flat line-art backgrounds with 6 rich, full-screen artworks
painted via the Canvas2D API (linear/radial gradients, screen/lighter
blending, soft glows, bokeh, stars) and registered as Phaser canvas textures:
- Nebula: colorful deep-space clouds (default)
- Aurora: northern lights over a starfield
- Sunset: synthwave sky with glowing sun + perspective grid
- Ocean: sunlit underwater depths with caustics and bubbles
- Dreamscape: soft pastel bokeh
- Monad: signature purple cosmos

- Backgrounds are now fixed full-screen images (no tilePositionY scroll,
  which would seam a gradient); motion comes from platforms + gwei particles
- Settings background preview switched to a scaled image showing the whole art
- Verified all 6 in-browser (menu, in-game, settings preview), no console errors
2026-06-01 01:23:40 +07:00
8cfe0b8a30 Selectable backgrounds with live preview in settings
- 6 procedural, vertically-tiling backgrounds generated in BootScene:
  Grid, Hex Nodes, Starfield, Synthwave, Circuit, Void
- config/backgrounds.js registry + utils/background.js helper; selection
  persisted via storage (KEYS.background)
- Menu / Game / GameOver use the selected background texture
- Settings overlay gains a BACKGROUND selector: in-panel live preview tile
  + < name > cycling, updates the menu background live and persists choice
- Fix: stepper/arrow buttons now add both bg and label to the modal so the
  -, +, <, > glyphs render above the panel
2026-06-01 01:10:40 +07:00
6f5b4d83f7 Phase B: new content — daily challenge, enemies, platform, settings, stats
B1 Seeded RNG + Daily Challenge
- utils/random.js wraps Phaser seedable RND (Between/frac are NOT seedable)
- All gameplay spawning (PlatformManager, Platform, Enemy) uses seeded rng
- GameScene reads mode/seed in init and seeds the run; daily shows a HUD badge
  and keeps a per-day best (daily_<YYYYMMDD>); MenuScene DAILY button;
  GameOver RETRY preserves mode and shows today's best
- Verified: same seed -> identical layout, different seed -> different

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

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

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

B5 Difficulty tuning via UNLOCK thresholds and rebalanced spawn rates

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

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

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

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

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

Fix:
- Replace startFollow with manual upward-only camera tracking that
  matches the player exactly (doodle-jump standard behavior)
- Disable camera roundPixels so antialiasing actually smooths motion
- Round bg tilePositionY to integer to avoid grid texture shimmer
- Slightly thin out boost effects (flame 3->2/frame, speed line 40->65ms)
  to keep frame budget headroom on weaker devices
2026-05-23 18:50:58 +07:00
de1a3fcf56 Boost effects rewrite — flame, wind, speed lines, burst, puff
- EffectsManager handles all powerup visual feedback in one place
- Replace circle trail with proper effects per type:
  * Rocket: yellow->red flame particles from feet + occasional bright spark
  * Propeller: white/blue wind streaks around player
  * Spring: gold/green spark trail while ascending fast
- Boost start burst: two expanding rings + radial sparks + camera shake
  (orange/gold for rocket, cyan for propeller, green/gold for spring)
- Boost end puff: gray smoke cloud spreading from player
- Screen edge speed lines during boost (alternate left/right edges)
- Player no longer tilts with horizontal input during boost; rocket
  stays rigid upright with tiny lean, propeller has gentle sinusoidal wobble
2026-05-23 18:43:53 +07:00
d509f1df4a Sprint 3: architecture — UI utility, achievements menu
- Extract createButton/pixelText helpers to src/utils/ui.js with
  sensible defaults and per-call option overrides
- MenuScene now shows BADGES button opening the achievement panel
  (10 entries, count of unlocked, star icons for completed)
- GameOverScene buttons migrated to shared utility, removing duplicate
  hover/click handlers
- Smaller LEADERBOARD button to make room for BADGES alongside
2026-05-23 17:00:56 +07:00
57f9e2f282 Sprint 2: gameplay — achievements, score popups, new enemy, polish
- AchievementsManager with 10 unlockables and toast popups
  (Genesis Block, Chain Reaction x3, Bug Hunter, First Flight,
   Liftoff, Power Trip, Survivor 500, Skyscraper 1000,
   Speedrun 100/60s, Gas Baron 50k)
- Score popups: +N text floats above player on every landing
  (gold and larger for genesis platforms)
- Powerup duration bar in HUD bottom, color-coded per power-up,
  uses scaleX for smooth depletion animation
- New enemy: Failed Tx, falls from above with sine drift, unlocks
  at difficulty > 800, can be stomped, tinted red
- Dynamic background: dark cosmic overlay alpha scales with height
  (max 0.5 at very high altitudes)
- Achievement hooks integrated into ScoreManager, GameScene
- Combo no longer resets if combo was already 0 (was triggering log spam)
2026-05-23 16:58:36 +07:00
fd93da0a71 Sprint 1: polish — sound, pause, tutorial, touch UI
- SoundManager via Web Audio API (no asset files needed) with procedural
  SFX for jump, spring, powerup, stomp, break, death, milestone, new-best
- Sound persists mute state in localStorage; mute button on Menu and Game
- Pause system: ESC key or onscreen pause button, modal overlay with
  Resume and Main Menu options, physics correctly paused/resumed
- First-run tutorial overlay explaining controls and platform types,
  dismissed and remembered via localStorage flag
- Touch indicator hints fade after 3.5s on touch devices only
- Menu start triggers AudioContext initialization (browser autoplay rules)
- GameOverScene supports ENTER/SPACE shortcut for retry, NEW BEST text
  now pulses, sounds fire on each transition
2026-05-23 16:55:05 +07:00
23 changed files with 2101 additions and 262 deletions

7
.gitignore vendored
View File

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

View File

@@ -13,9 +13,10 @@ export const PLATFORM_GAP_MIN = 60;
export const PLATFORM_GAP_MAX = 120; export const PLATFORM_GAP_MAX = 120;
export const SPAWN_RATES = { export const SPAWN_RATES = {
stable: 0.60, stable: 0.55,
moving: 0.20, moving: 0.18,
breaking: 0.15, breaking: 0.12,
reorg: 0.10,
genesis: 0.05, genesis: 0.05,
}; };
@@ -23,6 +24,15 @@ export const POWERUP_RATES = {
spring: 0.04, spring: 0.04,
propeller: 0.025, propeller: 0.025,
rocket: 0.012, rocket: 0.012,
magnet: 0.02,
shield: 0.025,
};
export const COIN = {
spawnChance: 0.42, // per platform, chance to drop a cluster of coins
value: 1, // $GWEI per coin
magnetRadius: 150, // px within which the magnet pulls coins
magnetSpeed: 560, // px/s pull speed
}; };
export const ENEMY_RATES = { export const ENEMY_RATES = {
@@ -37,6 +47,13 @@ export const DIFFICULTY = {
maxEnemyRate: 0.30, maxEnemyRate: 0.30,
}; };
// Height (px climbed) at which harder content starts appearing.
export const UNLOCK = {
reorg: 600,
failedTx: 800,
mevBot: 1500,
};
export const SCORE = { export const SCORE = {
basePoints: 10, basePoints: 10,
genesisBonus: 50, genesisBonus: 50,
@@ -50,12 +67,12 @@ 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 = {
propeller: 3500, propeller: 3500,
rocket: 3000, rocket: 3000,
magnet: 6000,
}; };

62
src/entities/Coin.js Normal file
View File

@@ -0,0 +1,62 @@
import { Physics } from 'phaser';
import { COIN } from '../config/game.config.js';
export class Coin extends Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'coin');
scene.add.existing(this);
scene.physics.add.existing(this);
this.setScale(0.7);
this.body.allowGravity = false;
this.body.setCircle(this.width / 2);
this.collected = false;
// subtle idle pulse
this.pulse = scene.tweens.add({
targets: this,
scaleX: 0.78,
scaleY: 0.78,
duration: 600,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
preUpdate(time, delta) {
super.preUpdate(time, delta);
if (this.collected) return;
const p = this.scene.player;
if (p && p.active && p.magnetActive) {
const dist = Phaser.Math.Distance.Between(this.x, this.y, p.x, p.y);
if (dist < COIN.magnetRadius) {
const ang = Phaser.Math.Angle.Between(this.x, this.y, p.x, p.y);
const step = COIN.magnetSpeed * (delta / 1000);
this.x += Math.cos(ang) * step;
this.y += Math.sin(ang) * step;
}
}
}
collect() {
if (this.collected) return false;
this.collected = true;
if (this.pulse) { this.pulse.stop(); this.pulse = null; }
if (this.body) this.body.enable = false;
this.scene.tweens.add({
targets: this,
scale: 1.4,
alpha: 0,
duration: 160,
ease: 'Quad.easeOut',
onComplete: () => this.destroy(),
});
return true;
}
destroy(fromScene) {
if (this.pulse) { this.pulse.stop(); this.pulse = null; }
super.destroy(fromScene);
}
}

View File

@@ -1,5 +1,6 @@
import { Physics } from 'phaser'; import { Physics } from 'phaser';
import { GAME_WIDTH } from '../config/game.config.js'; import { GAME_WIDTH } from '../config/game.config.js';
import { rng } from '../utils/random.js';
export class Enemy extends Physics.Arcade.Sprite { export class Enemy extends Physics.Arcade.Sprite {
constructor(scene, x, y, type = 'bug') { constructor(scene, x, y, type = 'bug') {
@@ -8,29 +9,77 @@ export class Enemy extends Physics.Arcade.Sprite {
scene.physics.add.existing(this); scene.physics.add.existing(this);
this.enemyType = type; this.enemyType = type;
this.setScale(0.7);
this.body.allowGravity = false; this.body.allowGravity = false;
this.body.setSize(90, 80);
this.body.setOffset(35, 30);
if (type === 'bug') { if (type === 'bug') {
this.speed = Phaser.Math.Between(60, 140) * (Math.random() < 0.5 ? 1 : -1); this.setScale(0.7);
this.body.setSize(this.width * 0.7, this.height * 0.7);
this.body.setOffset(this.width * 0.15, this.height * 0.15);
this.speed = rng.between(60, 140) * rng.sign();
this.startX = x; this.startX = x;
this.patrolRange = Phaser.Math.Between(80, 200); this.patrolRange = rng.between(80, 200);
} else if (type === 'failed_tx') {
this.setScale(0.55);
this.setTint(0xef4444);
this.body.setSize(this.width * 0.6, this.height * 0.6);
this.body.setOffset(this.width * 0.2, this.height * 0.2);
this.fallSpeed = rng.between(80, 140);
this.driftAmplitude = rng.between(20, 60);
this.driftFreq = rng.realBetween(0.001, 0.003);
this.spawnTime = scene.time.now;
this.spawnX = x;
} else if (type === 'mev_bot') {
// Homing chaser: eases toward the player's X. Stompable like the rest.
this.setScale(0.6);
this.setTint(0x22d3ee);
this.body.setSize(this.width * 0.65, this.height * 0.65);
this.body.setOffset(this.width * 0.18, this.height * 0.18);
this.homeSpeed = rng.between(70, 120);
this.bobAmp = rng.between(4, 10);
this.baseY = y;
this.spawnTime = scene.time.now;
} }
} }
preUpdate(time, delta) { preUpdate(time, delta) {
super.preUpdate(time, delta); super.preUpdate(time, delta);
const dt = delta / 1000;
if (this.enemyType === 'bug') { if (this.enemyType === 'bug') {
this.x += this.speed * (delta / 1000); this.x += this.speed * dt;
if (Math.abs(this.x - this.startX) > this.patrolRange) { // Reverse only when past the patrol edge AND still heading outward,
this.speed *= -1; // otherwise the flip re-triggers each frame and the bug jitters.
this.setFlipX(this.speed < 0); const dx = this.x - this.startX;
if (dx > this.patrolRange && this.speed > 0) {
this.speed = -Math.abs(this.speed);
this.setFlipX(true);
} else if (dx < -this.patrolRange && this.speed < 0) {
this.speed = Math.abs(this.speed);
this.setFlipX(false);
} }
// Wrap
if (this.x < -60) this.x = GAME_WIDTH + 60; if (this.x < -60) this.x = GAME_WIDTH + 60;
if (this.x > GAME_WIDTH + 60) this.x = -60; if (this.x > GAME_WIDTH + 60) this.x = -60;
} else if (this.enemyType === 'failed_tx') {
this.y += this.fallSpeed * dt;
const t = time - this.spawnTime;
this.x = this.spawnX + Math.sin(t * this.driftFreq) * this.driftAmplitude;
this.x = Phaser.Math.Clamp(this.x, 30, GAME_WIDTH - 30);
this.setAngle(Math.sin(t * 0.005) * 15);
} else if (this.enemyType === 'mev_bot') {
const player = this.scene.player;
if (player && player.active) {
// Deadzone: stop chasing when roughly aligned so Math.sign doesn't
// flip every frame and make the bot vibrate around the player column.
const diff = player.x - this.x;
if (Math.abs(diff) > 6) {
const dir = Math.sign(diff);
this.x += dir * this.homeSpeed * dt;
this.setFlipX(dir < 0);
}
}
this.x = Phaser.Math.Clamp(this.x, 24, GAME_WIDTH - 24);
const t = time - this.spawnTime;
this.y = this.baseY + Math.sin(t * 0.004) * this.bobAmp;
} }
} }
} }

33
src/entities/Magnet.js Normal file
View File

@@ -0,0 +1,33 @@
import { Physics } from 'phaser';
export class Magnet extends Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'magnet');
scene.add.existing(this);
scene.physics.add.existing(this, true);
this.setScale(0.8);
this.body.setSize(this.width * 0.8, this.height * 0.8);
this.body.setOffset(this.width * 0.1, this.height * 0.1);
this.consumed = false;
this.floatTween = scene.tweens.add({
targets: this, y: y - 6, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
onPlayerTouch(player) {
if (this.consumed) return false;
this.consumed = true;
this.body.enable = false;
this.setVisible(false);
player.startMagnet();
this.destroy();
return true;
}
destroy(fromScene) {
if (this.floatTween) { this.floatTween.stop(); this.floatTween = null; }
super.destroy(fromScene);
}
}

View File

@@ -1,10 +1,14 @@
import { Physics } from 'phaser'; import { Physics } from 'phaser';
import { rng } from '../utils/random.js';
export class Platform extends Physics.Arcade.Sprite { export class Platform extends Physics.Arcade.Sprite {
constructor(scene, x, y, type = 'stable') { constructor(scene, x, y, type = 'stable') {
super(scene, x, y, 'platform'); super(scene, x, y, 'platform');
scene.add.existing(this); scene.add.existing(this);
scene.physics.add.existing(this, true);
// Moving platforms need a dynamic body; everything else is static.
const isMoving = type === 'moving';
scene.physics.add.existing(this, !isMoving);
this.platformType = type; this.platformType = type;
this.breakingState = 0; this.breakingState = 0;
@@ -13,15 +17,25 @@ export class Platform extends Physics.Arcade.Sprite {
this.startX = x; this.startX = x;
this.glowTween = null; this.glowTween = null;
if (type === 'moving') { if (isMoving) {
this.body.destroy(); this.body.setAllowGravity(false);
this.body = new Phaser.Physics.Arcade.Body(scene.physics.world, this); this.body.setImmovable(true);
this.body.allowGravity = false; this.moveSpeed = rng.between(50, 120) * rng.sign();
this.body.immovable = true; this.moveRange = rng.between(60, 160);
this.moveSpeed = Phaser.Math.Between(50, 120) * (Math.random() < 0.5 ? 1 : -1);
this.moveRange = Phaser.Math.Between(60, 160);
} else if (type === 'breaking') { } else if (type === 'breaking') {
this.setTint(0x999999); this.setTint(0x999999);
} else if (type === 'reorg') {
// Phantom "reorg" platform: semi-transparent, vanishes shortly after use.
this.setTint(0x38bdf8);
this.setAlpha(0.55);
this.glowTween = scene.tweens.add({
targets: this,
alpha: 0.3,
duration: 700,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
} else if (type === 'genesis') { } else if (type === 'genesis') {
this.setTint(0xffd700); this.setTint(0xffd700);
this.genesisGlow = scene.add.ellipse(x, y + 10, 100, 30, 0xffd700, 0.25) this.genesisGlow = scene.add.ellipse(x, y + 10, 100, 30, 0xffd700, 0.25)
@@ -41,10 +55,16 @@ export class Platform extends Physics.Arcade.Sprite {
preUpdate(time, delta) { preUpdate(time, delta) {
super.preUpdate(time, delta); super.preUpdate(time, delta);
if (this.platformType === 'moving' && this.body) { if (this.platformType === 'moving' && this.body) {
this.setVelocityX(this.moveSpeed); // Reverse direction only when past the edge AND still heading outward.
if (Math.abs(this.x - this.startX) > this.moveRange) { // (A plain |x-startX|>range flip re-triggers every frame at the boundary,
this.moveSpeed *= -1; // which made the platform vibrate in place.)
const dx = this.x - this.startX;
if (dx > this.moveRange && this.moveSpeed > 0) {
this.moveSpeed = -Math.abs(this.moveSpeed);
} else if (dx < -this.moveRange && this.moveSpeed < 0) {
this.moveSpeed = Math.abs(this.moveSpeed);
} }
this.setVelocityX(this.moveSpeed);
if (this.genesisGlow) this.genesisGlow.setPosition(this.x, this.y + 10); if (this.genesisGlow) this.genesisGlow.setPosition(this.x, this.y + 10);
} }
} }
@@ -64,6 +84,26 @@ export class Platform extends Physics.Arcade.Sprite {
}); });
return true; return true;
} }
if (this.platformType === 'reorg') {
// Allow this bounce, then vanish after a short grace window.
if (this.breakingState > 0) return true;
this.breakingState = 1;
if (this.glowTween) { this.glowTween.stop(); this.glowTween = null; }
this.setAlpha(0.55);
this.scene.time.delayedCall(300, () => {
this.breakingState = 2;
if (this.body) this.disableBody(true, false);
this.scene.tweens.add({
targets: this,
alpha: 0,
scaleX: 0.2,
scaleY: 0.2,
duration: 200,
onComplete: () => this.destroy(),
});
});
return true;
}
return true; return true;
} }

View File

@@ -21,11 +21,28 @@ export class Player extends Physics.Arcade.Sprite {
this.state = 'normal'; this.state = 'normal';
this.propellerTimer = 0; this.propellerTimer = 0;
this.rocketTimer = 0; this.rocketTimer = 0;
// Magnet and shield are independent of the movement state machine.
this.magnetTimer = 0;
this.magnetActive = false;
this.shielded = false;
this.shieldAura = null;
} }
update(cursors, wasd, touchLeft, touchRight, time, delta) { update(cursors, wasd, touchLeft, touchRight, time, delta) {
if (this.state === 'dead') return; if (this.state === 'dead') return;
// Magnet ticks in every state.
if (this.magnetTimer > 0) {
this.magnetTimer -= delta;
this.magnetActive = this.magnetTimer > 0;
}
// Keep the shield aura glued to the player.
if (this.shieldAura) {
this.shieldAura.setPosition(this.x, this.y);
}
let velocityX = 0; let velocityX = 0;
if (cursors.left.isDown || wasd.left.isDown || touchLeft) velocityX = -PLAYER_SPEED; if (cursors.left.isDown || wasd.left.isDown || touchLeft) velocityX = -PLAYER_SPEED;
if (cursors.right.isDown || wasd.right.isDown || touchRight) velocityX = PLAYER_SPEED; if (cursors.right.isDown || wasd.right.isDown || touchRight) velocityX = PLAYER_SPEED;
@@ -34,50 +51,109 @@ export class Player extends Physics.Arcade.Sprite {
if (this.x < -this.width / 2) this.x = GAME_WIDTH + this.width / 2; if (this.x < -this.width / 2) this.x = GAME_WIDTH + this.width / 2;
if (this.x > GAME_WIDTH + this.width / 2) this.x = -this.width / 2; if (this.x > GAME_WIDTH + this.width / 2) this.x = -this.width / 2;
if (velocityX < 0) { if (this.state === 'rocket') {
this.setFlipX(true);
this.setAngle(-5);
} else if (velocityX > 0) {
this.setFlipX(false);
this.setAngle(5);
} else {
this.setAngle(0);
}
if (this.state === 'propeller') {
this.propellerTimer -= delta;
this.setVelocityY(PROPELLER_VELOCITY);
if (this.propellerTimer <= 0) this.endPowerUp();
} else if (this.state === 'rocket') {
this.rocketTimer -= delta; this.rocketTimer -= delta;
this.setVelocityY(ROCKET_VELOCITY); this.setVelocityY(ROCKET_VELOCITY);
if (velocityX < 0) this.setFlipX(true);
else if (velocityX > 0) this.setFlipX(false);
// Rocket: rigid upright with tiny lean toward movement
this.setAngle(velocityX === 0 ? 0 : (velocityX < 0 ? -3 : 3));
if (this.rocketTimer <= 0) this.endPowerUp(); if (this.rocketTimer <= 0) this.endPowerUp();
} else if (this.state === 'propeller') {
this.propellerTimer -= delta;
this.setVelocityY(PROPELLER_VELOCITY);
if (velocityX < 0) this.setFlipX(true);
else if (velocityX > 0) this.setFlipX(false);
// Propeller: gentle sinusoidal wobble
const wobble = Math.sin(time / 80) * 4;
this.setAngle(wobble + (velocityX < 0 ? -2 : velocityX > 0 ? 2 : 0));
if (this.propellerTimer <= 0) this.endPowerUp();
} else {
if (velocityX < 0) {
this.setFlipX(true);
this.setAngle(-5);
} else if (velocityX > 0) {
this.setFlipX(false);
this.setAngle(5);
} else {
this.setAngle(0);
}
} }
} }
jump(force = JUMP_VELOCITY) { jump(force = JUMP_VELOCITY) {
if (this.state === 'dead' || this.state === 'rocket' || this.state === 'propeller') return false; if (this.state === 'dead' || this.state === 'rocket' || this.state === 'propeller') return false;
this.setVelocityY(force); this.setVelocityY(force);
this.scene.tweens.add({ // Squash-and-stretch, but never let two squash tweens stack on rapid
// bounces (that made the sprite scale pop and look like it was breaking up).
if (this.squashTween) this.squashTween.stop();
this.setScale(0.45);
this.squashTween = this.scene.tweens.add({
targets: this, targets: this,
scaleX: 0.55, scaleX: 0.55,
scaleY: 0.4, scaleY: 0.38,
duration: 80, duration: 90,
yoyo: true, yoyo: true,
ease: 'Quad.easeOut', ease: 'Quad.easeOut',
onComplete: () => {
this.squashTween = null;
this.setScale(0.45);
},
}); });
return true; return true;
} }
cutJump(factor) { _stopSquash() {
if (this.state !== 'normal') return; if (this.squashTween) {
if (this.body.velocity.y < 0) { this.squashTween.stop();
this.setVelocityY(this.body.velocity.y * factor); this.squashTween = null;
}
this.setScale(0.45);
}
startMagnet() {
this.magnetTimer = POWERUP_DURATION.magnet;
this.magnetActive = true;
}
startShield() {
this.shielded = true;
if (this.shieldAura) this.shieldAura.destroy();
this.shieldAura = this.scene.add.image(this.x, this.y, 'px_ring')
.setTint(0x22d3ee)
.setScale(1.5)
.setAlpha(0.7)
.setDepth(this.depth - 1);
this.scene.tweens.add({
targets: this.shieldAura,
scale: 1.7,
alpha: 0.35,
duration: 700,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
consumeShield() {
this.shielded = false;
if (this.shieldAura) {
const aura = this.shieldAura;
this.shieldAura = null;
this.scene.tweens.add({
targets: aura,
scale: 2.6,
alpha: 0,
duration: 250,
ease: 'Quad.easeOut',
onComplete: () => aura.destroy(),
});
} }
} }
startPropeller() { startPropeller() {
if (this.state === 'dead') return; if (this.state === 'dead') return;
this._stopSquash();
this.state = 'propeller'; this.state = 'propeller';
this.propellerTimer = POWERUP_DURATION.propeller; this.propellerTimer = POWERUP_DURATION.propeller;
const oldHeight = this.displayHeight; const oldHeight = this.displayHeight;
@@ -89,6 +165,7 @@ export class Player extends Physics.Arcade.Sprite {
startRocket() { startRocket() {
if (this.state === 'dead') return; if (this.state === 'dead') return;
this._stopSquash();
this.state = 'rocket'; this.state = 'rocket';
this.rocketTimer = POWERUP_DURATION.rocket; this.rocketTimer = POWERUP_DURATION.rocket;
const oldHeight = this.displayHeight; const oldHeight = this.displayHeight;
@@ -99,6 +176,7 @@ export class Player extends Physics.Arcade.Sprite {
} }
endPowerUp() { endPowerUp() {
this._stopSquash();
const oldHeight = this.displayHeight; const oldHeight = this.displayHeight;
this.state = 'normal'; this.state = 'normal';
this.setTexture('player_idle'); this.setTexture('player_idle');
@@ -110,6 +188,11 @@ export class Player extends Physics.Arcade.Sprite {
die() { die() {
if (this.state === 'dead') return; if (this.state === 'dead') return;
this._stopSquash();
this.magnetActive = false;
this.magnetTimer = 0;
if (this.shieldAura) { this.shieldAura.destroy(); this.shieldAura = null; }
this.shielded = false;
this.state = 'dead'; this.state = 'dead';
this.setTexture('player_dead'); this.setTexture('player_dead');
this.setScale(0.4); this.setScale(0.4);

33
src/entities/Shield.js Normal file
View File

@@ -0,0 +1,33 @@
import { Physics } from 'phaser';
export class Shield extends Physics.Arcade.Sprite {
constructor(scene, x, y) {
super(scene, x, y, 'shield');
scene.add.existing(this);
scene.physics.add.existing(this, true);
this.setScale(0.8);
this.body.setSize(this.width * 0.8, this.height * 0.8);
this.body.setOffset(this.width * 0.1, this.height * 0.1);
this.consumed = false;
this.floatTween = scene.tweens.add({
targets: this, y: y - 6, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
onPlayerTouch(player) {
if (this.consumed) return false;
this.consumed = true;
this.body.enable = false;
this.setVisible(false);
player.startShield();
this.destroy();
return true;
}
destroy(fromScene) {
if (this.floatTween) { this.floatTween.stop(); this.floatTween = null; }
super.destroy(fromScene);
}
}

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

@@ -0,0 +1,141 @@
import { sound } from './SoundManager.js';
import { storage, KEYS } from '../utils/storage.js';
const DEFS = [
{ id: 'genesis_block', title: 'Genesis Block', desc: 'Land on a gold platform' },
{ id: 'chain_reaction', title: 'Chain Reaction', desc: 'Reach combo x3.0' },
{ id: 'bug_hunter', title: 'Bug Hunter', desc: 'Stomp 5 enemies in one run' },
{ id: 'first_flight', title: 'First Flight', desc: 'Use the propeller' },
{ id: 'liftoff', title: 'Liftoff', desc: 'Use the rocket' },
{ id: 'power_trip', title: 'Power Trip', desc: 'Use all 3 power-ups in one run' },
{ id: 'survivor', title: 'Survivor', desc: 'Reach block height 500' },
{ id: 'skyscraper', title: 'Skyscraper', desc: 'Reach block height 1000' },
{ id: 'speedrun', title: 'Speedrun', desc: 'Reach block 100 in under 60s' },
{ id: 'gas_baron', title: 'Gas Baron', desc: 'Score 50,000 in one run' },
];
export class AchievementsManager {
constructor(scene) {
this.scene = scene;
this.unlocked = this._load();
this.popupQueue = [];
this.popupActive = false;
// Per-run counters
this.bugsStomped = 0;
this.powerupsUsed = new Set();
this.startTime = Date.now();
}
static getAll() {
return DEFS;
}
_load() {
return storage.getJSON(KEYS.achievements, {}) || {};
}
_save() {
storage.setJSON(KEYS.achievements, this.unlocked);
}
isUnlocked(id) {
return !!this.unlocked[id];
}
unlock(id) {
if (this.unlocked[id]) return;
const def = DEFS.find((d) => d.id === id);
if (!def) return;
this.unlocked[id] = Date.now();
this._save();
this.popupQueue.push(def);
this._processQueue();
sound.powerup();
}
_processQueue() {
if (this.popupActive || this.popupQueue.length === 0) return;
this.popupActive = true;
const def = this.popupQueue.shift();
this._showPopup(def, () => {
this.popupActive = false;
this._processQueue();
});
}
_showPopup(def, onDone) {
const { width } = this.scene.scale;
const y = 110;
const panel = this.scene.add.rectangle(width / 2, y, 320, 70, 0x1a0533, 0.92)
.setStrokeStyle(2, 0xffd700)
.setScrollFactor(0)
.setDepth(800);
const title = this.scene.add.text(width / 2, y - 14, `ACHIEVEMENT: ${def.title}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: '#ffd700',
}).setOrigin(0.5).setScrollFactor(0).setDepth(801);
const desc = this.scene.add.text(width / 2, y + 10, def.desc, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '8px',
color: '#d8b4fe',
}).setOrigin(0.5).setScrollFactor(0).setDepth(801);
panel.setAlpha(0);
title.setAlpha(0);
desc.setAlpha(0);
this.scene.tweens.add({
targets: [panel, title, desc],
alpha: 1,
y: `+=10`,
duration: 250,
ease: 'Quad.easeOut',
});
this.scene.time.delayedCall(2500, () => {
this.scene.tweens.add({
targets: [panel, title, desc],
alpha: 0,
duration: 350,
onComplete: () => {
panel.destroy(); title.destroy(); desc.destroy();
onDone();
},
});
});
}
// --- Game event hooks ---
onPlatformLand(type, combo) {
if (type === 'genesis') this.unlock('genesis_block');
if (combo >= 3) this.unlock('chain_reaction');
}
onEnemyStomp() {
this.bugsStomped += 1;
if (this.bugsStomped >= 5) this.unlock('bug_hunter');
}
onPowerup(name) {
this.powerupsUsed.add(name);
if (name === 'propeller') this.unlock('first_flight');
if (name === 'rocket') this.unlock('liftoff');
if (this.powerupsUsed.size >= 3) this.unlock('power_trip');
}
onBlockHeight(blocks) {
if (blocks >= 500) this.unlock('survivor');
if (blocks >= 1000) this.unlock('skyscraper');
if (blocks >= 100) {
const elapsed = (Date.now() - this.startTime) / 1000;
if (elapsed <= 60) this.unlock('speedrun');
}
}
onScore(score) {
if (score >= 50000) this.unlock('gas_baron');
}
}

View File

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

View File

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

View File

@@ -2,10 +2,14 @@ import { Platform } from '../entities/Platform.js';
import { Spring } from '../entities/Spring.js'; import { Spring } from '../entities/Spring.js';
import { PropellerHat } from '../entities/PropellerHat.js'; import { PropellerHat } from '../entities/PropellerHat.js';
import { Rocket } from '../entities/Rocket.js'; import { Rocket } from '../entities/Rocket.js';
import { Magnet } from '../entities/Magnet.js';
import { Shield } from '../entities/Shield.js';
import { Coin } from '../entities/Coin.js';
import { Enemy } from '../entities/Enemy.js'; import { Enemy } from '../entities/Enemy.js';
import { rng } from '../utils/random.js';
import { import {
GAME_WIDTH, PLATFORM_GAP_MIN, PLATFORM_GAP_MAX, GAME_WIDTH, PLATFORM_GAP_MIN, PLATFORM_GAP_MAX,
SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY, SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY, UNLOCK, COIN,
} from '../config/game.config.js'; } from '../config/game.config.js';
export class PlatformManager { export class PlatformManager {
@@ -14,6 +18,7 @@ export class PlatformManager {
this.platforms = scene.add.group({ classType: Platform }); this.platforms = scene.add.group({ classType: Platform });
this.enemies = scene.add.group({ classType: Enemy }); this.enemies = scene.add.group({ classType: Enemy });
this.powerups = scene.add.group(); this.powerups = scene.add.group();
this.coins = scene.add.group({ classType: Coin });
this.lastY = scene.scale.height - 80; this.lastY = scene.scale.height - 80;
this.highestY = this.lastY; this.highestY = this.lastY;
} }
@@ -28,6 +33,7 @@ export class PlatformManager {
this.cleanupGroup(this.platforms, killLine); this.cleanupGroup(this.platforms, killLine);
this.cleanupGroup(this.enemies, killLine); this.cleanupGroup(this.enemies, killLine);
this.cleanupGroup(this.powerups, killLine); this.cleanupGroup(this.powerups, killLine);
this.cleanupGroup(this.coins, killLine);
} }
cleanupGroup(group, killLine) { cleanupGroup(group, killLine) {
@@ -45,45 +51,81 @@ export class PlatformManager {
Math.floor(difficultyLevel / 1000) * DIFFICULTY.gapIncreasePer1000, Math.floor(difficultyLevel / 1000) * DIFFICULTY.gapIncreasePer1000,
DIFFICULTY.maxGap - DIFFICULTY.initialGap DIFFICULTY.maxGap - DIFFICULTY.initialGap
); );
const gap = Phaser.Math.Between( const gap = rng.between(
PLATFORM_GAP_MIN + gapIncrease, PLATFORM_GAP_MIN + gapIncrease,
Math.min(PLATFORM_GAP_MAX + gapIncrease, DIFFICULTY.maxGap) Math.min(PLATFORM_GAP_MAX + gapIncrease, DIFFICULTY.maxGap)
); );
this.highestY -= gap; this.highestY -= gap;
const x = Phaser.Math.Between(60, GAME_WIDTH - 60); const x = rng.between(60, GAME_WIDTH - 60);
const rand = Math.random(); const rand = rng.frac();
let type; let type;
if (rand < SPAWN_RATES.stable) type = 'stable'; if (rand < SPAWN_RATES.stable) type = 'stable';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving) type = 'moving'; else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving) type = 'moving';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking) type = 'breaking'; else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking) type = 'breaking';
else if (rand < SPAWN_RATES.stable + SPAWN_RATES.moving + SPAWN_RATES.breaking + SPAWN_RATES.reorg) type = 'reorg';
else type = 'genesis'; else type = 'genesis';
// Phantom reorg platforms only appear once the climb gets serious.
if (type === 'reorg' && difficultyLevel < UNLOCK.reorg) type = 'stable';
const platform = new Platform(this.scene, x, this.highestY, type); const platform = new Platform(this.scene, x, this.highestY, type);
this.platforms.add(platform); this.platforms.add(platform);
if (type !== 'breaking') { // No power-ups on platforms that disappear under you.
if (type !== 'breaking' && type !== 'reorg') {
this.maybeSpawnPowerUp(x, this.highestY - 25); this.maybeSpawnPowerUp(x, this.highestY - 25);
} }
this.maybeSpawnCoins(x, this.highestY);
this.maybeSpawnEnemy(x, this.highestY, difficultyLevel); this.maybeSpawnEnemy(x, this.highestY, difficultyLevel);
} }
maybeSpawnPowerUp(platformX, y) { maybeSpawnPowerUp(platformX, y) {
const rand = Math.random(); const r = POWERUP_RATES;
const rand = rng.frac();
let type = null; let type = null;
if (rand < POWERUP_RATES.spring) type = 'spring'; let acc = 0;
else if (rand < POWERUP_RATES.spring + POWERUP_RATES.propeller) type = 'propeller'; if (rand < (acc += r.spring)) type = 'spring';
else if (rand < POWERUP_RATES.spring + POWERUP_RATES.propeller + POWERUP_RATES.rocket) type = 'rocket'; else if (rand < (acc += r.propeller)) type = 'propeller';
else if (rand < (acc += r.rocket)) type = 'rocket';
else if (rand < (acc += r.magnet)) type = 'magnet';
else if (rand < (acc += r.shield)) type = 'shield';
if (!type) return; if (!type) return;
const x = Phaser.Math.Clamp(platformX + Phaser.Math.Between(-15, 15), 30, GAME_WIDTH - 30); const x = Phaser.Math.Clamp(platformX + rng.between(-15, 15), 30, GAME_WIDTH - 30);
if (type === 'spring') { if (type === 'spring') {
this.powerups.add(new Spring(this.scene, x, y)); this.powerups.add(new Spring(this.scene, x, y));
} else if (type === 'propeller') { } else if (type === 'propeller') {
this.powerups.add(new PropellerHat(this.scene, x, y - 35)); this.powerups.add(new PropellerHat(this.scene, x, y - 35));
} else if (type === 'rocket') { } else if (type === 'rocket') {
this.powerups.add(new Rocket(this.scene, x, y - 45)); this.powerups.add(new Rocket(this.scene, x, y - 45));
} else if (type === 'magnet') {
this.powerups.add(new Magnet(this.scene, x, y - 35));
} else if (type === 'shield') {
this.powerups.add(new Shield(this.scene, x, y - 35));
}
}
// Drop a small cluster of $GWEI coins (line or arc) above a platform.
maybeSpawnCoins(platformX, platformY) {
if (rng.frac() >= COIN.spawnChance) return;
const count = rng.between(3, 5);
const spacing = 26;
const arc = rng.frac() < 0.5; // half arcs, half vertical lines
const baseX = Phaser.Math.Clamp(platformX + rng.between(-30, 30), 40, GAME_WIDTH - 40);
const topY = platformY - rng.between(40, 70);
for (let i = 0; i < count; i++) {
let cx = baseX;
let cy = topY - i * spacing;
if (arc) {
cx = baseX + Math.sin((i / (count - 1)) * Math.PI) * 36 * (rng.frac() < 0.5 ? 1 : -1);
cy = topY - i * (spacing - 4);
}
cx = Phaser.Math.Clamp(cx, 20, GAME_WIDTH - 20);
this.coins.add(new Coin(this.scene, cx, cy));
} }
} }
@@ -92,12 +134,14 @@ export class PlatformManager {
Math.floor(difficultyLevel / 1000) * DIFFICULTY.enemyIncreasePer1000, Math.floor(difficultyLevel / 1000) * DIFFICULTY.enemyIncreasePer1000,
DIFFICULTY.maxEnemyRate DIFFICULTY.maxEnemyRate
); );
const bugRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate); const totalRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate);
if (Math.random() >= bugRate) return; if (rng.frac() >= totalRate) return;
const offset = Phaser.Math.Between(-60, 60); const type = this._rollEnemyType(difficultyLevel);
const offset = rng.between(-60, 60);
const ex = Phaser.Math.Clamp(platformX + offset, 50, GAME_WIDTH - 50); const ex = Phaser.Math.Clamp(platformX + offset, 50, GAME_WIDTH - 50);
const ey = platformY - Phaser.Math.Between(60, 130); const ey = platformY - rng.between(60, 130);
const minDist = 150; const minDist = 150;
let tooClose = false; let tooClose = false;
@@ -108,7 +152,20 @@ export class PlatformManager {
}); });
if (tooClose) return; if (tooClose) return;
this.enemies.add(new Enemy(this.scene, ex, ey, 'bug')); this.enemies.add(new Enemy(this.scene, ex, ey, type));
}
_rollEnemyType(difficultyLevel) {
const r = rng.frac();
if (difficultyLevel >= UNLOCK.mevBot) {
if (r < 0.25) return 'mev_bot';
if (r < 0.50) return 'failed_tx';
return 'bug';
}
if (difficultyLevel >= UNLOCK.failedTx) {
return r < 0.30 ? 'failed_tx' : 'bug';
}
return 'bug';
} }
getPlatforms() { getPlatforms() {
@@ -122,4 +179,8 @@ export class PlatformManager {
getPowerups() { getPowerups() {
return this.powerups; return this.powerups;
} }
getCoins() {
return this.coins;
}
} }

View File

@@ -1,4 +1,5 @@
import { SCORE } from '../config/game.config.js'; import { SCORE, POWERUP_DURATION } from '../config/game.config.js';
import { storage, KEYS } from '../utils/storage.js';
export class ScoreManager { export class ScoreManager {
constructor(scene) { constructor(scene) {
@@ -9,6 +10,7 @@ export class ScoreManager {
this.comboMultiplier = 1; this.comboMultiplier = 1;
this.genesisActive = false; this.genesisActive = false;
this.genesisJumps = 0; this.genesisJumps = 0;
this.gwei = 0;
this.hudBg = scene.add.rectangle(10, 10, 200, 90, 0x000000, 0.5) this.hudBg = scene.add.rectangle(10, 10, 200, 90, 0x000000, 0.5)
.setOrigin(0) .setOrigin(0)
@@ -39,6 +41,24 @@ export class ScoreManager {
color: '#aaa', color: '#aaa',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(200).setShadow(1, 1, '#000000', 2, false, true); }).setOrigin(1, 0).setScrollFactor(0).setDepth(200).setShadow(1, 1, '#000000', 2, false, true);
this.hudGwei = scene.add.text(scene.scale.width - 16, 34, '◈ 0', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '12px',
color: '#ffd700',
}).setOrigin(1, 0).setScrollFactor(0).setDepth(200).setShadow(1, 1, '#000000', 2, false, true);
// Powerup duration bar (hidden until active)
this.powerupBarBg = scene.add.rectangle(scene.scale.width / 2, scene.scale.height - 24, 200, 12, 0x000000, 0.6)
.setStrokeStyle(2, 0xa855f7)
.setScrollFactor(0).setDepth(200).setVisible(false);
this.powerupBarFill = scene.add.rectangle(scene.scale.width / 2 - 99, scene.scale.height - 24, 196, 8, 0xa855f7)
.setOrigin(0, 0.5).setScrollFactor(0).setDepth(201).setVisible(false);
this.powerupBarLabel = scene.add.text(scene.scale.width / 2, scene.scale.height - 40, '', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#d8b4fe',
}).setOrigin(0.5).setScrollFactor(0).setDepth(201).setVisible(false);
this.updateBestDisplay(); this.updateBestDisplay();
} }
@@ -47,11 +67,57 @@ export class ScoreManager {
if (blocks > this.blockHeight) { if (blocks > this.blockHeight) {
this.blockHeight = blocks; this.blockHeight = blocks;
this.hudBlocks.setText(`Block: ${this.blockHeight}`); this.hudBlocks.setText(`Block: ${this.blockHeight}`);
if (this.scene.achievements) this.scene.achievements.onBlockHeight(this.blockHeight);
if (this.blockHeight % 100 === 0 && this.blockHeight > 0) { if (this.blockHeight % 100 === 0 && this.blockHeight > 0) {
this.showMilestone(this.blockHeight); this.showMilestone(this.blockHeight);
} }
} }
this._updatePowerupBar();
}
_updatePowerupBar() {
const player = this.scene.player;
if (!player) return;
let active = null;
let remaining = 0;
let total = 0;
let color = 0xa855f7;
let label = '';
if (player.state === 'propeller') {
active = 'propeller';
remaining = Math.max(0, player.propellerTimer);
total = 3500;
color = 0x44aaff;
label = 'PROPELLER';
} else if (player.state === 'rocket') {
active = 'rocket';
remaining = Math.max(0, player.rocketTimer);
total = 3000;
color = 0xff4444;
label = 'ROCKET';
} else if (player.magnetActive) {
active = 'magnet';
remaining = Math.max(0, player.magnetTimer);
total = POWERUP_DURATION.magnet;
color = 0xffd700;
label = 'MAGNET';
}
if (active) {
const pct = remaining / total;
this.powerupBarBg.setVisible(true);
this.powerupBarFill.setVisible(true).setFillStyle(color);
this.powerupBarFill.scaleX = pct;
this.powerupBarLabel.setVisible(true).setText(label);
} else {
this.powerupBarBg.setVisible(false);
this.powerupBarFill.setVisible(false);
this.powerupBarLabel.setVisible(false);
}
} }
onLand(platformY, platformType) { onLand(platformY, platformType) {
@@ -72,7 +138,16 @@ export class ScoreManager {
if (this.genesisJumps <= 0) this.genesisActive = false; if (this.genesisJumps <= 0) this.genesisActive = false;
} }
this.addPoints(Math.floor(basePoints * multiplier)); const gained = Math.floor(basePoints * multiplier);
this.addPoints(gained);
this._spawnScorePopup(gained, platformType === 'genesis');
if (this.scene.achievements) {
this.scene.achievements.onPlatformLand(platformType, this.comboMultiplier);
}
if (this.scene.stats) {
this.scene.stats.onCombo(this.comboMultiplier);
}
if (this.combo > 1) { if (this.combo > 1) {
this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`); this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`);
@@ -80,15 +155,42 @@ export class ScoreManager {
} }
} }
_spawnScorePopup(amount, big) {
const player = this.scene.player;
if (!player) return;
const txt = this.scene.add.text(player.x, player.y - 30, `+${amount}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: big ? '14px' : '10px',
color: big ? '#ffd700' : '#22c55e',
}).setOrigin(0.5).setDepth(150);
this.scene.tweens.add({
targets: txt,
y: txt.y - 35,
alpha: 0,
duration: 700,
ease: 'Quad.easeOut',
onComplete: () => txt.destroy(),
});
}
addPoints(amount) { addPoints(amount) {
this.score += amount; this.score += amount;
this.hudScore.setText(`Gas: ${this.score}`); this.hudScore.setText(`Gas: ${this.score}`);
if (this.scene.achievements) this.scene.achievements.onScore(this.score);
}
addGwei(amount) {
this.gwei += amount;
this.hudGwei.setText(`${this.gwei}`);
this.scene.tweens.add({ targets: this.hudGwei, scale: 1.3, duration: 90, yoyo: true });
} }
onFall() { onFall() {
this.combo = 0; if (this.combo > 0) {
this.comboMultiplier = 1; this.combo = 0;
this.hudCombo.setText(''); this.comboMultiplier = 1;
this.hudCombo.setText('');
}
} }
showMilestone(blocks) { showMilestone(blocks) {
@@ -112,16 +214,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;
@@ -133,5 +233,9 @@ export class ScoreManager {
this.hudBlocks.destroy(); this.hudBlocks.destroy();
this.hudCombo.destroy(); this.hudCombo.destroy();
this.hudBest.destroy(); this.hudBest.destroy();
this.hudGwei.destroy();
this.powerupBarBg.destroy();
this.powerupBarFill.destroy();
this.powerupBarLabel.destroy();
} }
} }

View File

@@ -0,0 +1,155 @@
import { storage, KEYS } from '../utils/storage.js';
const MAX_GAIN = 0.4;
class SoundManager {
constructor() {
this.ctx = null;
this.master = null;
this.musicNodes = null;
this.muted = storage.getItem(KEYS.muted, '0') === '1';
this.volume = Phaser.Math.Clamp(storage.getFloat(KEYS.volume, 1), 0, 1);
}
init() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = this._effectiveGain();
this.master.connect(this.ctx.destination);
}
resume() {
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
}
_effectiveGain() {
return this.muted ? 0 : MAX_GAIN * this.volume;
}
_applyGain() {
if (this.master) this.master.gain.value = this._effectiveGain();
}
setMuted(value) {
this.muted = value;
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();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.master);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
_sweep(freqStart, freqEnd, duration, type = 'square', volume = 0.3) {
if (!this.ctx || this.muted) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freqStart, this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(Math.max(1, freqEnd), this.ctx.currentTime + duration);
gain.gain.setValueAtTime(volume, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.master);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
_noise(duration, volume = 0.15) {
if (!this.ctx || this.muted) return;
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
data[i] = (Math.random() * 2 - 1) * (1 - i / bufferSize);
}
const src = this.ctx.createBufferSource();
src.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.value = volume;
src.connect(gain);
gain.connect(this.master);
src.start();
}
jump() {
this._sweep(320, 540, 0.1, 'square', 0.14);
}
land() {
this._beep(180, 0.06, 'triangle', 0.10);
}
spring() {
this._sweep(280, 1000, 0.18, 'square', 0.22);
setTimeout(() => this._sweep(800, 1300, 0.12, 'triangle', 0.15), 50);
}
powerup() {
[440, 554, 659, 880].forEach((f, i) => setTimeout(() => this._beep(f, 0.13, 'triangle', 0.20), i * 55));
}
stomp() {
this._sweep(420, 100, 0.16, 'sawtooth', 0.22);
this._noise(0.08, 0.10);
}
break() {
this._noise(0.15, 0.12);
this._sweep(200, 60, 0.2, 'sawtooth', 0.10);
}
death() {
this._sweep(280, 50, 0.5, 'sawtooth', 0.28);
setTimeout(() => this._noise(0.3, 0.15), 100);
}
milestone() {
[523, 659, 783, 1046].forEach((f, i) => setTimeout(() => this._beep(f, 0.22, 'triangle', 0.25), i * 90));
}
click() {
this._beep(660, 0.05, 'square', 0.12);
}
coin() {
this._beep(988, 0.05, 'square', 0.12);
setTimeout(() => this._beep(1319, 0.08, 'square', 0.12), 45);
}
newBest() {
[523, 659, 783, 1046, 1318].forEach((f, i) => setTimeout(() => this._beep(f, 0.18, 'triangle', 0.25), i * 80));
}
}
export const sound = new SoundManager();

View File

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

View File

@@ -18,9 +18,46 @@ export class BootScene extends Scene {
create() { create() {
this.createBackgrounds(); this.createBackgrounds();
this.createParticleTextures();
this.scene.start('MenuScene'); this.scene.start('MenuScene');
} }
createParticleTextures() {
// All white so emitters can tint per-use. Keep textures tiny.
// Solid square (jump dust, explosion, sparks, puff)
const sq = this.make.graphics({ x: 0, y: 0, add: false });
sq.fillStyle(0xffffff, 1);
sq.fillRect(0, 0, 8, 8);
sq.generateTexture('px_square', 8, 8);
sq.destroy();
// Soft round dot (flame core, glow sparks)
const soft = this.make.graphics({ x: 0, y: 0, add: false });
soft.fillStyle(0xffffff, 0.35);
soft.fillCircle(8, 8, 8);
soft.fillStyle(0xffffff, 0.7);
soft.fillCircle(8, 8, 5);
soft.fillStyle(0xffffff, 1);
soft.fillCircle(8, 8, 2.5);
soft.generateTexture('px_soft', 16, 16);
soft.destroy();
// Thin streak (wind, speed lines)
const streak = this.make.graphics({ x: 0, y: 0, add: false });
streak.fillStyle(0xffffff, 1);
streak.fillRect(0, 0, 3, 18);
streak.generateTexture('px_streak', 3, 18);
streak.destroy();
// Ring (boost burst shockwave)
const ring = this.make.graphics({ x: 0, y: 0, add: false });
ring.lineStyle(4, 0xffffff, 1);
ring.strokeCircle(32, 32, 28);
ring.generateTexture('px_ring', 64, 64);
ring.destroy();
}
createBackgrounds() { createBackgrounds() {
const gridW = 512; const gridW = 512;
const gridH = 512; const gridH = 512;
@@ -111,5 +148,52 @@ export class BootScene extends Scene {
} }
springGfx.strokePath(); springGfx.strokePath();
springGfx.generateTexture('spring', 20, 32); springGfx.generateTexture('spring', 20, 32);
// $GWEI coin — gold disc with highlight + purple diamond (matches Naddie)
const coin = this.make.graphics({ x: 0, y: 0, add: false });
coin.fillStyle(0xb8860b, 1); coin.fillCircle(16, 16, 15);
coin.fillStyle(0xffd700, 1); coin.fillCircle(16, 16, 13);
coin.fillStyle(0xfff3b0, 1); coin.fillCircle(16, 16, 9);
coin.fillStyle(0xffd700, 1); coin.fillCircle(16, 16, 7);
coin.fillStyle(0x7c3aed, 1);
coin.beginPath();
coin.moveTo(16, 9); coin.lineTo(21, 16); coin.lineTo(16, 23); coin.lineTo(11, 16);
coin.closePath(); coin.fillPath();
coin.fillStyle(0xffffff, 0.5); coin.fillCircle(12, 11, 2);
coin.generateTexture('coin', 32, 32);
// Magnet — classic red/grey horseshoe
const mag = this.make.graphics({ x: 0, y: 0, add: false });
mag.lineStyle(9, 0xe11d48, 1);
mag.beginPath();
mag.arc(20, 18, 13, Math.PI, 0, false); // top arc opening downward
mag.strokePath();
mag.lineStyle(9, 0xe11d48, 1);
mag.beginPath(); mag.moveTo(7, 18); mag.lineTo(7, 32); mag.strokePath();
mag.beginPath(); mag.moveTo(33, 18); mag.lineTo(33, 32); mag.strokePath();
mag.fillStyle(0xd1d5db, 1);
mag.fillRect(3, 31, 8, 6);
mag.fillRect(29, 31, 8, 6);
mag.generateTexture('magnet', 40, 40);
// Shield — cyan heater shield with glassy fill
const sh = this.make.graphics({ x: 0, y: 0, add: false });
const drawShield = (inset, fill, alpha) => {
sh.fillStyle(fill, alpha);
sh.beginPath();
sh.moveTo(20, 2 + inset);
sh.lineTo(36 - inset, 9 + inset / 2);
sh.lineTo(36 - inset, 22);
sh.lineTo(20, 40 - inset);
sh.lineTo(4 + inset, 22);
sh.lineTo(4 + inset, 9 + inset / 2);
sh.closePath();
sh.fillPath();
};
drawShield(0, 0x0e7490, 1);
drawShield(3, 0x22d3ee, 1);
drawShield(8, 0xa5f3fc, 0.9);
sh.fillStyle(0xffffff, 0.5); sh.fillRect(12, 10, 4, 12);
sh.generateTexture('shield', 40, 42);
} }
} }

View File

@@ -1,4 +1,8 @@
import { Scene } from 'phaser'; import { Scene } from 'phaser';
import { sound } from '../managers/SoundManager.js';
import { createButton } from '../utils/ui.js';
import { todaySeed } from '../utils/random.js';
import { storage, KEYS } from '../utils/storage.js';
export class GameOverScene extends Scene { export class GameOverScene extends Scene {
constructor() { constructor() {
@@ -9,6 +13,18 @@ export class GameOverScene extends Scene {
this.finalScore = data.score || 0; this.finalScore = data.score || 0;
this.blockHeight = data.blockHeight || 0; this.blockHeight = data.blockHeight || 0;
this.isNewBest = data.isNewBest || false; this.isNewBest = data.isNewBest || false;
this.mode = data.mode || 'normal';
this.dailyBest = data.dailyBest;
this.gwei = data.gwei || 0;
}
retry() {
sound.click();
if (this.mode === 'daily') {
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
} else {
this.scene.start('GameScene', { mode: 'normal' });
}
} }
create() { create() {
@@ -16,19 +32,9 @@ export class GameOverScene extends Scene {
this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg'); this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg');
// Dead Naddie image const naddie = this.add.image(width / 2, height * 0.22, 'player_dead').setScale(0.32);
const naddie = this.add.image(width / 2, height * 0.22, 'player_dead') this.tweens.add({ targets: naddie, angle: -10, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
.setScale(0.32);
this.tweens.add({
targets: naddie,
angle: -10,
duration: 2000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
// Game Over text
this.add.text(width / 2, height * 0.38, 'GAME OVER', { this.add.text(width / 2, height * 0.38, 'GAME OVER', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '32px', fontSize: '32px',
@@ -37,11 +43,12 @@ export class GameOverScene extends Scene {
}).setOrigin(0.5).setShadow(3, 3, '#7f1d1d', 0, false, true); }).setOrigin(0.5).setShadow(3, 3, '#7f1d1d', 0, false, true);
if (this.isNewBest) { if (this.isNewBest) {
this.add.text(width / 2, height * 0.46, 'NEW BEST!', { const best = this.add.text(width / 2, height * 0.46, 'NEW BEST!', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '14px', fontSize: '14px',
color: '#22c55e', color: '#22c55e',
}).setOrigin(0.5); }).setOrigin(0.5);
this.tweens.add({ targets: best, scale: 1.15, duration: 600, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
} }
this.add.text(width / 2, height * 0.54, `Block Height: ${this.blockHeight}`, { this.add.text(width / 2, height * 0.54, `Block Height: ${this.blockHeight}`, {
@@ -50,19 +57,29 @@ export class GameOverScene extends Scene {
color: '#d8b4fe', color: '#d8b4fe',
}).setOrigin(0.5); }).setOrigin(0.5);
this.add.text(width / 2, height * 0.61, `Gas Score: ${this.finalScore}`, { this.add.text(width / 2, height * 0.60, `Gas Score: ${this.finalScore}`, {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '16px', fontSize: '16px',
color: '#a855f7', color: '#a855f7',
}).setOrigin(0.5); }).setOrigin(0.5);
this.createButton(width / 2, height * 0.72, 'RETRY', () => { const wallet = storage.getInt(KEYS.gwei, 0);
this.scene.start('GameScene'); this.add.text(width / 2, height * 0.655, `◈ +${this.gwei} $GWEI (wallet: ${wallet})`, {
}); fontFamily: '"Press Start 2P", monospace',
fontSize: '11px',
color: '#ffd700',
}).setOrigin(0.5);
this.createButton(width / 2, height * 0.82, 'MAIN MENU', () => { if (this.mode === 'daily') {
this.scene.start('MenuScene'); this.add.text(width / 2, height * 0.695, `DAILY · Today's Best: ${this.dailyBest ?? this.finalScore}`, {
}); fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: '#ffd700',
}).setOrigin(0.5);
}
createButton(this, width / 2, height * 0.72, 'RETRY', () => this.retry());
createButton(this, width / 2, height * 0.82, 'MAIN MENU', () => this.scene.start('MenuScene'));
this.add.text(width / 2, height * 0.92, 'ON-CHAIN SUBMIT — COMING SOON', { this.add.text(width / 2, height * 0.92, 'ON-CHAIN SUBMIT — COMING SOON', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
@@ -70,20 +87,8 @@ export class GameOverScene extends Scene {
color: '#666', color: '#666',
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
}
createButton(x, y, text, callback) { this.input.keyboard.once('keydown-ENTER', () => this.retry());
const bg = this.add.rectangle(x, y, 260, 48, 0x581c87) this.input.keyboard.once('keydown-SPACE', () => this.retry());
.setStrokeStyle(2, 0xa855f7)
.setInteractive({ useHandCursor: true });
const label = this.add.text(x, y, text, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '13px',
color: '#ffffff',
}).setOrigin(0.5);
bg.on('pointerover', () => bg.setFillStyle(0x7e22ce));
bg.on('pointerout', () => bg.setFillStyle(0x581c87));
bg.on('pointerdown', callback);
return { bg, label };
} }
} }

View File

@@ -3,22 +3,47 @@ import { Player } from '../entities/Player.js';
import { Platform } from '../entities/Platform.js'; import { Platform } from '../entities/Platform.js';
import { PlatformManager } from '../managers/PlatformManager.js'; import { PlatformManager } from '../managers/PlatformManager.js';
import { ScoreManager } from '../managers/ScoreManager.js'; import { ScoreManager } from '../managers/ScoreManager.js';
import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS } from '../config/game.config.js'; import { AchievementsManager } from '../managers/AchievementsManager.js';
import { EffectsManager } from '../managers/EffectsManager.js';
import { ParticleManager } from '../managers/ParticleManager.js';
import { StatsManager } from '../managers/StatsManager.js';
import { sound } from '../managers/SoundManager.js';
import { storage, KEYS } from '../utils/storage.js';
import { rng } from '../utils/random.js';
import { GAME_WIDTH, GAME_HEIGHT, SCORE, PHYSICS, COIN } from '../config/game.config.js';
export class GameScene extends Scene { export class GameScene extends Scene {
constructor() { constructor() {
super({ key: 'GameScene' }); super({ key: 'GameScene' });
} }
init(data) {
this.mode = (data && data.mode) || 'normal';
this.seed = (data && data.seed) || `${Date.now()}-${Math.random()}`;
}
create() { create() {
sound.init();
sound.resume();
// Seed gameplay RNG so a given seed produces a reproducible run (Daily).
rng.seed(this.seed);
this.bg = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'gridBg') this.bg = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'gridBg')
.setScrollFactor(0) .setScrollFactor(0)
.setDepth(-10); .setDepth(-10);
// Color overlay for height-based tint shift
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.createGweiParticles();
this.cursors = this.input.keyboard.createCursorKeys(); this.cursors = this.input.keyboard.createCursorKeys();
this.wasd = this.input.keyboard.addKeys({ up: 'W', down: 'S', left: 'A', right: 'D' }); this.wasd = this.input.keyboard.addKeys({ up: 'W', down: 'S', left: 'A', right: 'D' });
this.escKey = this.input.keyboard.addKey('ESC');
this.touchLeft = false; this.touchLeft = false;
this.touchRight = false; this.touchRight = false;
this.setupTouchControls(); this.setupTouchControls();
@@ -30,13 +55,11 @@ export class GameScene extends Scene {
this.player = new Player(this, GAME_WIDTH / 2, startY - 140); this.player = new Player(this, GAME_WIDTH / 2, startY - 140);
this.lastJumpY = this.player.y; this.lastJumpY = this.player.y;
this.playerShadow = this.add.graphics(); this.effects = new EffectsManager(this);
this.playerShadow.setDepth(-4);
this.trailPoints = [];
this.trailGraphics = this.add.graphics().setDepth(-3);
this.platformManager = new PlatformManager(this); this.platformManager = new PlatformManager(this);
this.achievements = new AchievementsManager(this);
this.stats = new StatsManager(this);
this.scoreManager = new ScoreManager(this); this.scoreManager = new ScoreManager(this);
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
@@ -47,27 +70,93 @@ export class GameScene extends Scene {
this.physics.add.collider(this.player, this.platformManager.getPlatforms(), this.handlePlatformCollision, this.platformCollisionFilter, this); this.physics.add.collider(this.player, this.platformManager.getPlatforms(), this.handlePlatformCollision, this.platformCollisionFilter, this);
this.physics.add.overlap(this.player, this.platformManager.getPowerups(), this.handlePowerup, null, this); this.physics.add.overlap(this.player, this.platformManager.getPowerups(), this.handlePowerup, null, this);
this.physics.add.overlap(this.player, this.platformManager.getEnemies(), this.handleEnemy, null, this); this.physics.add.overlap(this.player, this.platformManager.getEnemies(), this.handleEnemy, null, this);
this.physics.add.overlap(this.player, this.platformManager.getCoins(), this.handleCoin, null, this);
this.cameras.main.setBounds(0, -999999, GAME_WIDTH, 999999 + GAME_HEIGHT); this.cameras.main.setBounds(0, -999999, GAME_WIDTH, 999999 + GAME_HEIGHT);
this.cameras.main.startFollow(this.player, true, 0, 0.05, 0, 180); // Doodle-jump camera: free movement below the trigger line, camera only
// scrolls up when player rises above it, and never moves down.
this.cameraTriggerY = GAME_HEIGHT * 0.42; // ~358 of 854 (just above middle)
this.cameras.main.setRoundPixels(false);
this.isGameOver = false; this.isGameOver = false;
this.isPaused = false;
this.difficultyLevel = 0; this.difficultyLevel = 0;
this.minScrollY = this.cameras.main.scrollY; this.minScrollY = this.cameras.main.scrollY;
this.onMilestone = () => sound.milestone();
this.createPauseUI();
this.createMuteButton();
if (this.mode === 'daily') {
this.add.text(GAME_WIDTH / 2, 18, `DAILY · ${this.seed}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#ffd700',
}).setOrigin(0.5, 0).setScrollFactor(0).setDepth(300);
}
this.escKey.on('down', () => this.togglePause());
// Move the camera after physics has synced the sprite (POST_UPDATE) so the
// player never lags the camera by a frame on fast vertical movement.
this.events.on('postupdate', this.lateUpdate, this);
// 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;
}
this.events.off('postupdate', this.lateUpdate, this);
}
// Runs after the physics step (sprite positions already synced this frame).
lateUpdate() {
if (this.isGameOver || this.isPaused || !this.player) return;
// Doodle-jump camera: latch the player at the trigger line going up; never
// scroll back down.
const targetScrollY = this.player.y - this.cameraTriggerY;
if (targetScrollY < this.cameras.main.scrollY) {
this.cameras.main.scrollY = targetScrollY;
}
this.bg.tilePositionY = Math.round(this.cameras.main.scrollY * 0.3);
} }
update(time, delta) { update(time, delta) {
if (this.isGameOver) return; if (this.isGameOver || this.isPaused) return;
this.player.update(this.cursors, this.wasd, this.touchLeft, this.touchRight, time, delta); this.player.update(this.cursors, this.wasd, this.touchLeft, this.touchRight, time, delta);
this.bg.tilePositionY = this.cameras.main.scrollY * 0.3; // Cap downward speed so the player can never tunnel through a thin platform
// in a single physics step (does not affect upward boost velocities).
if (this.player.body.velocity.y > PHYSICS.maxFallSpeed) {
this.player.setVelocityY(PHYSICS.maxFallSpeed);
}
// NOTE: camera following happens in lateUpdate (POST_UPDATE) — after the
// physics step syncs the sprite — otherwise the camera lags the player by
// one frame and fast ascent/rocket looks jittery.
this.minScrollY = Math.min(this.minScrollY, this.cameras.main.scrollY); this.minScrollY = Math.min(this.minScrollY, this.cameras.main.scrollY);
const killLine = this.minScrollY + GAME_HEIGHT; const killLine = this.minScrollY + GAME_HEIGHT;
const height = Math.max(0, GAME_HEIGHT - this.player.y); const height = Math.max(0, GAME_HEIGHT - this.player.y);
this.difficultyLevel = height; this.difficultyLevel = height;
// Background darkens as player climbs higher (cosmic feel)
const tintAlpha = Math.min(0.5, height / 8000);
this.bgTint.fillAlpha = tintAlpha;
this.platformManager.update(this.difficultyLevel, killLine); this.platformManager.update(this.difficultyLevel, killLine);
this.scoreManager.update(this.player.y); this.scoreManager.update(this.player.y);
@@ -81,48 +170,18 @@ export class GameScene extends Scene {
this.lastJumpY = this.player.y; this.lastJumpY = this.player.y;
} }
this.updatePlayerShadow(); this.effects.update(this.player, delta);
this.updateTrail();
}
updatePlayerShadow() {
this.playerShadow.clear();
if (!this.player.active) return;
const alpha = Math.max(0.05, 0.25 - (Math.abs(this.player.body.velocity.y) / 1200));
this.playerShadow.fillStyle(0x000000, alpha);
this.playerShadow.fillCircle(this.player.x, this.player.y + 42, 16);
}
updateTrail() {
if (!this.player.active) return;
const isFast = this.player.state === 'rocket' || this.player.state === 'propeller';
if (isFast && Math.abs(this.player.body.velocity.y) > 200) {
this.trailPoints.push({ x: this.player.x, y: this.player.y, alpha: 0.5, scale: 1 });
if (this.trailPoints.length > 15) this.trailPoints.shift();
}
for (let i = this.trailPoints.length - 1; i >= 0; i--) {
const point = this.trailPoints[i];
point.alpha -= 0.04;
point.scale -= 0.03;
if (point.alpha <= 0 || point.scale <= 0) {
this.trailPoints.splice(i, 1);
}
}
this.trailGraphics.clear();
for (const point of this.trailPoints) {
const color = this.player.state === 'rocket' ? 0xff4444 : 0x44aaff;
this.trailGraphics.fillStyle(color, point.alpha);
this.trailGraphics.fillCircle(point.x, point.y, 8 * point.scale);
}
} }
platformCollisionFilter(player, platform) { platformCollisionFilter(player, platform) {
if (!platform || !platform.body) return false; if (!platform || !platform.body) return false;
if (typeof platform.isBroken === 'function' && platform.isBroken()) return false; if (typeof platform.isBroken === 'function' && platform.isBroken()) return false;
return player.body.velocity.y > 0 && player.y < platform.y + 8; if (player.body.velocity.y <= 0) return false;
// Swept one-way check: only bounce if the player's feet were at/above the
// platform top on the PREVIOUS step (i.e. genuinely landing from above).
// Using deltaY makes this correct at any fall speed.
const prevBottom = player.body.bottom - player.body.deltaY();
return prevBottom <= platform.body.top + PHYSICS.landTolerance;
} }
handlePlatformCollision(player, platform) { handlePlatformCollision(player, platform) {
@@ -130,9 +189,13 @@ export class GameScene extends Scene {
if (platform && typeof platform.onPlayerLand === 'function') { if (platform && typeof platform.onPlayerLand === 'function') {
platform.onPlayerLand(player); platform.onPlayerLand(player);
this.scoreManager.onLand(platform.y, platform.platformType || 'stable'); this.scoreManager.onLand(platform.y, platform.platformType || 'stable');
if (platform.platformType === 'breaking') sound.break();
} }
this.lastJumpY = player.y; this.lastJumpY = player.y;
player.jump(); if (player.jump()) {
sound.jump();
if (this.stats) this.stats.onJump();
}
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
} }
@@ -141,10 +204,23 @@ export class GameScene extends Scene {
if (powerup && typeof powerup.onPlayerTouch === 'function') { if (powerup && typeof powerup.onPlayerTouch === 'function') {
const px = powerup.x; const px = powerup.x;
const py = powerup.y; const py = powerup.y;
const name = powerup.constructor.name.toLowerCase();
const map = { spring: 'spring', propellerhat: 'propeller', rocket: 'rocket' };
const kind = map[name] || name;
const consumed = powerup.onPlayerTouch(player); const consumed = powerup.onPlayerTouch(player);
if (consumed) { if (consumed) {
this.createPowerupParticles(px, py); this.createPowerupParticles(px, py);
this.flashScreen(); this.flashScreen();
if (kind === 'spring') {
sound.spring();
this.effects.startBoost(player, 'spring');
} else if (kind === 'propeller' || kind === 'rocket') {
sound.powerup();
this.effects.startBoost(player, kind);
} else {
sound.powerup();
}
if (this.achievements) this.achievements.onPowerup(kind);
} }
} }
} }
@@ -154,40 +230,242 @@ export class GameScene extends Scene {
if (player.state === 'rocket') { if (player.state === 'rocket') {
this.createExplosion(enemy.x, enemy.y); this.createExplosion(enemy.x, enemy.y);
enemy.destroy(); enemy.destroy();
sound.stomp();
return; return;
} }
if (player.state === 'propeller') { if (player.state === 'propeller') {
player.endPowerUp(); player.endPowerUp();
this.createExplosion(enemy.x, enemy.y); this.createExplosion(enemy.x, enemy.y);
enemy.destroy(); enemy.destroy();
sound.stomp();
return; return;
} }
if (player.body.velocity.y > 0 && player.body.bottom <= enemy.body.top + PHYSICS.stompTolerance) { if (player.body.velocity.y > 0 && player.body.bottom <= enemy.body.top + PHYSICS.stompTolerance) {
this.createExplosion(enemy.x, enemy.y); this.createExplosion(enemy.x, enemy.y);
enemy.destroy(); enemy.destroy();
player.jump(); player.jump();
sound.stomp();
this.scoreManager.addPoints(SCORE.stompBonus); this.scoreManager.addPoints(SCORE.stompBonus);
if (this.achievements) this.achievements.onEnemyStomp();
if (this.stats) this.stats.onStomp();
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3); this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
return; return;
} }
// Gas Limit shield absorbs one hit instead of dying.
if (player.shielded) {
player.consumeShield();
this.createExplosion(enemy.x, enemy.y);
enemy.destroy();
sound.stomp();
this.flashScreen();
return;
}
this.gameOver(enemy.x, enemy.y); this.gameOver(enemy.x, enemy.y);
} }
handleCoin(player, coin) {
if (this.isGameOver) return;
if (coin && typeof coin.collect === 'function' && coin.collect()) {
this.scoreManager.addGwei(COIN.value);
if (this.stats) this.stats.onCoin();
sound.coin();
}
}
gameOver(ex, ey) { gameOver(ex, ey) {
if (this.isGameOver) return; if (this.isGameOver) return;
this.isGameOver = true; this.isGameOver = true;
this.player.die(); this.player.die();
sound.death();
this.cameras.main.shake(300, 0.012); this.cameras.main.shake(300, 0.012);
const bx = ex ?? this.player.x; const bx = ex ?? this.player.x;
const by = ey ?? this.player.y; const by = ey ?? this.player.y;
this.createExplosion(bx, by); this.createExplosion(bx, by);
const isNewBest = this.scoreManager.saveBest(); const isNewBest = this.scoreManager.saveBest();
if (isNewBest) {
this.time.delayedCall(400, () => sound.newBest());
}
const score = this.scoreManager.score; const score = this.scoreManager.score;
const blockHeight = this.scoreManager.blockHeight; const blockHeight = this.scoreManager.blockHeight;
const gwei = this.scoreManager.gwei;
// Bank the run's $GWEI into the persistent wallet.
if (gwei > 0) {
storage.setItem(KEYS.gwei, storage.getInt(KEYS.gwei, 0) + gwei);
}
if (this.stats) this.stats.flush(blockHeight);
// Daily challenge keeps its own per-day best.
let dailyBest = null;
if (this.mode === 'daily') {
const key = KEYS.dailyPrefix + this.seed;
dailyBest = storage.getInt(key, 0);
if (score > dailyBest) {
dailyBest = score;
storage.setItem(key, score);
}
}
this.time.delayedCall(1500, () => { this.time.delayedCall(1500, () => {
this.scoreManager.destroy(); this.scoreManager.destroy();
this.scene.start('GameOverScene', { score, blockHeight, isNewBest }); this.scene.start('GameOverScene', {
score, blockHeight, isNewBest, mode: this.mode, dailyBest, gwei,
});
});
}
togglePause() {
if (this.isGameOver) return;
this.isPaused = !this.isPaused;
if (this.isPaused) {
this.physics.pause();
this.pauseOverlay.setVisible(true);
sound.click();
} else {
this.physics.resume();
this.pauseOverlay.setVisible(false);
sound.click();
}
}
createPauseUI() {
const { width, height } = this.scale;
const container = this.add.container(0, 0).setScrollFactor(0).setDepth(500).setVisible(false);
const dim = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.65);
const panel = this.add.rectangle(width / 2, height / 2, 320, 280, 0x1a0533).setStrokeStyle(3, 0xa855f7);
const title = this.add.text(width / 2, height / 2 - 90, 'PAUSED', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '22px',
color: '#d8b4fe',
}).setOrigin(0.5);
const resume = this.add.rectangle(width / 2, height / 2 - 20, 220, 44, 0x581c87)
.setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true });
const resumeLabel = this.add.text(width / 2, height / 2 - 20, 'RESUME', {
fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#fff',
}).setOrigin(0.5);
resume.on('pointerdown', () => this.togglePause());
const quit = this.add.rectangle(width / 2, height / 2 + 40, 220, 44, 0x581c87)
.setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true });
const quitLabel = this.add.text(width / 2, height / 2 + 40, 'MAIN MENU', {
fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#fff',
}).setOrigin(0.5);
quit.on('pointerdown', () => {
sound.click();
this.physics.resume();
this.scoreManager.destroy();
this.scene.start('MenuScene');
});
const hint = this.add.text(width / 2, height / 2 + 100, 'ESC to resume', {
fontFamily: '"Press Start 2P", monospace', fontSize: '9px', color: '#888',
}).setOrigin(0.5);
container.add([dim, panel, title, resume, resumeLabel, quit, quitLabel, hint]);
this.pauseOverlay = container;
const pauseBtn = this.add.text(GAME_WIDTH - 16, 50, '⏸', {
fontSize: '22px',
}).setOrigin(1, 0.5).setScrollFactor(0).setDepth(300).setInteractive({ useHandCursor: true });
pauseBtn.on('pointerdown', () => this.togglePause());
}
createMuteButton() {
const icon = this.add.text(GAME_WIDTH - 16, 80, sound.isMuted() ? '🔇' : '🔊', {
fontSize: '18px',
}).setOrigin(1, 0.5).setScrollFactor(0).setDepth(300).setInteractive({ useHandCursor: true });
icon.on('pointerdown', () => {
const next = !sound.isMuted();
sound.setMuted(next);
icon.setText(next ? '🔇' : '🔊');
if (!next) sound.click();
});
}
maybeShowTutorial() {
if (storage.getItem(KEYS.tutorialSeen, '0') === '1') return;
const { width, height } = this.scale;
this.isPaused = true;
this.physics.pause();
const container = this.add.container(0, 0).setScrollFactor(0).setDepth(600);
const dim = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75);
const panel = this.add.rectangle(width / 2, height / 2, 380, 480, 0x1a0533).setStrokeStyle(3, 0xa855f7);
const title = this.add.text(width / 2, height * 0.22, 'HOW TO PLAY', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '18px',
color: '#d8b4fe',
}).setOrigin(0.5);
const lines = [
'← → / A D — Move',
'TAP LEFT / RIGHT — Move',
'AUTO-JUMP on platforms',
'',
'PURPLE — Stable platform',
'GOLD — Genesis (x2 bonus)',
'GREY — Breaks on landing',
'MOVING — Slides side to side',
'',
'Avoid BUGS, grab POWER-UPS',
'ESC — Pause',
];
const texts = lines.map((l, i) => this.add.text(width / 2, height * 0.30 + i * 24, l, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: l.includes('Genesis') ? '#ffd700' : l.includes('Breaks') ? '#999' : '#d8b4fe',
align: 'center',
}).setOrigin(0.5));
const startBtn = this.add.rectangle(width / 2, height * 0.85, 220, 48, 0x581c87)
.setStrokeStyle(2, 0xa855f7).setInteractive({ useHandCursor: true });
const startLabel = this.add.text(width / 2, height * 0.85, "LET'S JUMP!", {
fontFamily: '"Press Start 2P", monospace', fontSize: '14px', color: '#fff',
}).setOrigin(0.5);
container.add([dim, panel, title, ...texts, startBtn, startLabel]);
startBtn.on('pointerdown', () => {
sound.click();
storage.setItem(KEYS.tutorialSeen, '1');
container.destroy();
this.isPaused = false;
this.physics.resume();
});
}
showTouchIndicators() {
if (!this.sys.game.device.input.touch) return;
const { width, height } = this.scale;
const left = this.add.rectangle(width / 4, height - 100, width / 2 - 20, 60, 0xa855f7, 0.15)
.setScrollFactor(0).setDepth(250);
const leftText = this.add.text(width / 4, height - 100, '◀ TAP', {
fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#fff',
}).setOrigin(0.5).setScrollFactor(0).setDepth(251).setAlpha(0.7);
const right = this.add.rectangle(3 * width / 4, height - 100, width / 2 - 20, 60, 0xa855f7, 0.15)
.setScrollFactor(0).setDepth(250);
const rightText = this.add.text(3 * width / 4, height - 100, 'TAP ▶', {
fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#fff',
}).setOrigin(0.5).setScrollFactor(0).setDepth(251).setAlpha(0.7);
this.tweens.add({
targets: [left, right, leftText, rightText],
alpha: 0,
delay: 3500,
duration: 1500,
onComplete: () => {
left.destroy(); right.destroy();
leftText.destroy(); rightText.destroy();
},
}); });
} }
@@ -228,7 +506,6 @@ export class GameScene extends Scene {
Phaser.Math.Between(0, GAME_HEIGHT), Phaser.Math.Between(0, GAME_HEIGHT),
'gwei' 'gwei'
).setAlpha(0.5).setScrollFactor(0.3).setDepth(-8); ).setAlpha(0.5).setScrollFactor(0.3).setDepth(-8);
const scene = this;
this.tweens.add({ this.tweens.add({
targets: p, targets: p,
y: p.y - Phaser.Math.Between(100, 400), y: p.y - Phaser.Math.Between(100, 400),
@@ -236,7 +513,7 @@ export class GameScene extends Scene {
duration: Phaser.Math.Between(3000, 7000), duration: Phaser.Math.Between(3000, 7000),
repeat: -1, repeat: -1,
delay: Phaser.Math.Between(0, 4000), delay: Phaser.Math.Between(0, 4000),
onRepeat: function() { onRepeat: () => {
p.y = Phaser.Math.Between(0, GAME_HEIGHT); p.y = Phaser.Math.Between(0, GAME_HEIGHT);
p.x = Phaser.Math.Between(0, GAME_WIDTH); p.x = Phaser.Math.Between(0, GAME_WIDTH);
p.setAlpha(0.5); p.setAlpha(0.5);
@@ -246,52 +523,14 @@ export class GameScene extends Scene {
} }
createJumpParticles(x, y) { createJumpParticles(x, y) {
for (let i = 0; i < 8; i++) { this.particles.burstJump(x, y);
const size = Phaser.Math.Between(4, 7);
const p = this.add.rectangle(x, y, size, size, 0xa855f7).setAlpha(0.9);
this.tweens.add({
targets: p,
x: x + Phaser.Math.Between(-35, 35),
y: y + Phaser.Math.Between(10, 45),
alpha: 0,
scale: 0,
angle: Phaser.Math.Between(-90, 90),
duration: 250,
onComplete: () => p.destroy(),
});
}
} }
createPowerupParticles(x, y) { createPowerupParticles(x, y) {
for (let i = 0; i < 10; i++) { this.particles.burstPowerup(x, y);
const color = [0xffd700, 0xa855f7, 0x22c55e][Phaser.Math.Between(0, 2)];
const p = this.add.rectangle(x, y, 5, 5, color).setAlpha(1);
this.tweens.add({
targets: p,
x: x + Phaser.Math.Between(-50, 50),
y: y + Phaser.Math.Between(-50, 50),
alpha: 0,
scale: 0,
duration: 600,
onComplete: () => p.destroy(),
});
}
} }
createExplosion(x, y) { createExplosion(x, y) {
for (let i = 0; i < 12; i++) { this.particles.burstExplosion(x, y);
const p = this.add.rectangle(x, y, 6, 6, 0xef4444).setAlpha(1);
const angle = Phaser.Math.Between(0, 360);
const dist = Phaser.Math.Between(30, 80);
this.tweens.add({
targets: p,
x: x + Math.cos(angle * Math.PI / 180) * dist,
y: y + Math.sin(angle * Math.PI / 180) * dist,
alpha: 0,
scale: 0,
duration: 500,
onComplete: () => p.destroy(),
});
}
} }
} }

View File

@@ -1,4 +1,11 @@
import { Scene } from 'phaser'; import { Scene } from 'phaser';
import { sound } from '../managers/SoundManager.js';
import { AchievementsManager } from '../managers/AchievementsManager.js';
import { StatsManager } from '../managers/StatsManager.js';
import { ParticleManager } from '../managers/ParticleManager.js';
import { createButton } from '../utils/ui.js';
import { storage, KEYS } from '../utils/storage.js';
import { todaySeed } from '../utils/random.js';
export class MenuScene extends Scene { export class MenuScene extends Scene {
constructor() { constructor() {
@@ -8,69 +15,60 @@ export class MenuScene extends Scene {
create() { create() {
const { width, height } = this.scale; const { width, height } = this.scale;
// Background
this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg'); this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg');
// Title this.add.text(width / 2, height * 0.16, 'NADDIE JUMP', {
this.add.text(width / 2, height * 0.18, 'NADDIE JUMP', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '38px', fontSize: '38px',
color: '#d8b4fe', color: '#d8b4fe',
align: 'center', align: 'center',
}).setOrigin(0.5).setShadow(4, 4, '#581c87', 0, false, true); }).setOrigin(0.5).setShadow(4, 4, '#581c87', 0, false, true);
this.add.text(width / 2, height * 0.27, 'MONAD EDITION', { this.add.text(width / 2, height * 0.25, 'MONAD EDITION', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '14px', fontSize: '14px',
color: '#a855f7', color: '#a855f7',
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
// Floating Naddie preview const preview = this.add.image(width / 2, height * 0.44, 'player_idle').setScale(0.55);
const preview = this.add.image(width / 2, height * 0.48, 'player_idle') this.tweens.add({ targets: preview, y: height * 0.44 - 15, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
.setScale(0.55); this.tweens.add({ targets: preview, angle: 5, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({
targets: preview, createButton(this, width / 2, height * 0.585, 'START GAME', () => this.startGame(), {
y: height * 0.48 - 15, width: 280, height: 54, fontSize: '15px',
duration: 1400,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
this.tweens.add({
targets: preview,
angle: 5,
duration: 2000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
}); });
// Start button this.add.text(width / 2, height * 0.635, 'ENTER / SPACE / TAP', {
this.createButton(width / 2, height * 0.68, 'START GAME', () => {
this.scene.start('GameScene');
});
this.add.text(width / 2, height * 0.74, 'Press ENTER or SPACE to start', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '10px', fontSize: '9px',
color: '#888', color: '#888',
align: 'center', align: 'center',
}).setOrigin(0.5); }).setOrigin(0.5);
this.input.keyboard.on('keydown-ENTER', () => { this.input.keyboard.on('keydown-ENTER', () => this.startGame());
this.scene.start('GameScene'); this.input.keyboard.on('keydown-SPACE', () => this.startGame());
});
this.input.keyboard.on('keydown-SPACE', () => { createButton(this, width / 2, height * 0.69, 'DAILY CHALLENGE', () => this.startDaily(), {
this.scene.start('GameScene'); width: 280, height: 46, fontSize: '12px', bgColor: 0x854d0e, hoverColor: 0xa16207, strokeColor: 0xffd700,
}); });
// Leaderboard button createButton(this, width / 2 - 75, height * 0.775, 'BOARD', () => this.showLeaderboard(), {
this.createButton(width / 2, height * 0.78, 'LEADERBOARD', () => { width: 140, height: 42, fontSize: '11px',
this.showLeaderboard(); });
createButton(this, width / 2 + 75, height * 0.775, 'BADGES', () => this.showAchievements(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 - 75, height * 0.85, 'STATS', () => this.showStats(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 + 75, height * 0.85, 'SETTINGS', () => this.showSettings(), {
width: 140, height: 42, fontSize: '11px',
}); });
this.add.text(width / 2, height * 0.92, 'Web3 integration coming soon', { this.createMuteButton();
this.add.text(width / 2, height * 0.95, 'Web3 integration coming soon', {
fontFamily: '"Press Start 2P", monospace', fontFamily: '"Press Start 2P", monospace',
fontSize: '9px', fontSize: '9px',
color: '#444', color: '#444',
@@ -78,30 +76,38 @@ export class MenuScene extends Scene {
}).setOrigin(0.5); }).setOrigin(0.5);
this.createAmbientParticles(); this.createAmbientParticles();
this.input.once('pointerdown', () => { sound.init(); sound.resume(); });
this.input.keyboard.once('keydown', () => { sound.init(); sound.resume(); });
} }
createButton(x, y, text, callback) { startGame() {
const bg = this.add.rectangle(x, y, 280, 56, 0x581c87) sound.init();
.setStrokeStyle(3, 0xa855f7) sound.resume();
.setInteractive({ useHandCursor: true }); sound.click();
this.scene.start('GameScene', { mode: 'normal' });
}
const label = this.add.text(x, y, text, { startDaily() {
fontFamily: '"Press Start 2P", monospace', sound.init();
fontSize: '15px', sound.resume();
color: '#ffffff', sound.click();
}).setOrigin(0.5); this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
}
bg.on('pointerover', () => { createMuteButton() {
bg.setFillStyle(0x7e22ce); const { width } = this.scale;
this.tweens.add({ targets: [bg, label], scaleX: 1.05, scaleY: 1.05, duration: 100 }); const icon = this.add.text(width - 30, 30, sound.isMuted() ? '🔇' : '🔊', {
fontSize: '24px',
}).setOrigin(0.5).setInteractive({ useHandCursor: true });
icon.on('pointerdown', () => {
sound.init();
const next = !sound.isMuted();
sound.setMuted(next);
icon.setText(next ? '🔇' : '🔊');
if (!next) sound.click();
}); });
bg.on('pointerout', () => {
bg.setFillStyle(0x581c87);
this.tweens.add({ targets: [bg, label], scaleX: 1, scaleY: 1, duration: 100 });
});
bg.on('pointerdown', callback);
return { bg, label };
} }
showLeaderboard() { showLeaderboard() {
@@ -126,7 +132,7 @@ export class MenuScene extends Scene {
{ rank: 1, addr: '0xMonad...Dev', score: 99999 }, { rank: 1, addr: '0xMonad...Dev', score: 99999 },
{ rank: 2, addr: '0xAlice...xyz', score: 87500 }, { rank: 2, addr: '0xAlice...xyz', score: 87500 },
{ rank: 3, addr: '0xBob...abc', score: 74200 }, { rank: 3, addr: '0xBob...abc', score: 74200 },
{ rank: 4, addr: '0xYou', score: parseInt(localStorage.getItem('naddie_best_score') || '0', 10) }, { rank: 4, addr: '0xYou', score: storage.getInt(KEYS.best, 0) },
]; ];
mock.forEach((entry, i) => { mock.forEach((entry, i) => {
const y = height * 0.32 + i * 55; const y = height * 0.32 + i * 55;
@@ -138,14 +144,171 @@ export class MenuScene extends Scene {
}); });
close.on('pointerdown', () => { close.on('pointerdown', () => {
overlay.destroy(); sound.click();
panel.destroy(); overlay.destroy(); panel.destroy(); title.destroy(); close.destroy();
title.destroy();
close.destroy();
rows.forEach(r => { r.rank.destroy(); r.addr.destroy(); r.score.destroy(); }); rows.forEach(r => { r.rank.destroy(); r.addr.destroy(); r.score.destroy(); });
}); });
} }
showAchievements() {
const { width, height } = this.scale;
const all = AchievementsManager.getAll();
const unlocked = storage.getJSON(KEYS.achievements, {}) || {};
const elements = [];
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100);
const panel = this.add.rectangle(width / 2, height / 2, 420, 700, 0x1a0533).setStrokeStyle(3, 0xa855f7).setDepth(101);
const total = all.length;
const got = Object.keys(unlocked).length;
const title = this.add.text(width / 2, height * 0.12, `ACHIEVEMENTS ${got}/${total}`, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '14px',
color: '#d8b4fe',
}).setOrigin(0.5).setDepth(102);
elements.push(title);
const close = this.add.text(width / 2 + 190, height * 0.12 - 5, 'X', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '18px',
color: '#fff',
}).setOrigin(0.5).setDepth(102).setInteractive({ useHandCursor: true });
elements.push(close);
all.forEach((def, i) => {
const y = height * 0.18 + i * 55;
const isGot = !!unlocked[def.id];
const icon = this.add.text(width / 2 - 180, y, isGot ? '★' : '☆', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '18px',
color: isGot ? '#ffd700' : '#555',
}).setOrigin(0.5).setDepth(102);
const nameText = this.add.text(width / 2 - 150, y - 8, def.title, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '10px',
color: isGot ? '#ffd700' : '#888',
}).setOrigin(0, 0.5).setDepth(102);
const descText = this.add.text(width / 2 - 150, y + 10, def.desc, {
fontFamily: '"Press Start 2P", monospace',
fontSize: '8px',
color: '#d8b4fe',
}).setOrigin(0, 0.5).setDepth(102);
elements.push(icon, nameText, descText);
});
close.on('pointerdown', () => {
sound.click();
overlay.destroy(); panel.destroy();
elements.forEach((e) => e.destroy());
});
}
_openModal(titleText, panelH = 480) {
const { width, height } = this.scale;
const elements = [];
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.75).setDepth(100)
.setInteractive();
const panel = this.add.rectangle(width / 2, height / 2, 420, panelH, 0x1a0533).setStrokeStyle(3, 0xa855f7).setDepth(101);
const title = this.add.text(width / 2, height / 2 - panelH / 2 + 34, titleText, {
fontFamily: '"Press Start 2P", monospace', fontSize: '15px', color: '#d8b4fe',
}).setOrigin(0.5).setDepth(102);
const close = this.add.text(width / 2 + 185, height / 2 - panelH / 2 + 22, 'X', {
fontFamily: '"Press Start 2P", monospace', fontSize: '18px', color: '#fff',
}).setOrigin(0.5).setDepth(102).setInteractive({ useHandCursor: true });
elements.push(overlay, panel, title, close);
const api = {
elements,
add: (obj) => { obj.setDepth(102); elements.push(obj); return obj; },
close: () => { sound.click(); elements.forEach((e) => e.destroy()); },
};
close.on('pointerdown', api.close);
return api;
}
showStats() {
const { width, height } = this.scale;
const s = StatsManager.load();
const m = this._openModal('STATS', 420);
const rows = [
['Games played', s.gamesPlayed],
['Total jumps', s.totalJumps],
['Enemies stomped', s.totalStomps],
['Coins collected', s.totalCoins || 0],
['Blocks climbed', s.totalBlocks],
['Best combo', `x${(s.bestCombo || 1).toFixed(1)}`],
['Best score', storage.getInt(KEYS.best, 0)],
['$GWEI wallet', storage.getInt(KEYS.gwei, 0)],
];
rows.forEach((row, i) => {
const y = height / 2 - 130 + i * 38;
m.add(this.add.text(width / 2 - 170, y, row[0], {
fontFamily: '"Press Start 2P", monospace', fontSize: '10px', color: '#d8b4fe',
}).setOrigin(0, 0.5));
m.add(this.add.text(width / 2 + 170, y, String(row[1]), {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#ffd700',
}).setOrigin(1, 0.5));
});
}
showSettings() {
const { width, height } = this.scale;
const m = this._openModal('SETTINGS', 460);
const cx = width / 2;
let baseY = height / 2 - 130;
// Volume stepper
m.add(this.add.text(cx, baseY, 'VOLUME', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const volText = this.add.text(cx, baseY + 34, `${Math.round(sound.getVolume() * 100)}%`, {
fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#ffd700',
}).setOrigin(0.5);
m.add(volText);
const stepVol = (delta) => {
sound.init();
sound.setVolume(Math.round((sound.getVolume() + delta) * 10) / 10);
if (sound.isMuted() && sound.getVolume() > 0) sound.setMuted(false);
volText.setText(`${Math.round(sound.getVolume() * 100)}%`);
sound.click();
};
m.add(createButton(this, cx - 90, baseY + 34, '-', () => stepVol(-0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
m.add(createButton(this, cx + 90, baseY + 34, '+', () => stepVol(0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
// Particle quality toggle
baseY += 96;
m.add(this.add.text(cx, baseY, 'PARTICLE QUALITY', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const currentQ = () => storage.getItem(KEYS.particleQuality, ParticleManager.resolveQuality(this));
const qBtn = createButton(this, cx, baseY + 34, currentQ().toUpperCase(), () => {
const next = currentQ() === 'low' ? 'high' : 'low';
storage.setItem(KEYS.particleQuality, next);
qBtn.label.setText(next.toUpperCase());
sound.click();
}, { width: 160, height: 38, fontSize: '12px' });
m.add(qBtn.bg); m.add(qBtn.label);
// Reset progress (two-step confirm)
baseY += 96;
let armed = false;
const resetBtn = createButton(this, cx, baseY + 10, 'RESET PROGRESS', () => {
if (!armed) {
armed = true;
resetBtn.label.setText('TAP AGAIN!');
resetBtn.bg.setFillStyle(0x7f1d1d);
return;
}
StatsManager.reset();
storage.removeItem(KEYS.best);
storage.removeItem(KEYS.achievements);
storage.removeItem(KEYS.gwei);
resetBtn.label.setText('DONE');
sound.click();
}, { width: 240, height: 42, fontSize: '12px', bgColor: 0x581c87, hoverColor: 0x7e22ce });
m.add(resetBtn.bg); m.add(resetBtn.label);
}
createAmbientParticles() { createAmbientParticles() {
const { width, height } = this.scale; const { width, height } = this.scale;
for (let i = 0; i < 40; i++) { for (let i = 0; i < 40; i++) {

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

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

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

@@ -0,0 +1,93 @@
/**
* 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',
gwei: 'naddie_gwei_total',
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 (_) {}
},
};

51
src/utils/ui.js Normal file
View File

@@ -0,0 +1,51 @@
import { sound } from '../managers/SoundManager.js';
const DEFAULTS = {
width: 260,
height: 48,
fontSize: '13px',
bgColor: 0x581c87,
hoverColor: 0x7e22ce,
strokeColor: 0xa855f7,
strokeWidth: 2,
textColor: '#ffffff',
hoverScale: 1.04,
playClick: true,
};
export function createButton(scene, x, y, text, callback, options = {}) {
const opt = { ...DEFAULTS, ...options };
const bg = scene.add.rectangle(x, y, opt.width, opt.height, opt.bgColor)
.setStrokeStyle(opt.strokeWidth, opt.strokeColor)
.setInteractive({ useHandCursor: true });
const label = scene.add.text(x, y, text, {
fontFamily: '"Press Start 2P", monospace',
fontSize: opt.fontSize,
color: opt.textColor,
}).setOrigin(0.5);
bg.on('pointerover', () => {
bg.setFillStyle(opt.hoverColor);
scene.tweens.add({ targets: [bg, label], scaleX: opt.hoverScale, scaleY: opt.hoverScale, duration: 100 });
});
bg.on('pointerout', () => {
bg.setFillStyle(opt.bgColor);
scene.tweens.add({ targets: [bg, label], scaleX: 1, scaleY: 1, duration: 100 });
});
bg.on('pointerdown', () => {
if (opt.playClick) sound.click();
callback();
});
return { bg, label, destroy: () => { bg.destroy(); label.destroy(); } };
}
export function pixelText(scene, x, y, text, size = '14px', color = '#ffffff') {
return scene.add.text(x, y, text, {
fontFamily: '"Press Start 2P", monospace',
fontSize: size,
color,
}).setOrigin(0.5);
}