Files
naddie-jump/src/scenes/MenuScene.js
AnRil 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

325 lines
12 KiB
JavaScript

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 {
constructor() {
super({ key: 'MenuScene' });
}
create() {
const { width, height } = this.scale;
this.add.tileSprite(width / 2, height / 2, width, height, 'gridBg');
this.add.text(width / 2, height * 0.16, 'NADDIE JUMP', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '38px',
color: '#d8b4fe',
align: 'center',
}).setOrigin(0.5).setShadow(4, 4, '#581c87', 0, false, true);
this.add.text(width / 2, height * 0.25, 'MONAD EDITION', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '14px',
color: '#a855f7',
align: 'center',
}).setOrigin(0.5);
const preview = this.add.image(width / 2, height * 0.44, 'player_idle').setScale(0.55);
this.tweens.add({ targets: preview, y: height * 0.44 - 15, duration: 1400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: preview, angle: 5, duration: 2000, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
createButton(this, width / 2, height * 0.585, 'START GAME', () => this.startGame(), {
width: 280, height: 54, fontSize: '15px',
});
this.add.text(width / 2, height * 0.635, 'ENTER / SPACE / TAP', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#888',
align: 'center',
}).setOrigin(0.5);
this.input.keyboard.on('keydown-ENTER', () => this.startGame());
this.input.keyboard.on('keydown-SPACE', () => this.startGame());
createButton(this, width / 2, height * 0.69, 'DAILY CHALLENGE', () => this.startDaily(), {
width: 280, height: 46, fontSize: '12px', bgColor: 0x854d0e, hoverColor: 0xa16207, strokeColor: 0xffd700,
});
createButton(this, width / 2 - 75, height * 0.775, 'BOARD', () => this.showLeaderboard(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 + 75, height * 0.775, 'BADGES', () => this.showAchievements(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 - 75, height * 0.85, 'STATS', () => this.showStats(), {
width: 140, height: 42, fontSize: '11px',
});
createButton(this, width / 2 + 75, height * 0.85, 'SETTINGS', () => this.showSettings(), {
width: 140, height: 42, fontSize: '11px',
});
this.createMuteButton();
this.add.text(width / 2, height * 0.95, 'Web3 integration coming soon', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '9px',
color: '#444',
align: 'center',
}).setOrigin(0.5);
this.createAmbientParticles();
this.input.once('pointerdown', () => { sound.init(); sound.resume(); });
this.input.keyboard.once('keydown', () => { sound.init(); sound.resume(); });
}
startGame() {
sound.init();
sound.resume();
sound.click();
this.scene.start('GameScene', { mode: 'normal' });
}
startDaily() {
sound.init();
sound.resume();
sound.click();
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
}
createMuteButton() {
const { width } = this.scale;
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();
});
}
showLeaderboard() {
const { width, height } = this.scale;
const overlay = this.add.rectangle(width / 2, height / 2, width, height, 0x000000, 0.7).setDepth(100);
const panel = this.add.rectangle(width / 2, height / 2, 400, 440, 0x1a0533).setStrokeStyle(3, 0xa855f7).setDepth(101);
const title = this.add.text(width / 2, height * 0.22, 'LEADERBOARD', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '18px',
color: '#d8b4fe',
}).setOrigin(0.5).setDepth(102);
const close = this.add.text(width / 2 + 170, height * 0.22 - 80, 'X', {
fontFamily: '"Press Start 2P", monospace',
fontSize: '18px',
color: '#fff',
}).setOrigin(0.5).setDepth(102).setInteractive({ useHandCursor: true });
const rows = [];
const mock = [
{ rank: 1, addr: '0xMonad...Dev', score: 99999 },
{ rank: 2, addr: '0xAlice...xyz', score: 87500 },
{ rank: 3, addr: '0xBob...abc', score: 74200 },
{ rank: 4, addr: '0xYou', score: storage.getInt(KEYS.best, 0) },
];
mock.forEach((entry, i) => {
const y = height * 0.32 + i * 55;
rows.push({
rank: this.add.text(width / 2 - 160, y, `#${entry.rank}`, { fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#a855f7' }).setOrigin(0.5).setDepth(102),
addr: this.add.text(width / 2, y, entry.addr, { fontFamily: '"Press Start 2P", monospace', fontSize: '10px', color: '#fff' }).setOrigin(0.5).setDepth(102),
score: this.add.text(width / 2 + 150, y, String(entry.score), { fontFamily: '"Press Start 2P", monospace', fontSize: '12px', color: '#d8b4fe' }).setOrigin(0.5).setDepth(102),
});
});
close.on('pointerdown', () => {
sound.click();
overlay.destroy(); panel.destroy(); title.destroy(); close.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],
['Blocks climbed', s.totalBlocks],
['Best combo', `x${(s.bestCombo || 1).toFixed(1)}`],
['Best score', storage.getInt(KEYS.best, 0)],
];
rows.forEach((row, i) => {
const y = height / 2 - 100 + i * 42;
m.add(this.add.text(width / 2 - 170, y, row[0], {
fontFamily: '"Press Start 2P", monospace', fontSize: '10px', color: '#d8b4fe',
}).setOrigin(0, 0.5));
m.add(this.add.text(width / 2 + 170, y, String(row[1]), {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#ffd700',
}).setOrigin(1, 0.5));
});
}
showSettings() {
const { width, height } = this.scale;
const m = this._openModal('SETTINGS', 460);
const cx = width / 2;
let baseY = height / 2 - 130;
// Volume stepper
m.add(this.add.text(cx, baseY, 'VOLUME', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const volText = this.add.text(cx, baseY + 34, `${Math.round(sound.getVolume() * 100)}%`, {
fontFamily: '"Press Start 2P", monospace', fontSize: '13px', color: '#ffd700',
}).setOrigin(0.5);
m.add(volText);
const stepVol = (delta) => {
sound.init();
sound.setVolume(Math.round((sound.getVolume() + delta) * 10) / 10);
if (sound.isMuted() && sound.getVolume() > 0) sound.setMuted(false);
volText.setText(`${Math.round(sound.getVolume() * 100)}%`);
sound.click();
};
m.add(createButton(this, cx - 90, baseY + 34, '-', () => stepVol(-0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
m.add(createButton(this, cx + 90, baseY + 34, '+', () => stepVol(0.1), { width: 44, height: 36, fontSize: '14px' }).bg);
// Particle quality toggle
baseY += 96;
m.add(this.add.text(cx, baseY, 'PARTICLE QUALITY', {
fontFamily: '"Press Start 2P", monospace', fontSize: '11px', color: '#d8b4fe',
}).setOrigin(0.5));
const currentQ = () => storage.getItem(KEYS.particleQuality, ParticleManager.resolveQuality(this));
const qBtn = createButton(this, cx, baseY + 34, currentQ().toUpperCase(), () => {
const next = currentQ() === 'low' ? 'high' : 'low';
storage.setItem(KEYS.particleQuality, next);
qBtn.label.setText(next.toUpperCase());
sound.click();
}, { width: 160, height: 38, fontSize: '12px' });
m.add(qBtn.bg); m.add(qBtn.label);
// Reset progress (two-step confirm)
baseY += 96;
let armed = false;
const resetBtn = createButton(this, cx, baseY + 10, 'RESET PROGRESS', () => {
if (!armed) {
armed = true;
resetBtn.label.setText('TAP AGAIN!');
resetBtn.bg.setFillStyle(0x7f1d1d);
return;
}
StatsManager.reset();
storage.removeItem(KEYS.best);
storage.removeItem(KEYS.achievements);
resetBtn.label.setText('DONE');
sound.click();
}, { width: 240, height: 42, fontSize: '12px', bgColor: 0x581c87, hoverColor: 0x7e22ce });
m.add(resetBtn.bg); m.add(resetBtn.label);
}
createAmbientParticles() {
const { width, height } = this.scale;
for (let i = 0; i < 40; i++) {
const s = this.add.image(Phaser.Math.Between(0, width), Phaser.Math.Between(0, height), 'star');
s.setAlpha(Phaser.Math.FloatBetween(0.15, 0.7));
this.tweens.add({
targets: s,
y: s.y - Phaser.Math.Between(50, 250),
alpha: 0,
duration: Phaser.Math.Between(2000, 6000),
repeat: -1,
delay: Phaser.Math.Between(0, 3000),
});
}
}
}