Compare commits
15 Commits
d3f880d917
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| be1933f3ba | |||
| 7c6a792212 | |||
| 9c07c52d34 | |||
| 22dec51e93 | |||
| d7ae3a5c7e | |||
| b7fba447dc | |||
| 8cfe0b8a30 | |||
| 6f5b4d83f7 | |||
| fc1f12bb7e | |||
| 1062b2855a | |||
| 07b670fb09 | |||
| de1a3fcf56 | |||
| d509f1df4a | |||
| 57f9e2f282 | |||
| fd93da0a71 |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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
62
src/entities/Coin.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
33
src/entities/Magnet.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,6 +51,24 @@ 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 (this.state === 'rocket') {
|
||||||
|
this.rocketTimer -= delta;
|
||||||
|
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();
|
||||||
|
} 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) {
|
if (velocityX < 0) {
|
||||||
this.setFlipX(true);
|
this.setFlipX(true);
|
||||||
this.setAngle(-5);
|
this.setAngle(-5);
|
||||||
@@ -43,41 +78,82 @@ export class Player extends Physics.Arcade.Sprite {
|
|||||||
} else {
|
} else {
|
||||||
this.setAngle(0);
|
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.setVelocityY(ROCKET_VELOCITY);
|
|
||||||
if (this.rocketTimer <= 0) this.endPowerUp();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
33
src/entities/Shield.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/main.js
26
src/main.js
@@ -22,9 +22,33 @@ const config = {
|
|||||||
debug: false,
|
debug: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
fps: {
|
||||||
|
target: 60,
|
||||||
|
min: 30,
|
||||||
|
},
|
||||||
|
disableContextMenu: true,
|
||||||
scene: [BootScene, MenuScene, GameScene, GameOverScene],
|
scene: [BootScene, MenuScene, GameScene, GameOverScene],
|
||||||
pixelArt: false,
|
pixelArt: false,
|
||||||
antialias: true,
|
antialias: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
window.game = new Game(config);
|
const game = new Game(config);
|
||||||
|
window.game = game;
|
||||||
|
|
||||||
|
// Global crash guard: if an uncaught error happens during gameplay, recover to
|
||||||
|
// the menu instead of leaving a frozen canvas.
|
||||||
|
function recoverToMenu() {
|
||||||
|
try {
|
||||||
|
if (!game || !game.scene) return;
|
||||||
|
const inGame = game.scene.isActive('GameScene');
|
||||||
|
if (inGame) {
|
||||||
|
game.scene.stop('GameScene');
|
||||||
|
game.scene.start('MenuScene');
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
/* swallow — last-resort guard */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', recoverToMenu);
|
||||||
|
window.addEventListener('unhandledrejection', recoverToMenu);
|
||||||
|
|||||||
141
src/managers/AchievementsManager.js
Normal file
141
src/managers/AchievementsManager.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/managers/EffectsManager.js
Normal file
67
src/managers/EffectsManager.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
src/managers/ParticleManager.js
Normal file
225
src/managers/ParticleManager.js
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { GAME_WIDTH, GAME_HEIGHT } from '../config/game.config.js';
|
||||||
|
import { storage, KEYS } from '../utils/storage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns a fixed set of pooled Phaser particle emitters, created once per scene.
|
||||||
|
* All hot-path particle effects (jump, explosion, boost flame/wind, etc.) reuse
|
||||||
|
* these emitters instead of allocating GameObjects + tweens every frame.
|
||||||
|
*/
|
||||||
|
export class ParticleManager {
|
||||||
|
constructor(scene) {
|
||||||
|
this.scene = scene;
|
||||||
|
this.quality = ParticleManager.resolveQuality(scene);
|
||||||
|
this.low = this.quality === 'low';
|
||||||
|
this.emitters = {};
|
||||||
|
this._build();
|
||||||
|
}
|
||||||
|
|
||||||
|
static resolveQuality(scene) {
|
||||||
|
const saved = storage.getItem(KEYS.particleQuality, null);
|
||||||
|
if (saved === 'low' || saved === 'high') return saved;
|
||||||
|
const d = scene.sys.game.device;
|
||||||
|
const small = Math.min(window.innerWidth, window.innerHeight) < 500;
|
||||||
|
return (d.os.android || d.os.iOS || small) ? 'low' : 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
_q(high, low) {
|
||||||
|
return this.low ? low : high;
|
||||||
|
}
|
||||||
|
|
||||||
|
_build() {
|
||||||
|
const add = (key, texture, config, depth) => {
|
||||||
|
const e = this.scene.add.particles(0, 0, texture, { emitting: false, ...config });
|
||||||
|
e.setDepth(depth);
|
||||||
|
this.emitters[key] = e;
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Burst emitters (manual explode) ---
|
||||||
|
add('jump', 'px_square', {
|
||||||
|
lifespan: 260,
|
||||||
|
speed: { min: 40, max: 130 },
|
||||||
|
angle: { min: 250, max: 290 },
|
||||||
|
scale: { start: 0.9, end: 0 },
|
||||||
|
alpha: { start: 0.9, end: 0 },
|
||||||
|
rotate: { min: -180, max: 180 },
|
||||||
|
tint: [0xa855f7, 0xc084fc, 0x8b5cf6],
|
||||||
|
frequency: -1,
|
||||||
|
}, -2);
|
||||||
|
|
||||||
|
add('explosion', 'px_square', {
|
||||||
|
lifespan: 500,
|
||||||
|
speed: { min: 60, max: 170 },
|
||||||
|
angle: { min: 0, max: 360 },
|
||||||
|
scale: { start: 1.1, end: 0 },
|
||||||
|
alpha: { start: 1, end: 0 },
|
||||||
|
rotate: { min: -180, max: 180 },
|
||||||
|
tint: [0xef4444, 0xf97316, 0xfca5a5],
|
||||||
|
frequency: -1,
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
add('powerup', 'px_square', {
|
||||||
|
lifespan: 600,
|
||||||
|
speed: { min: 50, max: 150 },
|
||||||
|
angle: { min: 0, max: 360 },
|
||||||
|
scale: { start: 1, end: 0 },
|
||||||
|
alpha: { start: 1, end: 0 },
|
||||||
|
tint: [0xffd700, 0xa855f7, 0x22c55e],
|
||||||
|
frequency: -1,
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
add('puff', 'px_soft', {
|
||||||
|
lifespan: { min: 500, max: 800 },
|
||||||
|
speed: { min: 30, max: 70 },
|
||||||
|
angle: { min: 0, max: 360 },
|
||||||
|
scale: { start: 1.2, end: 2.2 },
|
||||||
|
alpha: { start: 0.55, end: 0 },
|
||||||
|
tint: [0x888888, 0xaaaaaa, 0x666666],
|
||||||
|
frequency: -1,
|
||||||
|
}, -2);
|
||||||
|
|
||||||
|
add('sparks', 'px_square', {
|
||||||
|
lifespan: 500,
|
||||||
|
speed: { min: 70, max: 150 },
|
||||||
|
angle: { min: 0, max: 360 },
|
||||||
|
scale: { start: 1, end: 0 },
|
||||||
|
alpha: { start: 1, end: 0 },
|
||||||
|
frequency: -1,
|
||||||
|
}, 1);
|
||||||
|
|
||||||
|
// --- Flow emitters (toggle start/stop, follow a position) ---
|
||||||
|
add('flame', 'px_soft', {
|
||||||
|
lifespan: { min: 360, max: 620 },
|
||||||
|
speedY: { min: 120, max: 260 },
|
||||||
|
speedX: { min: -45, max: 45 },
|
||||||
|
scale: { start: this._q(1.2, 0.9), end: 0 },
|
||||||
|
alpha: { start: 0.95, end: 0 },
|
||||||
|
tint: [0xffd700, 0xffaa00, 0xff6600, 0xff3300, 0xaa0000],
|
||||||
|
blendMode: 'ADD',
|
||||||
|
frequency: this._q(28, 55),
|
||||||
|
quantity: 1,
|
||||||
|
}, -2);
|
||||||
|
|
||||||
|
add('wind', 'px_streak', {
|
||||||
|
lifespan: { min: 300, max: 540 },
|
||||||
|
speedY: { min: 30, max: 80 },
|
||||||
|
speedX: { min: -25, max: 25 },
|
||||||
|
scale: { start: 1, end: 0.3 },
|
||||||
|
alpha: { start: 0.7, end: 0 },
|
||||||
|
tint: [0xffffff, 0xbbddff, 0x66aaff, 0x4477cc],
|
||||||
|
blendMode: 'ADD',
|
||||||
|
frequency: this._q(40, 80),
|
||||||
|
quantity: 1,
|
||||||
|
}, -2);
|
||||||
|
|
||||||
|
add('spring', 'px_square', {
|
||||||
|
lifespan: { min: 400, max: 620 },
|
||||||
|
speedY: { min: 60, max: 130 },
|
||||||
|
speedX: { min: -35, max: 35 },
|
||||||
|
scale: { start: 1, end: 0 },
|
||||||
|
alpha: { start: 0.9, end: 0 },
|
||||||
|
rotate: { min: -180, max: 180 },
|
||||||
|
tint: [0xffd700, 0xfff066, 0x22c55e, 0xa3e635],
|
||||||
|
frequency: this._q(35, 70),
|
||||||
|
quantity: 1,
|
||||||
|
}, -2);
|
||||||
|
|
||||||
|
add('speedline', 'px_streak', {
|
||||||
|
lifespan: { min: 280, max: 450 },
|
||||||
|
speedY: { min: 150, max: 300 },
|
||||||
|
scaleX: { start: 1, end: 1 },
|
||||||
|
scaleY: { start: 3.5, end: 1.5 },
|
||||||
|
alpha: { start: 0.45, end: 0 },
|
||||||
|
tint: 0xffffff,
|
||||||
|
frequency: -1,
|
||||||
|
}, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Bursts ---
|
||||||
|
burstJump(x, y) {
|
||||||
|
this.emitters.jump.explode(this._q(8, 4), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
burstExplosion(x, y) {
|
||||||
|
this.emitters.explosion.explode(this._q(14, 7), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
burstPowerup(x, y) {
|
||||||
|
this.emitters.powerup.explode(this._q(12, 6), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
puff(x, y) {
|
||||||
|
this.emitters.puff.explode(this._q(8, 4), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boost flow control (called each frame while active) ---
|
||||||
|
flameAt(x, y) {
|
||||||
|
const e = this.emitters.flame;
|
||||||
|
e.setPosition(x, y);
|
||||||
|
if (!e.emitting) e.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
windAt(x, y) {
|
||||||
|
const e = this.emitters.wind;
|
||||||
|
e.setPosition(x, y);
|
||||||
|
if (!e.emitting) e.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
springAt(x, y) {
|
||||||
|
const e = this.emitters.spring;
|
||||||
|
e.setPosition(x, y);
|
||||||
|
if (!e.emitting) e.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopFlow() {
|
||||||
|
if (this.emitters.flame.emitting) this.emitters.flame.stop();
|
||||||
|
if (this.emitters.wind.emitting) this.emitters.wind.stop();
|
||||||
|
if (this.emitters.spring.emitting) this.emitters.spring.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
speedLine() {
|
||||||
|
if (this.low) return;
|
||||||
|
const cam = this.scene.cameras.main;
|
||||||
|
const left = Math.random() < 0.5;
|
||||||
|
const x = left ? Phaser.Math.Between(0, 40) : Phaser.Math.Between(GAME_WIDTH - 40, GAME_WIDTH);
|
||||||
|
const y = cam.scrollY + Phaser.Math.Between(0, GAME_HEIGHT);
|
||||||
|
this.emitters.speedline.emitParticleAt(x, y, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Boost start burst: rings (rare, 2 sprites) + spark explode ---
|
||||||
|
boostBurst(x, y, type) {
|
||||||
|
let ringColor, sparkColor;
|
||||||
|
if (type === 'rocket') { ringColor = 0xff6600; sparkColor = 0xffd700; }
|
||||||
|
else if (type === 'spring') { ringColor = 0x22c55e; sparkColor = 0xffd700; }
|
||||||
|
else { ringColor = 0x88ccff; sparkColor = 0xffffff; }
|
||||||
|
|
||||||
|
this._ring(x, y, ringColor, 0.6, 380);
|
||||||
|
this._ring(x, y, sparkColor, 0.45, 520);
|
||||||
|
|
||||||
|
this.emitters.sparks.setParticleTint(sparkColor);
|
||||||
|
this.emitters.sparks.explode(this._q(type === 'rocket' ? 16 : 10, 6), x, y);
|
||||||
|
|
||||||
|
const shake = type === 'rocket' ? 0.006 : type === 'spring' ? 0.002 : 0.003;
|
||||||
|
const dur = type === 'rocket' ? 120 : type === 'spring' ? 60 : 80;
|
||||||
|
this.scene.cameras.main.shake(dur, shake);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ring(x, y, color, alpha, duration) {
|
||||||
|
const ring = this.scene.add.image(x, y, 'px_ring').setTint(color).setAlpha(alpha).setDepth(-1).setScale(0.4);
|
||||||
|
this.scene.tweens.add({
|
||||||
|
targets: ring,
|
||||||
|
scale: 3,
|
||||||
|
alpha: 0,
|
||||||
|
duration,
|
||||||
|
ease: 'Quad.easeOut',
|
||||||
|
onComplete: () => ring.destroy(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
Object.values(this.emitters).forEach((e) => {
|
||||||
|
if (e && e.destroy) e.destroy();
|
||||||
|
});
|
||||||
|
this.emitters = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,16 +155,43 @@ 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() {
|
||||||
|
if (this.combo > 0) {
|
||||||
this.combo = 0;
|
this.combo = 0;
|
||||||
this.comboMultiplier = 1;
|
this.comboMultiplier = 1;
|
||||||
this.hudCombo.setText('');
|
this.hudCombo.setText('');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
showMilestone(blocks) {
|
showMilestone(blocks) {
|
||||||
const { width, height } = this.scene.scale;
|
const { width, height } = this.scene.scale;
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
155
src/managers/SoundManager.js
Normal file
155
src/managers/SoundManager.js
Normal 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();
|
||||||
58
src/managers/StatsManager.js
Normal file
58
src/managers/StatsManager.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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}`;
|
||||||
|
}
|
||||||
93
src/utils/storage.js
Normal file
93
src/utils/storage.js
Normal 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
51
src/utils/ui.js
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user