From 6f5b4d83f7574a628068c7e9ff4710a0c02510f6 Mon Sep 17 00:00:00 2001 From: AnRil Date: Fri, 29 May 2026 13:29:34 +0700 Subject: [PATCH] =?UTF-8?q?Phase=20B:=20new=20content=20=E2=80=94=20daily?= =?UTF-8?q?=20challenge,=20enemies,=20platform,=20settings,=20stats?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_); 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. --- src/config/game.config.js | 14 +++- src/entities/Enemy.js | 35 ++++++-- src/entities/Platform.js | 37 ++++++++- src/managers/PlatformManager.js | 43 +++++++--- src/managers/ScoreManager.js | 3 + src/managers/StatsManager.js | 52 ++++++++++++ src/scenes/GameOverScene.js | 32 +++++-- src/scenes/GameScene.js | 38 ++++++++- src/scenes/MenuScene.js | 143 +++++++++++++++++++++++++++++--- src/utils/random.js | 45 ++++++++++ 10 files changed, 397 insertions(+), 45 deletions(-) create mode 100644 src/managers/StatsManager.js create mode 100644 src/utils/random.js diff --git a/src/config/game.config.js b/src/config/game.config.js index e3ed6cc..6b05d7b 100644 --- a/src/config/game.config.js +++ b/src/config/game.config.js @@ -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, diff --git a/src/entities/Enemy.js b/src/entities/Enemy.js index a399119..4e3b91a 100644 --- a/src/entities/Enemy.js +++ b/src/entities/Enemy.js @@ -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; } } } diff --git a/src/entities/Platform.js b/src/entities/Platform.js index 56890ff..ee0d7c6 100644 --- a/src/entities/Platform.js +++ b/src/entities/Platform.js @@ -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; } diff --git a/src/managers/PlatformManager.js b/src/managers/PlatformManager.js index 7e129d2..60e819b 100644 --- a/src/managers/PlatformManager.js +++ b/src/managers/PlatformManager.js @@ -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; } diff --git a/src/managers/ScoreManager.js b/src/managers/ScoreManager.js index 0b304e3..7f5abc9 100644 --- a/src/managers/ScoreManager.js +++ b/src/managers/ScoreManager.js @@ -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)}`); diff --git a/src/managers/StatsManager.js b/src/managers/StatsManager.js new file mode 100644 index 0000000..8806a7d --- /dev/null +++ b/src/managers/StatsManager.js @@ -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; + } +} diff --git a/src/scenes/GameOverScene.js b/src/scenes/GameOverScene.js index 6d22737..786e657 100644 --- a/src/scenes/GameOverScene.js +++ b/src/scenes/GameOverScene.js @@ -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()); } } diff --git a/src/scenes/GameScene.js b/src/scenes/GameScene.js index 356bd7d..f6edb01 100644 --- a/src/scenes/GameScene.js +++ b/src/scenes/GameScene.js @@ -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, + }); }); } diff --git a/src/scenes/MenuScene.js b/src/scenes/MenuScene.js index 7c16528..a9a7911 100644 --- a/src/scenes/MenuScene.js +++ b/src/scenes/MenuScene.js @@ -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++) { diff --git a/src/utils/random.js b/src/utils/random.js new file mode 100644 index 0000000..9082ed1 --- /dev/null +++ b/src/utils/random.js @@ -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}`; +}