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.
This commit is contained in:
@@ -13,9 +13,10 @@ export const PLATFORM_GAP_MIN = 60;
|
||||
export const PLATFORM_GAP_MAX = 120;
|
||||
|
||||
export const SPAWN_RATES = {
|
||||
stable: 0.60,
|
||||
moving: 0.20,
|
||||
breaking: 0.15,
|
||||
stable: 0.55,
|
||||
moving: 0.18,
|
||||
breaking: 0.12,
|
||||
reorg: 0.10,
|
||||
genesis: 0.05,
|
||||
};
|
||||
|
||||
@@ -37,6 +38,13 @@ export const DIFFICULTY = {
|
||||
maxEnemyRate: 0.30,
|
||||
};
|
||||
|
||||
// Height (px climbed) at which harder content starts appearing.
|
||||
export const UNLOCK = {
|
||||
reorg: 600,
|
||||
failedTx: 800,
|
||||
mevBot: 1500,
|
||||
};
|
||||
|
||||
export const SCORE = {
|
||||
basePoints: 10,
|
||||
genesisBonus: 50,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Physics } from 'phaser';
|
||||
import { GAME_WIDTH } from '../config/game.config.js';
|
||||
import { rng } from '../utils/random.js';
|
||||
|
||||
export class Enemy extends Physics.Arcade.Sprite {
|
||||
constructor(scene, x, y, type = 'bug') {
|
||||
@@ -14,27 +15,38 @@ export class Enemy extends Physics.Arcade.Sprite {
|
||||
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 = Phaser.Math.Between(60, 140) * (Math.random() < 0.5 ? 1 : -1);
|
||||
this.speed = rng.between(60, 140) * rng.sign();
|
||||
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 = Phaser.Math.Between(80, 140);
|
||||
this.driftAmplitude = Phaser.Math.Between(20, 60);
|
||||
this.driftFreq = Phaser.Math.FloatBetween(0.001, 0.003);
|
||||
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) {
|
||||
super.preUpdate(time, delta);
|
||||
const dt = delta / 1000;
|
||||
|
||||
if (this.enemyType === 'bug') {
|
||||
this.x += this.speed * (delta / 1000);
|
||||
this.x += this.speed * dt;
|
||||
if (Math.abs(this.x - this.startX) > this.patrolRange) {
|
||||
this.speed *= -1;
|
||||
this.setFlipX(this.speed < 0);
|
||||
@@ -42,12 +54,21 @@ export class Enemy extends Physics.Arcade.Sprite {
|
||||
if (this.x < -60) this.x = GAME_WIDTH + 60;
|
||||
if (this.x > GAME_WIDTH + 60) this.x = -60;
|
||||
} else if (this.enemyType === 'failed_tx') {
|
||||
const dt = delta / 1000;
|
||||
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) {
|
||||
const dir = Math.sign(player.x - this.x);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Physics } from 'phaser';
|
||||
import { rng } from '../utils/random.js';
|
||||
|
||||
export class Platform extends Physics.Arcade.Sprite {
|
||||
constructor(scene, x, y, type = 'stable') {
|
||||
@@ -19,10 +20,22 @@ export class Platform extends Physics.Arcade.Sprite {
|
||||
if (isMoving) {
|
||||
this.body.setAllowGravity(false);
|
||||
this.body.setImmovable(true);
|
||||
this.moveSpeed = Phaser.Math.Between(50, 120) * (Phaser.Math.RND.frac() < 0.5 ? 1 : -1);
|
||||
this.moveRange = Phaser.Math.Between(60, 160);
|
||||
this.moveSpeed = rng.between(50, 120) * rng.sign();
|
||||
this.moveRange = rng.between(60, 160);
|
||||
} else if (type === 'breaking') {
|
||||
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') {
|
||||
this.setTint(0xffd700);
|
||||
this.genesisGlow = scene.add.ellipse(x, y + 10, 100, 30, 0xffd700, 0.25)
|
||||
@@ -65,6 +78,26 @@ export class Platform extends Physics.Arcade.Sprite {
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { Spring } from '../entities/Spring.js';
|
||||
import { PropellerHat } from '../entities/PropellerHat.js';
|
||||
import { Rocket } from '../entities/Rocket.js';
|
||||
import { Enemy } from '../entities/Enemy.js';
|
||||
import { rng } from '../utils/random.js';
|
||||
import {
|
||||
GAME_WIDTH, PLATFORM_GAP_MIN, PLATFORM_GAP_MAX,
|
||||
SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY,
|
||||
SPAWN_RATES, POWERUP_RATES, ENEMY_RATES, DIFFICULTY, UNLOCK,
|
||||
} from '../config/game.config.js';
|
||||
|
||||
export class PlatformManager {
|
||||
@@ -45,31 +46,36 @@ export class PlatformManager {
|
||||
Math.floor(difficultyLevel / 1000) * DIFFICULTY.gapIncreasePer1000,
|
||||
DIFFICULTY.maxGap - DIFFICULTY.initialGap
|
||||
);
|
||||
const gap = Phaser.Math.Between(
|
||||
const gap = rng.between(
|
||||
PLATFORM_GAP_MIN + gapIncrease,
|
||||
Math.min(PLATFORM_GAP_MAX + gapIncrease, DIFFICULTY.maxGap)
|
||||
);
|
||||
this.highestY -= gap;
|
||||
|
||||
const x = Phaser.Math.Between(60, GAME_WIDTH - 60);
|
||||
const rand = Math.random();
|
||||
const x = rng.between(60, GAME_WIDTH - 60);
|
||||
const rand = rng.frac();
|
||||
let type;
|
||||
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 + 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';
|
||||
|
||||
// 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);
|
||||
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.maybeSpawnEnemy(x, this.highestY, difficultyLevel);
|
||||
}
|
||||
|
||||
maybeSpawnPowerUp(platformX, y) {
|
||||
const rand = Math.random();
|
||||
const rand = rng.frac();
|
||||
let type = null;
|
||||
if (rand < POWERUP_RATES.spring) type = 'spring';
|
||||
else if (rand < POWERUP_RATES.spring + POWERUP_RATES.propeller) type = 'propeller';
|
||||
@@ -77,7 +83,7 @@ export class PlatformManager {
|
||||
|
||||
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') {
|
||||
this.powerups.add(new Spring(this.scene, x, y));
|
||||
} else if (type === 'propeller') {
|
||||
@@ -93,15 +99,13 @@ export class PlatformManager {
|
||||
DIFFICULTY.maxEnemyRate
|
||||
);
|
||||
const totalRate = Math.min(ENEMY_RATES.bug + enemyBonus, DIFFICULTY.maxEnemyRate);
|
||||
if (Math.random() >= totalRate) return;
|
||||
if (rng.frac() >= totalRate) return;
|
||||
|
||||
// Failed-Tx type unlocks at higher difficulty
|
||||
const failedTxAllowed = difficultyLevel > 800;
|
||||
const type = failedTxAllowed && Math.random() < 0.30 ? 'failed_tx' : 'bug';
|
||||
const type = this._rollEnemyType(difficultyLevel);
|
||||
|
||||
const offset = Phaser.Math.Between(-60, 60);
|
||||
const offset = rng.between(-60, 60);
|
||||
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;
|
||||
let tooClose = false;
|
||||
@@ -115,6 +119,19 @@ export class PlatformManager {
|
||||
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() {
|
||||
return this.platforms;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ export class ScoreManager {
|
||||
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) {
|
||||
this.hudCombo.setText(`Combo x${this.comboMultiplier.toFixed(1)}`);
|
||||
|
||||
52
src/managers/StatsManager.js
Normal file
52
src/managers/StatsManager.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { storage, KEYS } from '../utils/storage.js';
|
||||
|
||||
const DEFAULTS = {
|
||||
gamesPlayed: 0,
|
||||
totalJumps: 0,
|
||||
totalStomps: 0,
|
||||
totalBlocks: 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, 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;
|
||||
}
|
||||
|
||||
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.totalBlocks += blockHeight || 0;
|
||||
t.bestCombo = Math.max(t.bestCombo, this.run.bestCombo);
|
||||
storage.setJSON(KEYS.stats, t);
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Scene } from 'phaser';
|
||||
import { sound } from '../managers/SoundManager.js';
|
||||
import { createButton } from '../utils/ui.js';
|
||||
import { todaySeed } from '../utils/random.js';
|
||||
|
||||
export class GameOverScene extends Scene {
|
||||
constructor() {
|
||||
@@ -11,6 +12,17 @@ export class GameOverScene extends Scene {
|
||||
this.finalScore = data.score || 0;
|
||||
this.blockHeight = data.blockHeight || 0;
|
||||
this.isNewBest = data.isNewBest || false;
|
||||
this.mode = data.mode || 'normal';
|
||||
this.dailyBest = data.dailyBest;
|
||||
}
|
||||
|
||||
retry() {
|
||||
sound.click();
|
||||
if (this.mode === 'daily') {
|
||||
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
|
||||
} else {
|
||||
this.scene.start('GameScene', { mode: 'normal' });
|
||||
}
|
||||
}
|
||||
|
||||
create() {
|
||||
@@ -49,7 +61,15 @@ export class GameOverScene extends Scene {
|
||||
color: '#a855f7',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
createButton(this, width / 2, height * 0.72, 'RETRY', () => this.scene.start('GameScene'));
|
||||
if (this.mode === 'daily') {
|
||||
this.add.text(width / 2, height * 0.665, `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', {
|
||||
@@ -59,13 +79,7 @@ export class GameOverScene extends Scene {
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.input.keyboard.once('keydown-ENTER', () => {
|
||||
sound.click();
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
this.input.keyboard.once('keydown-SPACE', () => {
|
||||
sound.click();
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
this.input.keyboard.once('keydown-ENTER', () => this.retry());
|
||||
this.input.keyboard.once('keydown-SPACE', () => this.retry());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import { ScoreManager } from '../managers/ScoreManager.js';
|
||||
import { AchievementsManager } from '../managers/AchievementsManager.js';
|
||||
import { EffectsManager } from '../managers/EffectsManager.js';
|
||||
import { ParticleManager } from '../managers/ParticleManager.js';
|
||||
import { 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 } from '../config/game.config.js';
|
||||
|
||||
export class GameScene extends Scene {
|
||||
@@ -15,10 +17,18 @@ export class GameScene extends Scene {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
init(data) {
|
||||
this.mode = (data && data.mode) || 'normal';
|
||||
this.seed = (data && data.seed) || `${Date.now()}-${Math.random()}`;
|
||||
}
|
||||
|
||||
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')
|
||||
.setScrollFactor(0)
|
||||
.setDepth(-10);
|
||||
@@ -49,6 +59,7 @@ export class GameScene extends Scene {
|
||||
|
||||
this.platformManager = new PlatformManager(this);
|
||||
this.achievements = new AchievementsManager(this);
|
||||
this.stats = new StatsManager(this);
|
||||
this.scoreManager = new ScoreManager(this);
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
@@ -76,6 +87,14 @@ export class GameScene extends Scene {
|
||||
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());
|
||||
|
||||
// Auto-pause when the tab/window is hidden — avoids a delta-spike teleport
|
||||
@@ -162,6 +181,7 @@ export class GameScene extends Scene {
|
||||
this.lastJumpY = player.y;
|
||||
if (player.jump()) {
|
||||
sound.jump();
|
||||
if (this.stats) this.stats.onJump();
|
||||
}
|
||||
this.createJumpParticles(player.x, player.y + player.displayHeight / 2 + 3);
|
||||
}
|
||||
@@ -214,6 +234,7 @@ export class GameScene extends Scene {
|
||||
sound.stomp();
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -236,9 +257,24 @@ export class GameScene extends Scene {
|
||||
const score = this.scoreManager.score;
|
||||
const blockHeight = this.scoreManager.blockHeight;
|
||||
|
||||
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.scoreManager.destroy();
|
||||
this.scene.start('GameOverScene', { score, blockHeight, isNewBest });
|
||||
this.scene.start('GameOverScene', {
|
||||
score, blockHeight, isNewBest, mode: this.mode, dailyBest,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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() {
|
||||
@@ -32,13 +35,13 @@ export class MenuScene extends Scene {
|
||||
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.62, 'START GAME', () => this.startGame(), {
|
||||
width: 280, height: 56, fontSize: '15px',
|
||||
createButton(this, width / 2, height * 0.585, 'START GAME', () => this.startGame(), {
|
||||
width: 280, height: 54, fontSize: '15px',
|
||||
});
|
||||
|
||||
this.add.text(width / 2, height * 0.68, 'ENTER / SPACE / TAP', {
|
||||
this.add.text(width / 2, height * 0.635, 'ENTER / SPACE / TAP', {
|
||||
fontFamily: '"Press Start 2P", monospace',
|
||||
fontSize: '10px',
|
||||
fontSize: '9px',
|
||||
color: '#888',
|
||||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
@@ -46,16 +49,26 @@ export class MenuScene extends Scene {
|
||||
this.input.keyboard.on('keydown-ENTER', () => this.startGame());
|
||||
this.input.keyboard.on('keydown-SPACE', () => this.startGame());
|
||||
|
||||
createButton(this, width / 2 - 75, height * 0.78, 'BOARD', () => this.showLeaderboard(), {
|
||||
width: 140, height: 44, fontSize: '11px',
|
||||
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.78, 'BADGES', () => this.showAchievements(), {
|
||||
width: 140, height: 44, fontSize: '11px',
|
||||
|
||||
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.94, 'Web3 integration coming soon', {
|
||||
this.add.text(width / 2, height * 0.95, 'Web3 integration coming soon', {
|
||||
fontFamily: '"Press Start 2P", monospace',
|
||||
fontSize: '9px',
|
||||
color: '#444',
|
||||
@@ -72,7 +85,14 @@ export class MenuScene extends Scene {
|
||||
sound.init();
|
||||
sound.resume();
|
||||
sound.click();
|
||||
this.scene.start('GameScene');
|
||||
this.scene.start('GameScene', { mode: 'normal' });
|
||||
}
|
||||
|
||||
startDaily() {
|
||||
sound.init();
|
||||
sound.resume();
|
||||
sound.click();
|
||||
this.scene.start('GameScene', { mode: 'daily', seed: todaySeed() });
|
||||
}
|
||||
|
||||
createMuteButton() {
|
||||
@@ -183,6 +203,109 @@ export class MenuScene extends Scene {
|
||||
});
|
||||
}
|
||||
|
||||
_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++) {
|
||||
|
||||
45
src/utils/random.js
Normal file
45
src/utils/random.js
Normal 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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user