1660 lines
66 KiB
JavaScript
1660 lines
66 KiB
JavaScript
import * as Phaser from 'phaser';
|
||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||
import { createOpponentPortrait } from '../../ui/Portrait.js';
|
||
import { api } from '../../services/api.js';
|
||
import {
|
||
TUNING, SCORING, POWERS,
|
||
createRound, launchBall, stepSim, simulatePreview, bucketX, bucketWidth, multiplierFor, clampAim,
|
||
} from './PeggleLogic.js';
|
||
|
||
// Screen placement of the 1200×900 logical board (canvas is 1920×1080).
|
||
const BOARD_X = 360;
|
||
const BOARD_Y = 90;
|
||
const BOARD_W = TUNING.BOARD_W;
|
||
const BOARD_H = TUNING.BOARD_H;
|
||
|
||
const SIM_STEP = 1 / 60;
|
||
const FEVER_TIMESCALE = 0.3;
|
||
|
||
// Overclock hum: loops for as long as an overclock-boosted ball is in play,
|
||
// climbing a bit in pitch with every peg it hits.
|
||
const OVERCLOCK_HUM_MIN_RATE = 0.9;
|
||
const OVERCLOCK_HUM_MAX_RATE = 1.9;
|
||
const OVERCLOCK_HUM_RATE_STEP = 0.03;
|
||
const OVERCLOCK_HUM_VOLUME = 0.5;
|
||
|
||
// Palette sampled from the dominant fill of each frame in
|
||
// assets/images/peggle-sprites.png, so brick curves (and the procedural peg
|
||
// fallback) match the painted pegs. Re-sample if the sheet is repainted.
|
||
const PEG_TINT = { blue: 0x6050b0, orange: 0xb86030, green: 0x58c040, purple: 0xc850b8 };
|
||
const PEG_LIT = { blue: 0x3000f8, orange: 0xe0a880, green: 0x50e020, purple: 0xf880f0 };
|
||
|
||
// Ball tint per ball-attached power (applied while the flag is active in
|
||
// flight) plus the accent color for the three instant powers' impact FX.
|
||
const POWER_COLOR = {
|
||
fireball: 0xff9d5c,
|
||
bodyslam: 0xffd166,
|
||
extremeball: 0xff3b3b,
|
||
tailwind: 0x5eead4,
|
||
cannonball: 0x5a6472,
|
||
beamup: 0x8df2ec,
|
||
zenball: 0xb6e3ff,
|
||
multiball: 0x8df2a8,
|
||
overclock: 0xff4500,
|
||
cosmicbloom: 0x8df2a8,
|
||
rewind: 0xb6e3ff,
|
||
};
|
||
|
||
// Priority order when multiple ball flags are set at once — most
|
||
// mechanically/visually dominant power wins the tint. `overclockActive` is
|
||
// state-level (not a per-ball flag) so it's passed in separately.
|
||
function tintForBall(ball, overclockActive) {
|
||
if (ball.fireball) return POWER_COLOR.fireball;
|
||
if (ball.heavy) return POWER_COLOR.bodyslam;
|
||
if (ball.extreme) return POWER_COLOR.extremeball;
|
||
if (ball.pierce > 0) return POWER_COLOR.cannonball;
|
||
if (ball.spooky > 0) return POWER_COLOR.beamup;
|
||
if (ball.rewound) return POWER_COLOR.rewind;
|
||
if (ball.floaty) return POWER_COLOR.tailwind;
|
||
if (ball.zen) return POWER_COLOR.zenball;
|
||
if (overclockActive) return POWER_COLOR.overclock;
|
||
return null;
|
||
}
|
||
|
||
// Optional drop-in spritesheet (see assets/gamedata/peggle/sprites.md). When
|
||
// assets/images/peggle-sprites.png exists it replaces the procedural circles;
|
||
// physics/geometry are untouched — frames are just display skins.
|
||
const SPRITE_SHEET = 'peggle-sprites';
|
||
const SPRITE_FRAME = { blue: 0, orange: 2, green: 4, purple: 6, ball: 8 }; // color+1 = lit variant
|
||
|
||
const FELT = 0x0d1b2e;
|
||
const BOARD_BG = 0x122a45;
|
||
|
||
function shade(color, f) {
|
||
const r = Math.min(255, Math.round(((color >> 16) & 0xff) * f));
|
||
const g = Math.min(255, Math.round(((color >> 8) & 0xff) * f));
|
||
const b = Math.min(255, Math.round((color & 0xff) * f));
|
||
return (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
const D = { felt: -2, board: -1, peg: 5, bucket: 8, ball: 10, fx: 12, preview: 14, launcher: 16, ui: 30, banner: 40, overlay: 60, overlayUI: 62 };
|
||
|
||
export default class PeggleGame extends Phaser.Scene {
|
||
constructor() { super('PeggleGame'); }
|
||
|
||
init(data) {
|
||
this.gameDef = data.game ?? { slug: 'peggle', name: 'Peggle' };
|
||
this.testLevel = data.testLevel ?? null; // editor test-play
|
||
this.returnToEditor = !!data.returnToEditor;
|
||
this.manifest = [];
|
||
this.levelCache = new Map(); // file -> layout json
|
||
this.roster = [];
|
||
this.levelsCompleted = 0;
|
||
this.canPersist = !this.testLevel;
|
||
this.view = 'select';
|
||
|
||
// per-level play state
|
||
this.entry = null; // manifest entry
|
||
this.state = null; // PeggleLogic round state
|
||
this.accum = 0;
|
||
this.aimAngle = 0;
|
||
this.overlayUp = false;
|
||
this.shotHits = 0; // for the rising plink pitch
|
||
this.happyPlayedThisBall = false;
|
||
this._overclockHum = null;
|
||
this.pegImages = new Map();
|
||
this.ballImages = [];
|
||
this.portrait = null;
|
||
this.tube = null;
|
||
this.reservoirBalls = [];
|
||
this.inputHoldUntil = 0; // suppress launches right after UI clicks
|
||
this.introDom = null;
|
||
this.introVideo = null;
|
||
|
||
// end-of-level celebration
|
||
this.celebrating = false;
|
||
this.celebrationFx = null;
|
||
this.countTween = null;
|
||
this.countTimer = null;
|
||
this.countText = null;
|
||
this.countTotal = 0;
|
||
this.countDone = false;
|
||
this.skipHandler = null;
|
||
}
|
||
|
||
async create() {
|
||
try {
|
||
const music = this.cache.json.get('music');
|
||
if (music?.tracks) new MusicPlayer(this, music.tracks);
|
||
} catch (_) { /* optional */ }
|
||
|
||
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.felt);
|
||
this.buildTextures();
|
||
this.useSprites = this.textures.exists(SPRITE_SHEET);
|
||
|
||
this.manifest = (this.cache.json.get('peggle-levels')?.levels ?? []).slice().sort((a, b) => a.level - b.level);
|
||
|
||
try {
|
||
const res = await fetch('data/opponents.json');
|
||
const data = await res.json();
|
||
this.roster = data.opponents ?? data;
|
||
} catch (_) { this.roster = []; }
|
||
|
||
if (!this.testLevel) {
|
||
try {
|
||
const res = await api.get('/puzzles/peggle/progress');
|
||
this.levelsCompleted = res?.levelsCompleted ?? 0;
|
||
} catch (_) {
|
||
this.canPersist = false;
|
||
this.levelsCompleted = 0;
|
||
}
|
||
}
|
||
|
||
this.layer = this.add.container(0, 0);
|
||
this.bindInput();
|
||
|
||
if (this.testLevel) {
|
||
this.startLevel(this.testLevel.entry, this.testLevel.layout);
|
||
} else {
|
||
this.showLevelSelect();
|
||
}
|
||
}
|
||
|
||
buildTextures() {
|
||
if (!this.textures.exists('peggle-peg')) {
|
||
const g = this.make.graphics({ add: false });
|
||
// White peg with a highlight; tinted per color at render time.
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillCircle(16, 16, 16);
|
||
g.fillStyle(0xf2f7ff, 1);
|
||
g.fillCircle(13, 12, 7);
|
||
g.generateTexture('peggle-peg', 32, 32);
|
||
g.destroy();
|
||
}
|
||
if (!this.textures.exists('peggle-ball')) {
|
||
const g = this.make.graphics({ add: false });
|
||
g.fillStyle(0xd8dee8, 1);
|
||
g.fillCircle(13, 13, 13);
|
||
g.fillStyle(0xffffff, 0.9);
|
||
g.fillCircle(9, 9, 5);
|
||
g.lineStyle(2, 0x8b95a5, 1);
|
||
g.strokeCircle(13, 13, 12);
|
||
g.generateTexture('peggle-ball', 26, 26);
|
||
g.destroy();
|
||
}
|
||
if (!this.textures.exists('peggle-spark')) {
|
||
const g = this.make.graphics({ add: false });
|
||
g.fillStyle(0xffffff, 1);
|
||
g.fillCircle(4, 4, 4);
|
||
g.generateTexture('peggle-spark', 8, 8);
|
||
g.destroy();
|
||
}
|
||
// Brick-curve sections: pre-styled per color (dark outer stroke, color
|
||
// fill, light inner stroke — the classic Peggle brick look). Pre-tinting
|
||
// keeps the stroke contrast that a runtime tint would flatten.
|
||
const L = TUNING.BRICK_LEN;
|
||
const W = TUNING.BRICK_WID;
|
||
for (const [color, base] of Object.entries(PEG_TINT)) {
|
||
for (const [suffix, tone] of [['', base], ['-lit', PEG_LIT[color]]]) {
|
||
const key = `peggle-brick-${color}${suffix}`;
|
||
if (this.textures.exists(key)) continue;
|
||
const g = this.make.graphics({ add: false });
|
||
g.fillStyle(tone, 1);
|
||
g.fillRoundedRect(2, 2, L, W, 8);
|
||
g.lineStyle(3, shade(tone, 0.45), 1);
|
||
g.strokeRoundedRect(2, 2, L, W, 8);
|
||
g.lineStyle(2, shade(tone, 1.45), 0.95);
|
||
g.strokeRoundedRect(6.5, 6.5, L - 9, W - 9, 5);
|
||
g.generateTexture(key, L + 4, W + 4);
|
||
g.destroy();
|
||
}
|
||
}
|
||
}
|
||
|
||
masterFor(entry) {
|
||
return this.roster.find((o) => o.id === entry.masterId)
|
||
?? { id: entry.masterId, spriteIndex: 0, name: entry.masterId, bio: '', speech: {} };
|
||
}
|
||
|
||
// Happy reaction (power charged / fever start) — capped at once per ball
|
||
// so a shot that both charges the power and triggers fever doesn't
|
||
// double up the video. Speech is Portrait's own responsibility (it rolls
|
||
// a chance to say something whenever an emotion video plays); we only
|
||
// trigger the video/emotion here.
|
||
triggerHappy() {
|
||
if (this.happyPlayedThisBall) return;
|
||
this.happyPlayedThisBall = true;
|
||
this.portrait?.playEmotion('happy');
|
||
}
|
||
|
||
clearLayer() {
|
||
// Undo any fever zoom/pan before rebuilding the view.
|
||
this.cameras.main.setZoom(1);
|
||
this.cameras.main.centerOn(GAME_WIDTH / 2, GAME_HEIGHT / 2);
|
||
this.feverZoomed = false;
|
||
this.stopOverclockHum();
|
||
this.portrait?.destroy();
|
||
this.portrait = null;
|
||
this.pegImages.clear();
|
||
this.ballImages = [];
|
||
this.previewG = null;
|
||
this.launcherG = null;
|
||
this.bucketC = null;
|
||
this.feverBuckets = null;
|
||
this.hud = null;
|
||
this.tube = null;
|
||
this.reservoirBalls = [];
|
||
// Destroyed with the layer below; drop the stale references so
|
||
// updatePowerHud() / the celebration don't poke dead objects before the
|
||
// next level rebuilds them.
|
||
this.powerStatus = null;
|
||
if (this.introVideo) { try { this.introVideo.pause(); } catch (_) { /* already gone */ } this.introVideo = null; }
|
||
this.introDom?.destroy();
|
||
this.introDom = null;
|
||
this.celebrating = false;
|
||
this.countDone = false;
|
||
this.countTween?.remove();
|
||
this.countTween = null;
|
||
this.countTimer?.remove();
|
||
this.countTimer = null;
|
||
this.celebrationFx = null;
|
||
this.countText = null;
|
||
if (this.skipHandler) { this.input.off('pointerup', this.skipHandler); this.skipHandler = null; }
|
||
this.layer.removeAll(true);
|
||
}
|
||
|
||
// ── Level select ────────────────────────────────────────────────────────────
|
||
|
||
showLevelSelect(page = null) {
|
||
this.view = 'select';
|
||
this.state = null;
|
||
this.overlayUp = false;
|
||
this.clearLayer();
|
||
const cx = GAME_WIDTH / 2;
|
||
|
||
if (this.textures.exists('bg-peggle-menu')) {
|
||
this.layer.add(this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'bg-peggle-menu')
|
||
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT));
|
||
}
|
||
|
||
if (!this.manifest.length) {
|
||
const msg = this.add.text(cx, 480, 'No levels found.\nRun: node tools/genPeggleLevels.js', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.dangerHex, align: 'center',
|
||
}).setOrigin(0.5);
|
||
const back = new Button(this, cx, GAME_HEIGHT - 90, 'Back', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||
this.layer.add([msg, back]);
|
||
return;
|
||
}
|
||
|
||
const nextLevel = Math.min(this.levelsCompleted + 1, this.manifest.length);
|
||
const prog = this.add.text(GAME_WIDTH - 40, 208, `Completed ${this.levelsCompleted} / ${this.manifest.length}`, {
|
||
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
|
||
}).setOrigin(1, 0.5);
|
||
this.layer.add(prog);
|
||
|
||
// One row per friend: portrait + name + power on the left, that friend's
|
||
// level tiles on the right. Blocks are consecutive same-master runs, so
|
||
// future friends slot in with no code change.
|
||
const blocks = [];
|
||
for (const entry of this.manifest) {
|
||
const last = blocks[blocks.length - 1];
|
||
if (last && last.masterId === entry.masterId) last.entries.push(entry);
|
||
else blocks.push({ masterId: entry.masterId, entries: [entry] });
|
||
}
|
||
|
||
// Paged, 5 friends per page. Defaults to the page holding the next
|
||
// unplayed level; Prev/Next re-render the view on the chosen page.
|
||
const PER_PAGE = 5;
|
||
const pageCount = Math.max(1, Math.ceil(blocks.length / PER_PAGE));
|
||
if (page == null) {
|
||
const bi = blocks.findIndex((blk) => blk.entries.some((e) => e.level === nextLevel));
|
||
page = Math.floor(Math.max(0, bi) / PER_PAGE);
|
||
}
|
||
page = Math.max(0, Math.min(pageCount - 1, page));
|
||
|
||
const rowH = 134;
|
||
const top = 300;
|
||
const SIZE = 110;
|
||
blocks.slice(page * PER_PAGE, (page + 1) * PER_PAGE).forEach((blk, b) => {
|
||
const y = top + b * rowH;
|
||
const first = blk.entries[0];
|
||
const master = this.masterFor(first);
|
||
const power = POWERS[first.powerId];
|
||
const rowUnlocked = first.level <= nextLevel;
|
||
|
||
const face = this.add.image(320, y, 'opponents', master.spriteIndex ?? 0)
|
||
.setDisplaySize(104, 104);
|
||
if (!rowUnlocked) face.setTint(0x334455).setAlpha(0.6);
|
||
const name = this.add.text(400, y - 24, master.name, {
|
||
fontFamily: 'Righteous', fontSize: '30px',
|
||
color: rowUnlocked ? COLORS.textHex : '#54606b',
|
||
}).setOrigin(0, 0.5);
|
||
const ptxt = this.add.text(400, y + 18, power?.name ?? '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '19px',
|
||
color: rowUnlocked ? '#3ed164' : '#54606b',
|
||
}).setOrigin(0, 0.5);
|
||
this.layer.add([face, name, ptxt]);
|
||
|
||
blk.entries.forEach((entry, i) => {
|
||
const x = 850 + i * 140;
|
||
const level = entry.level;
|
||
const cleared = level <= this.levelsCompleted;
|
||
const playable = level <= nextLevel;
|
||
|
||
const fill = cleared ? 0x1f5c3a : playable ? 0x1e3a52 : 0x16202b;
|
||
const stroke = cleared ? 0x2ecc71 : playable ? COLORS.gold : 0x2a3744;
|
||
const tile = this.add.rectangle(x, y, SIZE, SIZE, fill)
|
||
.setStrokeStyle(playable || cleared ? 3 : 2, stroke, 1);
|
||
const num = this.add.text(x, y - 12, String(level), {
|
||
fontFamily: 'Righteous', fontSize: '36px',
|
||
color: playable || cleared ? COLORS.textHex : '#54606b',
|
||
}).setOrigin(0.5);
|
||
const tag = this.add.text(x, y + 30, cleared ? '✓' : playable ? entry.name : 'locked', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '13px',
|
||
color: cleared ? '#9be7b4' : playable ? COLORS.mutedHex : '#54606b',
|
||
}).setOrigin(0.5);
|
||
// Keep long level names inside the tile.
|
||
if (tag.width > SIZE - 10) tag.setScale((SIZE - 10) / tag.width);
|
||
this.layer.add([tile, num, tag]);
|
||
|
||
if (playable) {
|
||
tile.setInteractive({ useHandCursor: true });
|
||
tile.on('pointerover', () => tile.setStrokeStyle(4, COLORS.gold, 1));
|
||
tile.on('pointerout', () => tile.setStrokeStyle(3, stroke, 1));
|
||
tile.on('pointerup', () => this.playLevel(entry));
|
||
}
|
||
});
|
||
});
|
||
|
||
const resume = new Button(this, cx - 150, GAME_HEIGHT - 78, `Play Level ${nextLevel}`,
|
||
() => this.playLevel(this.manifest[nextLevel - 1]), { width: 280, height: 58, fontSize: 24 });
|
||
const back = new Button(this, cx + 170, GAME_HEIGHT - 78, 'Back', () => this.scene.start('GameMenu'),
|
||
{ variant: 'ghost', width: 180, height: 58, fontSize: 24 });
|
||
const reset = new Button(this, 210, GAME_HEIGHT - 78, 'Reset Progress', () => this.confirmResetProgress(),
|
||
{ variant: 'ghost', width: 260, height: 58, fontSize: 22, textColor: COLORS.dangerHex });
|
||
this.layer.add([resume, back, reset]);
|
||
|
||
if (pageCount > 1) {
|
||
const pageLabel = this.add.text(GAME_WIDTH - 330, GAME_HEIGHT - 78, `Page ${page + 1} / ${pageCount}`, {
|
||
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
|
||
}).setOrigin(0.5);
|
||
this.layer.add(pageLabel);
|
||
if (page > 0) {
|
||
this.layer.add(new Button(this, GAME_WIDTH - 470, GAME_HEIGHT - 78, '◀',
|
||
() => this.showLevelSelect(page - 1), { variant: 'ghost', width: 70, height: 58, fontSize: 24 }));
|
||
}
|
||
if (page < pageCount - 1) {
|
||
this.layer.add(new Button(this, GAME_WIDTH - 190, GAME_HEIGHT - 78, '▶',
|
||
() => this.showLevelSelect(page + 1), { variant: 'ghost', width: 70, height: 58, fontSize: 24 }));
|
||
}
|
||
}
|
||
}
|
||
|
||
confirmResetProgress() {
|
||
const cx = GAME_WIDTH / 2;
|
||
const cy = GAME_HEIGHT / 2;
|
||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||
const panel = this.add.graphics().setDepth(D.overlay);
|
||
panel.fillStyle(COLORS.panel, 0.98);
|
||
panel.fillRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||
panel.lineStyle(3, COLORS.danger, 1);
|
||
panel.strokeRoundedRect(cx - 320, cy - 160, 640, 320, 20);
|
||
const title = this.add.text(cx, cy - 92, 'Reset Progress?', {
|
||
fontFamily: 'Righteous', fontSize: '52px', color: COLORS.dangerHex,
|
||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||
const msg = this.add.text(cx, cy - 14,
|
||
'This clears every level you have cleared and\nstarts you back at Level 1. This cannot be undone.', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex, align: 'center', lineSpacing: 6,
|
||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||
const yes = new Button(this, cx - 150, cy + 88, 'Reset', () => {
|
||
api.post('/puzzles/peggle/reset').catch(() => { /* best effort */ });
|
||
this.levelsCompleted = 0;
|
||
this.showLevelSelect();
|
||
}, { width: 250, height: 58, fontSize: 24, textColor: COLORS.dangerHex }).setDepth(D.overlayUI);
|
||
const no = new Button(this, cx + 150, cy + 88, 'Cancel', () => this.showLevelSelect(),
|
||
{ variant: 'ghost', width: 250, height: 58, fontSize: 24 }).setDepth(D.overlayUI);
|
||
this.layer.add([dim, panel, title, msg, yes, no]);
|
||
}
|
||
|
||
// ── Level start ─────────────────────────────────────────────────────────────
|
||
|
||
async playLevel(entry, opts = {}) {
|
||
if (!entry) return;
|
||
let layout = this.levelCache.get(entry.file);
|
||
if (!layout) {
|
||
try {
|
||
const res = await fetch(`assets/gamedata/peggle/${entry.file}`);
|
||
layout = await res.json();
|
||
this.levelCache.set(entry.file, layout);
|
||
} catch (_) {
|
||
return;
|
||
}
|
||
}
|
||
if (!opts.skipIntro && !this.testLevel && this.isFirstOfBlock(entry)) {
|
||
this.showFriendIntro(entry, layout);
|
||
} else {
|
||
this.startLevel(entry, layout);
|
||
}
|
||
}
|
||
|
||
// A friend's first level (the manifest entry where masterId changes) gets
|
||
// the video intro screen.
|
||
isFirstOfBlock(entry) {
|
||
const idx = this.manifest.findIndex((m) => m.level === entry.level);
|
||
if (idx < 0) return false;
|
||
return idx === 0 || this.manifest[idx - 1].masterId !== entry.masterId;
|
||
}
|
||
|
||
// ── Friend intro (video + power explainer before a friend's first level) ────
|
||
|
||
showFriendIntro(entry, layout) {
|
||
this.view = 'intro';
|
||
this.state = null;
|
||
this.overlayUp = true;
|
||
this.clearLayer();
|
||
const master = this.masterFor(entry);
|
||
const power = POWERS[entry.powerId];
|
||
const cx = GAME_WIDTH / 2;
|
||
const cy = GAME_HEIGHT / 2;
|
||
|
||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x06101c, 0.96).setInteractive();
|
||
const panel = this.add.graphics();
|
||
panel.fillStyle(COLORS.panel, 0.98);
|
||
panel.fillRoundedRect(cx - 700, cy - 400, 1400, 800, 24);
|
||
panel.lineStyle(3, COLORS.gold, 1);
|
||
panel.strokeRoundedRect(cx - 700, cy - 400, 1400, 800, 24);
|
||
const headline = this.add.text(cx, cy - 352, `LEVEL ${entry.level} — MEET YOUR FRIEND`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5);
|
||
this.layer.add([dim, panel, headline]);
|
||
|
||
// Video window (720×720 source shown at 560×560). Click replays once the
|
||
// video has finished; a missing/broken file falls back to the portrait.
|
||
const vx = cx - 380;
|
||
const vy = cy + 10;
|
||
const wrap = document.createElement('div');
|
||
wrap.style.cssText = 'position:relative; width:560px; height:560px; border-radius:18px; overflow:hidden;'
|
||
+ ' border:3px solid #f0c75e; background:#0a1522; cursor:pointer;';
|
||
const vid = document.createElement('video');
|
||
vid.src = `assets/videos/peggle/${master.id}.mp4`;
|
||
vid.playsInline = true;
|
||
vid.style.cssText = 'width:100%; height:100%; object-fit:cover;';
|
||
const replay = document.createElement('div');
|
||
replay.style.cssText = 'position:absolute; inset:0; display:none; flex-direction:column; align-items:center;'
|
||
+ ' justify-content:center; background:rgba(6,16,28,.55); color:#fff; font-family:Righteous,sans-serif;';
|
||
replay.innerHTML = '<div style="font-size:76px; line-height:1;">▶</div><div style="font-size:30px;">Replay</div>';
|
||
wrap.append(vid, replay);
|
||
wrap.addEventListener('click', () => {
|
||
replay.style.display = 'none';
|
||
vid.currentTime = 0;
|
||
vid.play().catch(() => { replay.style.display = 'flex'; });
|
||
});
|
||
vid.addEventListener('ended', () => { replay.style.display = 'flex'; });
|
||
vid.addEventListener('error', () => {
|
||
// No video for this friend (yet) — show their portrait frame instead.
|
||
this.introDom?.destroy();
|
||
this.introDom = null;
|
||
this.introVideo = null;
|
||
const face = this.add.image(vx, vy, 'opponents', master.spriteIndex ?? 0).setDisplaySize(560, 560);
|
||
const frame = this.add.graphics();
|
||
frame.lineStyle(3, COLORS.gold, 1);
|
||
frame.strokeRoundedRect(vx - 280, vy - 280, 560, 560, 18);
|
||
this.layer.add([face, frame]);
|
||
});
|
||
this.introDom = this.add.dom(vx, vy, wrap);
|
||
this.introVideo = vid;
|
||
vid.play().catch(() => {
|
||
// Autoplay blocked — invite the user to start it.
|
||
replay.lastElementChild.textContent = 'Play';
|
||
replay.style.display = 'flex';
|
||
});
|
||
|
||
// Friend + power explainer column.
|
||
const tx = cx - 40;
|
||
const name = this.add.text(tx, cy - 250, master.name, {
|
||
fontFamily: 'Righteous', fontSize: '58px', color: COLORS.goldHex,
|
||
}).setOrigin(0, 0.5);
|
||
const bio = this.add.text(tx, cy - 178, master.bio ?? '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||
wordWrap: { width: 640 }, lineSpacing: 6,
|
||
}).setOrigin(0, 0);
|
||
const pTitle = this.add.text(tx, cy - 20, `SPECIAL SHOT — ${power?.name ?? ''}`, {
|
||
fontFamily: 'Righteous', fontSize: '30px', color: '#3ed164',
|
||
}).setOrigin(0, 0.5);
|
||
const pDesc = this.add.text(tx, cy + 16, power?.desc ?? '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
|
||
wordWrap: { width: 640 }, lineSpacing: 8,
|
||
}).setOrigin(0, 0);
|
||
const hint = this.add.text(tx, cy + 148, 'Hit a green peg during a shot to charge it up!', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||
}).setOrigin(0, 0.5);
|
||
this.layer.add([name, bio, pTitle, pDesc, hint]);
|
||
|
||
const cont = new Button(this, cx + 380, cy + 310, 'Continue ▶', () => {
|
||
// Swallow this click entirely — the pointerup that triggered the button
|
||
// must not also fire the cannon once the level is live.
|
||
this.inputHoldUntil = this.time.now + 400;
|
||
this.startLevel(entry, layout);
|
||
}, { width: 300, height: 66, fontSize: 26 });
|
||
const back = new Button(this, cx + 30, cy + 310, 'Back', () => this.showLevelSelect(),
|
||
{ variant: 'ghost', width: 180, height: 66, fontSize: 24 });
|
||
this.layer.add([cont, back]);
|
||
}
|
||
|
||
startLevel(entry, layout) {
|
||
// Whatever click got us here (Try Again, Replay, Next Level, a level
|
||
// tile, the intro's Continue…) must not also fire the cannon.
|
||
this.inputHoldUntil = this.time.now + 350;
|
||
this.view = 'play';
|
||
this.entry = entry;
|
||
this.master = this.masterFor(entry);
|
||
this.state = createRound({ ...layout, powerId: entry.powerId });
|
||
this.accum = 0;
|
||
this.aimAngle = 0;
|
||
this.overlayUp = false;
|
||
this.shotHits = 0;
|
||
this.happyPlayedThisBall = false;
|
||
this.feverZoomed = false;
|
||
|
||
this.clearLayer();
|
||
this.drawBoardChrome();
|
||
this.renderPegs();
|
||
this.drawBucket();
|
||
this.drawLauncher();
|
||
this.drawHud();
|
||
// The portrait (playIntro: true) already queues one random intro clip and
|
||
// animates the speech visualizer — don't queue a second one here.
|
||
this.drawMasterPanel();
|
||
}
|
||
|
||
// ── Board rendering ─────────────────────────────────────────────────────────
|
||
|
||
sx(x) { return BOARD_X + x; }
|
||
sy(y) { return BOARD_Y + y; }
|
||
|
||
drawBoardChrome() {
|
||
// Children of this.layer render in insertion order: frame, then
|
||
// background (image or procedural), then scrim, then border.
|
||
const g = this.add.graphics().setDepth(D.board);
|
||
g.fillStyle(0x0a1522, 1);
|
||
g.fillRoundedRect(BOARD_X - 18, BOARD_Y - 18, BOARD_W + 36, BOARD_H + 36, 16);
|
||
|
||
// Per-master board background (assets/images/peggle/<masterId>.png,
|
||
// 1200×900) when painted; procedural gradient starfield otherwise.
|
||
const bgKey = `peggle-bg-${this.entry?.masterId}`;
|
||
if (this.entry?.masterId && this.textures.exists(bgKey)) {
|
||
this.layer.add(g);
|
||
const img = this.add.image(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2, bgKey)
|
||
.setDisplaySize(BOARD_W, BOARD_H)
|
||
.setDepth(D.board);
|
||
this.layer.add(img);
|
||
// Dark scrim keeps pegs, ball, and the aim guide readable over any art.
|
||
const scrim = this.add.rectangle(BOARD_X + BOARD_W / 2, BOARD_Y + BOARD_H / 2,
|
||
BOARD_W, BOARD_H, 0x0a1522, 0.38).setDepth(D.board);
|
||
this.layer.add(scrim);
|
||
} else {
|
||
g.fillGradientStyle(BOARD_BG, BOARD_BG, 0x0e2036, 0x0e2036, 1);
|
||
g.fillRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H);
|
||
// Subtle star field so ball flight reads against the background.
|
||
g.fillStyle(0xffffff, 0.06);
|
||
let sx = 137;
|
||
for (let i = 0; i < 90; i++) {
|
||
sx = (sx * 16807) % 2147483647;
|
||
const px = sx % BOARD_W;
|
||
const py = (Math.floor(sx / BOARD_W) % (BOARD_H - 60)) + 30;
|
||
g.fillCircle(BOARD_X + px, BOARD_Y + py, (sx % 3) === 0 ? 2 : 1);
|
||
}
|
||
this.layer.add(g);
|
||
}
|
||
|
||
const border = this.add.graphics().setDepth(D.board);
|
||
border.lineStyle(3, 0x2f5b8f, 1);
|
||
border.strokeRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H);
|
||
this.layer.add(border);
|
||
}
|
||
|
||
tintFor(peg) {
|
||
return peg.lit ? PEG_LIT[peg.color] : PEG_TINT[peg.color];
|
||
}
|
||
|
||
// Skins a peg image from its logical state — spritesheet frame when the
|
||
// sheet is present, tinted procedural circle otherwise. Brick-curve
|
||
// sections use their own pre-styled textures, rotated to the curve tangent.
|
||
applyPegVisual(img, peg) {
|
||
if (peg.shape === 'brick') {
|
||
img.setTexture(`peggle-brick-${peg.color}${peg.lit ? '-lit' : ''}`);
|
||
img.clearTint();
|
||
img.setDisplaySize(TUNING.BRICK_LEN + 4, TUNING.BRICK_WID + 4);
|
||
img.setRotation(peg.angle);
|
||
return;
|
||
}
|
||
if (this.useSprites) {
|
||
img.setTexture(SPRITE_SHEET, SPRITE_FRAME[peg.color] + (peg.lit ? 1 : 0));
|
||
img.clearTint();
|
||
} else {
|
||
img.setTexture('peggle-peg');
|
||
img.setTint(this.tintFor(peg));
|
||
}
|
||
img.setDisplaySize(peg.r * 2, peg.r * 2);
|
||
}
|
||
|
||
makeBallImage() {
|
||
const img = this.useSprites
|
||
? this.add.image(0, 0, SPRITE_SHEET, SPRITE_FRAME.ball).setDisplaySize(TUNING.BALL_R * 2, TUNING.BALL_R * 2)
|
||
: this.add.image(0, 0, 'peggle-ball');
|
||
return img.setDepth(D.ball);
|
||
}
|
||
|
||
renderPegs() {
|
||
this.pegImages.forEach((img) => img.destroy());
|
||
this.pegImages.clear();
|
||
for (const peg of this.state.pegs) {
|
||
if (peg.removed) continue;
|
||
const img = this.add.image(this.sx(peg.x), this.sy(peg.y), 'peggle-peg').setDepth(D.peg);
|
||
this.applyPegVisual(img, peg);
|
||
this.layer.add(img);
|
||
this.pegImages.set(peg.id, img);
|
||
}
|
||
}
|
||
|
||
refreshPegTints() {
|
||
for (const peg of this.state.pegs) {
|
||
const img = this.pegImages.get(peg.id);
|
||
if (img && !peg.removed) this.applyPegVisual(img, peg);
|
||
}
|
||
}
|
||
|
||
drawBucket() {
|
||
const width = this.state ? bucketWidth(this.state) : TUNING.BUCKET_W;
|
||
this.bucketDrawnW = width;
|
||
const c = this.add.container(0, this.sy(TUNING.BUCKET_Y)).setDepth(D.bucket);
|
||
const g = this.add.graphics();
|
||
const half = width / 2;
|
||
const rimW = TUNING.BUCKET_RIM_W;
|
||
const rimH = TUNING.BUCKET_RIM_H;
|
||
g.fillStyle(0xc9a44a, 1);
|
||
g.fillRect(-half - rimW, 0, rimW, rimH);
|
||
g.fillRect(half, 0, rimW, rimH);
|
||
g.fillStyle(0x8a6f2f, 1);
|
||
g.fillRect(-half - rimW, rimH, width + rimW * 2, 12);
|
||
g.fillStyle(0x5c4a1f, 1);
|
||
g.fillRect(-half, 6, width, rimH - 6);
|
||
c.add(g);
|
||
const label = this.add.text(0, 22, 'FREE BALL', {
|
||
fontFamily: 'Righteous', fontSize: '13px', color: '#ffe9b0',
|
||
}).setOrigin(0.5);
|
||
c.add(label);
|
||
this.layer.add(c);
|
||
this.bucketC = c;
|
||
}
|
||
|
||
drawLauncher() {
|
||
this.launcherG = this.add.graphics().setDepth(D.launcher);
|
||
this.previewG = this.add.graphics().setDepth(D.preview);
|
||
this.layer.add([this.launcherG, this.previewG]);
|
||
this.renderLauncher();
|
||
}
|
||
|
||
renderLauncher() {
|
||
const g = this.launcherG;
|
||
if (!g) return;
|
||
g.clear();
|
||
const lx = this.sx(TUNING.LAUNCH_X);
|
||
const ly = this.sy(TUNING.LAUNCH_Y);
|
||
const a = this.aimAngle;
|
||
// Barrel
|
||
g.fillStyle(0x9aa7b8, 1);
|
||
// Canvas rotation is mirrored vs. our aim convention (angle from straight
|
||
// down, positive = right), so rotate by -a.
|
||
g.save();
|
||
g.translateCanvas(lx, ly);
|
||
g.rotateCanvas(-a);
|
||
g.fillRoundedRect(-12, -6, 24, 52, 8);
|
||
g.restore();
|
||
// Housing
|
||
g.fillStyle(0x3a4b61, 1);
|
||
g.fillCircle(lx, ly, 26);
|
||
g.lineStyle(3, 0x6d84a3, 1);
|
||
g.strokeCircle(lx, ly, 26);
|
||
g.fillStyle(0xd8dee8, 1);
|
||
g.fillCircle(lx, ly, 9);
|
||
}
|
||
|
||
renderPreview() {
|
||
const g = this.previewG;
|
||
if (!g) return;
|
||
g.clear();
|
||
if (!this.state || this.state.phase !== 'aim' || this.overlayUp) return;
|
||
const maxPegHits = this.state.superGuideShots > 0 ? 3 : 1;
|
||
const { points } = simulatePreview(this.state, this.aimAngle, { maxPegHits });
|
||
const superGuide = this.state.superGuideShots > 0;
|
||
g.fillStyle(superGuide ? 0x7df29b : 0xffffff, 0.75);
|
||
for (let i = 4; i < points.length; i += 3) {
|
||
const p = points[i];
|
||
const fade = Math.max(0.15, 1 - i / points.length);
|
||
g.fillStyle(superGuide ? 0x7df29b : 0xffffff, 0.75 * fade);
|
||
g.fillCircle(this.sx(p.x), this.sy(p.y), 4);
|
||
}
|
||
}
|
||
|
||
// ── HUD ─────────────────────────────────────────────────────────────────────
|
||
|
||
drawHud() {
|
||
const rx = BOARD_X + BOARD_W + 24;
|
||
const rw = GAME_WIDTH - rx - 24;
|
||
const cx = rx + rw / 2;
|
||
|
||
const panel = this.add.graphics().setDepth(D.ui);
|
||
panel.fillStyle(0x0a1522, 0.92);
|
||
panel.fillRoundedRect(rx, BOARD_Y, rw, BOARD_H, 16);
|
||
panel.lineStyle(2, 0x2f5b8f, 1);
|
||
panel.strokeRoundedRect(rx, BOARD_Y, rw, BOARD_H, 16);
|
||
this.layer.add(panel);
|
||
|
||
const title = this.add.text(cx, BOARD_Y + 44, `Level ${this.entry.level}`, {
|
||
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
const name = this.add.text(cx, BOARD_Y + 82, this.entry.name, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.layer.add([title, name]);
|
||
|
||
this.hud = {};
|
||
const mk = (y, label, size = 40) => {
|
||
const l = this.add.text(cx, y, label, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
const v = this.add.text(cx, y + 34, '', {
|
||
fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.layer.add([l, v]);
|
||
return v;
|
||
};
|
||
this.hud.score = mk(BOARD_Y + 140, 'SCORE', 42);
|
||
this.hud.balls = mk(BOARD_Y + 250, 'BALLS LEFT', 42);
|
||
this.hud.orange = mk(BOARD_Y + 360, 'ORANGE PEGS', 36);
|
||
this.hud.mult = mk(BOARD_Y + 464, 'MULTIPLIER', 36);
|
||
this.hud.mult.setColor(COLORS.goldHex);
|
||
|
||
this.buildReservoir(cx);
|
||
|
||
const levelsBtn = new Button(this, cx, BOARD_Y + BOARD_H - 50,
|
||
this.returnToEditor ? 'Back to Editor' : 'Levels',
|
||
() => this.exitLevel(), { variant: 'ghost', width: rw - 60, height: 54, fontSize: 22 });
|
||
levelsBtn.setDepth(D.ui);
|
||
this.layer.add(levelsBtn);
|
||
|
||
this.updateHud();
|
||
}
|
||
|
||
// ── Ball reservoir (Peggle-style tube of waiting balls) ─────────────────────
|
||
// Shows ballsLeft − 1 while aiming (one ball sits in the cannon). The bottom
|
||
// ball drops out when the cannon reloads after a shot; free balls fall in
|
||
// from the top; the stack settles with a gravity bounce.
|
||
|
||
buildReservoir(cx) {
|
||
const size = 24; // displayed ball diameter in the tube
|
||
const tubeW = 46;
|
||
const top = BOARD_Y + 545;
|
||
const height = 270;
|
||
this.tube = { cx, top, bottom: top + height, size };
|
||
|
||
const label = this.add.text(cx, top - 18, 'RESERVE', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
const tg = this.add.graphics().setDepth(D.ui);
|
||
tg.fillStyle(0x0d2036, 1);
|
||
tg.fillRoundedRect(cx - tubeW / 2, top, tubeW, height, 10);
|
||
tg.lineStyle(2, 0x2f5b8f, 1);
|
||
tg.strokeRoundedRect(cx - tubeW / 2, top, tubeW, height, 10);
|
||
this.layer.add([label, tg]);
|
||
|
||
this.reservoirBalls = []; // index 0 = bottom of the stack
|
||
const n = Math.max(0, this.state.ballsLeft - 1);
|
||
for (let i = 0; i < n; i++) {
|
||
const img = this.makeReservoirBall();
|
||
img.setPosition(cx, this.reservoirSlotY(i));
|
||
this.reservoirBalls.push(img);
|
||
}
|
||
}
|
||
|
||
reservoirSlotY(i) {
|
||
return this.tube.bottom - this.tube.size / 2 - 4 - i * this.tube.size;
|
||
}
|
||
|
||
makeReservoirBall() {
|
||
const img = this.makeBallImage()
|
||
.setDisplaySize(this.tube.size, this.tube.size)
|
||
.setDepth(D.ui + 1);
|
||
this.layer.add(img);
|
||
return img;
|
||
}
|
||
|
||
// Free ball earned: a new ball drops into the tube from above.
|
||
addReservoirBall() {
|
||
if (!this.tube) return;
|
||
const i = this.reservoirBalls.length;
|
||
const img = this.makeReservoirBall();
|
||
img.setPosition(this.tube.cx, this.tube.top - 26);
|
||
this.reservoirBalls.push(img);
|
||
this.tweens.add({
|
||
targets: img, y: this.reservoirSlotY(i), duration: 520, ease: 'Bounce.easeOut',
|
||
});
|
||
}
|
||
|
||
// Cannon reloads: the bottom ball exits the tube and the rest fall down.
|
||
loadCannonBall() {
|
||
if (!this.tube || !this.reservoirBalls.length) return;
|
||
const img = this.reservoirBalls.shift();
|
||
this.tweens.add({
|
||
targets: img, y: this.tube.bottom + 30, alpha: 0, duration: 240, ease: 'Quad.easeIn',
|
||
onComplete: () => img.destroy(),
|
||
});
|
||
this.reservoirBalls.forEach((b, i) => {
|
||
this.tweens.add({
|
||
targets: b, y: this.reservoirSlotY(i), duration: 420, ease: 'Bounce.easeOut',
|
||
});
|
||
});
|
||
}
|
||
|
||
exitLevel() {
|
||
if (this.returnToEditor) {
|
||
this.scene.start('PeggleEditor', { resume: true });
|
||
} else {
|
||
this.showLevelSelect();
|
||
}
|
||
}
|
||
|
||
updateHud() {
|
||
if (!this.hud || !this.state) return;
|
||
const st = this.state;
|
||
this.hud.score.setText(st.score.toLocaleString());
|
||
this.hud.balls.setText(String(st.ballsLeft));
|
||
this.hud.orange.setText(`${st.orangeTotal - st.orangeCleared} left`);
|
||
this.hud.mult.setText(`×${multiplierFor(st.orangeCleared)}`);
|
||
this.updatePowerHud();
|
||
}
|
||
|
||
drawMasterPanel() {
|
||
const lw = BOARD_X - 48;
|
||
const lx = 24;
|
||
const cx = lx + lw / 2;
|
||
|
||
const panel = this.add.graphics().setDepth(D.ui);
|
||
panel.fillStyle(0x0a1522, 0.92);
|
||
panel.fillRoundedRect(lx, BOARD_Y, lw, BOARD_H, 16);
|
||
panel.lineStyle(2, 0x2f5b8f, 1);
|
||
panel.strokeRoundedRect(lx, BOARD_Y, lw, BOARD_H, 16);
|
||
this.layer.add(panel);
|
||
|
||
this.portrait = createOpponentPortrait(this, this.master, cx, BOARD_Y + 160, 110, D.ui + 1, { playIntro: true });
|
||
|
||
const name = this.add.text(cx, BOARD_Y + 300, this.master.name, {
|
||
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.layer.add(name);
|
||
|
||
const power = POWERS[this.entry.powerId];
|
||
const pTitle = this.add.text(cx, BOARD_Y + 356, `POWER · ${power?.name ?? '—'}`, {
|
||
fontFamily: 'Righteous', fontSize: '22px', color: '#3ed164',
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
const pDesc = this.add.text(cx, BOARD_Y + 420, power?.desc ?? '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
|
||
align: 'center', wordWrap: { width: lw - 50 }, lineSpacing: 6,
|
||
}).setOrigin(0.5, 0).setDepth(D.ui);
|
||
this.layer.add([pTitle, pDesc]);
|
||
|
||
this.powerStatus = this.add.text(cx, BOARD_Y + 560, '', {
|
||
fontFamily: 'Righteous', fontSize: '24px', color: '#8df2a8', align: 'center',
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.layer.add(this.powerStatus);
|
||
|
||
const hint = this.add.text(cx, BOARD_Y + BOARD_H - 70,
|
||
'Hit a green peg\nto trigger the power!', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#3ed164', align: 'center', lineSpacing: 4,
|
||
}).setOrigin(0.5).setDepth(D.ui);
|
||
this.layer.add(hint);
|
||
}
|
||
|
||
updatePowerHud() {
|
||
if (!this.powerStatus || !this.state) return;
|
||
const st = this.state;
|
||
const lines = [];
|
||
if (st.superGuideShots > 0) lines.push(`Super Guide: ${st.superGuideShots} shots`);
|
||
if (st.fireballNext > 0) lines.push(`Fireball ready ×${st.fireballNext}`);
|
||
if (st.zenNext > 0) lines.push(`Zen Ball ready ×${st.zenNext}`);
|
||
if (st.heavyNext > 0) lines.push(`Slam Ball ready ×${st.heavyNext}`);
|
||
if (st.extremeNext > 0) lines.push(`Extreme Ball ready ×${st.extremeNext}`);
|
||
if (st.floatyNext > 0) lines.push(`Tailwind ready ×${st.floatyNext}`);
|
||
if (st.rewindCharges > 0) lines.push(`Rewind ready ×${st.rewindCharges}`);
|
||
if (st.wideBucketShots > 0) lines.push(`Beaver Dam: ${st.wideBucketShots} shots`);
|
||
if (st.overclockShots > 0) lines.push(`OVERCLOCK ×2 active: ${st.overclockShots} shots`);
|
||
this.powerStatus.setText(lines.join('\n'));
|
||
}
|
||
|
||
// ── Input ───────────────────────────────────────────────────────────────────
|
||
|
||
bindInput() {
|
||
this.input.on('pointermove', (pointer) => {
|
||
if (this.view !== 'play' || !this.state || this.overlayUp) return;
|
||
this.setAimFromPointer(pointer);
|
||
});
|
||
this.input.on('pointerup', (pointer) => {
|
||
if (this.view !== 'play' || !this.state || this.overlayUp) return;
|
||
if (this.time.now < this.inputHoldUntil) return; // e.g. the intro's Continue click
|
||
if (this.state.phase !== 'aim' || this.state.ballsLeft <= 0) return;
|
||
const bx = pointer.x - BOARD_X;
|
||
const by = pointer.y - BOARD_Y;
|
||
if (bx < 0 || bx > BOARD_W || by < 0 || by > BOARD_H) return;
|
||
this.setAimFromPointer(pointer);
|
||
this.fire();
|
||
});
|
||
}
|
||
|
||
setAimFromPointer(pointer) {
|
||
const dx = (pointer.x - BOARD_X) - TUNING.LAUNCH_X;
|
||
const dy = (pointer.y - BOARD_Y) - TUNING.LAUNCH_Y;
|
||
if (dy <= 4) return; // don't aim above the launcher
|
||
this.aimAngle = clampAim(Math.atan2(dx, dy));
|
||
this.renderLauncher();
|
||
this.renderPreview();
|
||
}
|
||
|
||
fire() {
|
||
const events = launchBall(this.state, this.aimAngle);
|
||
if (!events.length) return;
|
||
this.shotHits = 0;
|
||
this.happyPlayedThisBall = false;
|
||
playSound(this, SFX.SCIFI_LAUNCH);
|
||
this.previewG?.clear();
|
||
this.processEvents(events);
|
||
this.updateHud();
|
||
}
|
||
|
||
// ── Sim loop ────────────────────────────────────────────────────────────────
|
||
|
||
update(_time, delta) {
|
||
if (this.view !== 'play' || !this.state) return;
|
||
const st = this.state;
|
||
this.updateOverclockHum(st);
|
||
if (st.phase === 'aim') {
|
||
// Keep the round clock ticking between shots so the free-ball bucket
|
||
// patrols while the player aims (stepSim only advances time in 'aim').
|
||
stepSim(st, delta / 1000);
|
||
if (this.bucketC && !this.feverBuckets) {
|
||
if (bucketWidth(st) !== this.bucketDrawnW) {
|
||
this.bucketC.destroy();
|
||
this.drawBucket();
|
||
}
|
||
this.bucketC.x = this.sx(bucketX(st));
|
||
}
|
||
return;
|
||
}
|
||
if (st.phase !== 'flight' && st.phase !== 'fever') return;
|
||
|
||
const scale = st.phase === 'fever' ? FEVER_TIMESCALE : 1;
|
||
this.accum = Math.min(this.accum + (delta / 1000) * scale, 0.12);
|
||
while (this.accum >= SIM_STEP) {
|
||
this.accum -= SIM_STEP;
|
||
const events = stepSim(st, SIM_STEP);
|
||
if (events.length) this.processEvents(events);
|
||
if (st.phase !== 'flight' && st.phase !== 'fever') break;
|
||
}
|
||
this.updateOverclockHum(st);
|
||
this.renderDynamic();
|
||
}
|
||
|
||
renderDynamic() {
|
||
const st = this.state;
|
||
// Balls
|
||
while (this.ballImages.length < st.balls.length) {
|
||
const img = this.makeBallImage();
|
||
this.layer.add(img);
|
||
this.ballImages.push(img);
|
||
}
|
||
while (this.ballImages.length > st.balls.length) this.ballImages.pop().destroy();
|
||
for (let i = 0; i < st.balls.length; i++) {
|
||
const ball = st.balls[i];
|
||
const img = this.ballImages[i];
|
||
img.setPosition(this.sx(ball.x), this.sy(ball.y));
|
||
// Body Slam launches an oversized ball.
|
||
const r = ball.r ?? TUNING.BALL_R;
|
||
if (img._r !== r) { img._r = r; img.setDisplaySize(r * 2, r * 2); }
|
||
// Tint the ball while an attached power is active in flight.
|
||
const tint = tintForBall(ball, st.overclockShots > 0);
|
||
if (img._tint !== tint) {
|
||
img._tint = tint;
|
||
if (tint == null) img.clearTint(); else img.setTint(tint);
|
||
}
|
||
}
|
||
// Bucket (Beaver Dam widens it; rebuild when the opening changes).
|
||
if (this.bucketC && !this.feverBuckets) {
|
||
if (bucketWidth(st) !== this.bucketDrawnW) {
|
||
this.bucketC.destroy();
|
||
this.drawBucket();
|
||
}
|
||
this.bucketC.x = this.sx(bucketX(st));
|
||
}
|
||
// Fever camera follows the falling ball.
|
||
if (st.phase === 'fever' && st.balls.length && this.feverZoomed) {
|
||
const b = st.balls[0];
|
||
this.cameras.main.pan(this.sx(b.x), Math.min(this.sy(b.y) + 120, GAME_HEIGHT - 250), 180, 'Sine.easeOut', true);
|
||
}
|
||
}
|
||
|
||
// ── Event presentation ──────────────────────────────────────────────────────
|
||
|
||
processEvents(events) {
|
||
for (const e of events) {
|
||
switch (e.type) {
|
||
case 'pegHit': this.onPegHit(e); break;
|
||
case 'longShot': this.banner('LONG SHOT! +25,000', '#ffd166'); playSound(this, SFX.FIREWORK); break;
|
||
case 'freeBall':
|
||
// Rewind announces itself; don't double-banner.
|
||
if (e.reason !== 'rewind') {
|
||
this.banner('FREE BALL!', '#8df2a8');
|
||
playSound(this, SFX.UI_CHIME);
|
||
}
|
||
this.addReservoirBall();
|
||
break;
|
||
case 'ballLost':
|
||
if (this.state.shotScore === 0) this.portrait?.playEmotion('upset');
|
||
break;
|
||
case 'powerCharged':
|
||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||
this.triggerHappy();
|
||
break;
|
||
case 'powerFired': this.onPowerFired(e); break;
|
||
case 'multiball': this.banner('MULTIBALL!', '#8df2a8'); this.onMultiball(e); break;
|
||
case 'extremeSplit': this.onExtremeSplit(e); break;
|
||
case 'spaceBlast': this.onSpaceBlast(e); break;
|
||
case 'cannonBlast': this.onCannonBlast(e); break;
|
||
case 'cosmicBlast': this.onCosmicBlast(e); break;
|
||
case 'zenAdjust': this.banner('ZEN BALL', '#b6e3ff'); break;
|
||
case 'laserRow':
|
||
this.flashBeam(this.sx(0), this.sy(e.y), this.sx(BOARD_W), this.sy(e.y), 0xff4d4d);
|
||
playSound(this, SFX.LASER_ZAP);
|
||
break;
|
||
case 'meteorColumn':
|
||
this.flashBeam(this.sx(e.x), this.sy(0), this.sx(e.x), this.sy(BOARD_H), 0xff9d5c);
|
||
playSound(this, SFX.SCIFI_EXPLODE);
|
||
break;
|
||
case 'shadowSlash': {
|
||
const R = POWERS.shadowslash.params.radius;
|
||
const s = Math.SQRT1_2 * R;
|
||
this.flashBeam(this.sx(e.x - s), this.sy(e.y - s), this.sx(e.x + s), this.sy(e.y + s), 0xb9a7ff);
|
||
this.flashBeam(this.sx(e.x + s), this.sy(e.y - s), this.sx(e.x - s), this.sy(e.y + s), 0xb9a7ff);
|
||
playSound(this, SFX.SWORD_SLICE);
|
||
break;
|
||
}
|
||
case 'beamUp':
|
||
this.banner('BEAMED UP!', '#8df2ec');
|
||
playSound(this, SFX.SCIFI_REVEAL);
|
||
break;
|
||
case 'rewind':
|
||
this.banner('REWIND!', '#b6e3ff');
|
||
this.onRewindGlitch();
|
||
break;
|
||
case 'rewindPortal': this.onRewindPortal(e); break;
|
||
case 'pegsConverted':
|
||
this.refreshPegTints();
|
||
this.onCosmicBloomBurst(e);
|
||
break;
|
||
case 'purpleMoved': this.refreshPegTints(); break;
|
||
case 'pegsCleared': this.onPegsCleared(e); break;
|
||
case 'shotEnd':
|
||
this.shotHits = 0;
|
||
this.renderPreview();
|
||
// Back to aiming: the next ball leaves the tube for the cannon.
|
||
if (this.state.phase === 'aim') this.loadCannonBall();
|
||
break;
|
||
case 'feverStart': this.onFeverStart(); break;
|
||
case 'feverResolve': this.onFeverResolve(e); break;
|
||
case 'win':
|
||
// After a fever finish the celebration sequence owns the timing and
|
||
// calls showResult itself; only the direct (non-fever) path here.
|
||
if (!this.celebrating) this.time.delayedCall(1100, () => this.showResult(true));
|
||
break;
|
||
case 'lose': this.time.delayedCall(600, () => this.showResult(false)); break;
|
||
default: break;
|
||
}
|
||
}
|
||
this.updateHud();
|
||
}
|
||
|
||
onPegHit(e) {
|
||
const peg = this.state.pegs[e.pegId];
|
||
const img = this.pegImages.get(e.pegId);
|
||
if (img) {
|
||
this.applyPegVisual(img, peg); // peg.lit is already set
|
||
this.tweens.add({ targets: img, scale: img.scale * 1.25, duration: 90, yoyo: true });
|
||
}
|
||
// Rising plink, the classic Peggle feel.
|
||
const rate = Math.min(1 + this.shotHits * 0.07, 2.2);
|
||
this.shotHits++;
|
||
try { this.sound.play(SFX.SCIFI_PLINK, { rate, volume: 0.7 }); } catch (_) { /* audio locked */ }
|
||
this.bumpOverclockHum();
|
||
|
||
this.floatText(this.sx(peg.x), this.sy(peg.y) - 22, `+${e.points.toLocaleString()}`,
|
||
peg.color === 'orange' ? '#ffc266' : peg.color === 'green' ? '#8df2a8' : peg.color === 'purple' ? '#e3a8ff' : '#cfe3ff');
|
||
}
|
||
|
||
onPowerFired(e) {
|
||
const FX = {
|
||
fireball: ['FIREBALL!', '#ff9d5c'],
|
||
bodyslam: ['BODY SLAM!', '#ffd166'],
|
||
lasergrid: ['LASER SWEEP!', '#ff8f8f'],
|
||
beamup: null, // beamUp event banners on the return
|
||
meteor: ['METEOR STRIKE!', '#ff9d5c'],
|
||
overclock: ['OVERCLOCK ×2!', '#8df2ec'],
|
||
extremeball: ['EXTREME BALL!', '#ff3b3b'],
|
||
cannonball: ['CANNONBALL!', '#5a6472'],
|
||
beaverdam: ['BEAVER DAM!', '#c9a44a'],
|
||
shadowslash: ['SHADOW SLASH!', '#b9a7ff'],
|
||
cosmicbloom: ['COSMIC BLOOM!', '#8df2a8'],
|
||
};
|
||
const fx = FX[e.powerId];
|
||
if (fx) this.banner(fx[0], fx[1]);
|
||
if (e.powerId === 'spaceblast') playSound(this, SFX.SCIFI_EXPLODE);
|
||
if (e.powerId === 'overclock') this.onOverclock();
|
||
}
|
||
|
||
// Short-lived bright line used by the laser / meteor / slash powers.
|
||
flashBeam(x1, y1, x2, y2, color) {
|
||
const g = this.add.graphics().setDepth(D.fx);
|
||
g.lineStyle(10, color, 0.9);
|
||
g.lineBetween(x1, y1, x2, y2);
|
||
g.lineStyle(3, 0xffffff, 1);
|
||
g.lineBetween(x1, y1, x2, y2);
|
||
this.layer.add(g);
|
||
this.tweens.add({ targets: g, alpha: 0, duration: 480, ease: 'Cubic.easeOut', onComplete: () => g.destroy() });
|
||
}
|
||
|
||
onSpaceBlast(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const ring = this.add.circle(x, y, 20, 0x3ed164, 0.5).setDepth(D.fx);
|
||
this.layer.add(ring);
|
||
this.tweens.add({
|
||
targets: ring, radius: SCORING.SPACE_BLAST_RADIUS, alpha: 0, duration: 420, ease: 'Cubic.easeOut',
|
||
onUpdate: () => ring.setRadius(ring.radius),
|
||
onComplete: () => ring.destroy(),
|
||
});
|
||
this.banner('SPACE BLAST!', '#8df2a8');
|
||
}
|
||
|
||
// Shockwave ring where Blackwind's Cannonball detonates on the green peg.
|
||
// The CANNONBALL! banner already fires from onPowerFired, so no banner here.
|
||
onCannonBlast(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const ring = this.add.circle(x, y, 20, POWER_COLOR.cannonball, 0.5).setDepth(D.fx);
|
||
this.layer.add(ring);
|
||
this.tweens.add({
|
||
targets: ring, radius: SCORING.SPACE_BLAST_RADIUS, alpha: 0, duration: 420, ease: 'Cubic.easeOut',
|
||
onUpdate: () => ring.setRadius(ring.radius),
|
||
onComplete: () => ring.destroy(),
|
||
});
|
||
playSound(this, SFX.SCIFI_EXPLODE);
|
||
}
|
||
|
||
// Shockwave ring where Aiko's Cosmic Bloom detonates on the green peg.
|
||
// The COSMIC BLOOM! banner already fires from onPowerFired, so no banner here.
|
||
onCosmicBlast(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const ring = this.add.circle(x, y, 20, POWER_COLOR.cosmicbloom, 0.5).setDepth(D.fx);
|
||
this.layer.add(ring);
|
||
this.tweens.add({
|
||
targets: ring, radius: POWERS.cosmicbloom.params.blastRadius, alpha: 0, duration: 420, ease: 'Cubic.easeOut',
|
||
onUpdate: () => ring.setRadius(ring.radius),
|
||
onComplete: () => ring.destroy(),
|
||
});
|
||
playSound(this, SFX.SCIFI_EXPLODE);
|
||
}
|
||
|
||
// Nadia's Rewind: the drained ball glitches out before the portal opens
|
||
// (see onRewindPortal below). The REWIND! banner already fires alongside
|
||
// this from the event switch, so this is just screen-space fx + sound.
|
||
onRewindGlitch() {
|
||
playSound(this, SFX.REWIND);
|
||
this.cameras.main.shake(220, 0.007);
|
||
this.cameras.main.flash(140, 150, 210, 255, false);
|
||
for (let i = 0; i < 4; i++) {
|
||
const y = BOARD_Y + Math.random() * BOARD_H;
|
||
const h = 5 + Math.random() * 12;
|
||
const dir = Math.random() < 0.5 ? -1 : 1;
|
||
const bar = this.add.rectangle(BOARD_X + BOARD_W / 2, y, BOARD_W, h, 0x8df2ec, 0.35).setDepth(D.overlay);
|
||
this.layer.add(bar);
|
||
this.tweens.add({
|
||
targets: bar, x: bar.x + dir * (20 + Math.random() * 30), alpha: 0,
|
||
duration: 220 + Math.random() * 140, ease: 'Steps(3)',
|
||
onComplete: () => bar.destroy(),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Nadia's Rewind: the portal opens at the top of the board and the fresh
|
||
// ball streaks out toward its target; a quick ping on the target peg shows
|
||
// where it's headed just before it arrives. No banner here (REWIND! already
|
||
// fired when the ball glitched out).
|
||
onRewindPortal(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const outer = this.add.circle(x, y, 10, POWER_COLOR.rewind, 0.5).setDepth(D.fx);
|
||
const inner = this.add.circle(x, y, 6, 0xffffff, 0.8).setDepth(D.fx);
|
||
this.layer.add(outer);
|
||
this.layer.add(inner);
|
||
this.tweens.add({
|
||
targets: outer, radius: 44, alpha: 0, duration: 460, ease: 'Cubic.easeOut',
|
||
onUpdate: () => outer.setRadius(outer.radius),
|
||
onComplete: () => outer.destroy(),
|
||
});
|
||
this.tweens.add({
|
||
targets: inner, radius: 24, alpha: 0, duration: 340, ease: 'Cubic.easeOut',
|
||
onUpdate: () => inner.setRadius(inner.radius),
|
||
onComplete: () => inner.destroy(),
|
||
});
|
||
|
||
const tx = this.sx(e.targetX);
|
||
const ty = this.sy(e.targetY);
|
||
const mark = this.add.circle(tx, ty, 4, POWER_COLOR.rewind, 0.5).setDepth(D.fx);
|
||
this.layer.add(mark);
|
||
this.tweens.add({
|
||
targets: mark, radius: 22, alpha: 0, duration: 360, ease: 'Cubic.easeOut',
|
||
onUpdate: () => mark.setRadius(mark.radius),
|
||
onComplete: () => mark.destroy(),
|
||
});
|
||
|
||
playSound(this, SFX.SCIFI_LAUNCH);
|
||
}
|
||
|
||
// Radial spark burst where Kona's clone balls spawn.
|
||
onMultiball(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const burst = this.add.particles(x, y, 'peggle-spark', {
|
||
emitting: false,
|
||
quantity: 18,
|
||
angle: { min: 0, max: 360 },
|
||
speed: { min: 220, max: 420 },
|
||
lifespan: 420,
|
||
scale: { start: 1.4, end: 0 },
|
||
blendMode: 'ADD',
|
||
tint: [POWER_COLOR.multiball, 0xffffff],
|
||
}).setDepth(D.fx);
|
||
this.layer.add(burst);
|
||
burst.explode(18);
|
||
this.time.delayedCall(500, () => burst.destroy());
|
||
playSound(this, SFX.WOOSH);
|
||
}
|
||
|
||
// Small spark burst where Gerome's Extreme Ball fans out into two.
|
||
onExtremeSplit(e) {
|
||
const x = this.sx(e.x);
|
||
const y = this.sy(e.y);
|
||
const burst = this.add.particles(x, y, 'peggle-spark', {
|
||
emitting: false,
|
||
quantity: 12,
|
||
angle: { min: 0, max: 360 },
|
||
speed: { min: 160, max: 320 },
|
||
lifespan: 320,
|
||
scale: { start: 1.1, end: 0 },
|
||
blendMode: 'ADD',
|
||
tint: [POWER_COLOR.extremeball, 0xffffff],
|
||
}).setDepth(D.fx);
|
||
this.layer.add(burst);
|
||
burst.explode(12);
|
||
this.time.delayedCall(400, () => burst.destroy());
|
||
playSound(this, SFX.WOOSH);
|
||
}
|
||
|
||
// Board-wide power-surge flash for Nicole's Overclock.
|
||
onOverclock() {
|
||
const cx = BOARD_X + BOARD_W / 2;
|
||
const cy = BOARD_Y + BOARD_H / 2;
|
||
const fill = this.add.rectangle(cx, cy, BOARD_W, BOARD_H, POWER_COLOR.overclock, 0.35).setDepth(D.fx);
|
||
this.layer.add(fill);
|
||
this.tweens.add({ targets: fill, alpha: 0, duration: 380, ease: 'Cubic.easeOut', onComplete: () => fill.destroy() });
|
||
|
||
const g = this.add.graphics().setDepth(D.fx);
|
||
g.lineStyle(6, POWER_COLOR.overclock, 1);
|
||
g.strokeRect(BOARD_X, BOARD_Y, BOARD_W, BOARD_H);
|
||
this.layer.add(g);
|
||
this.tweens.add({ targets: g, alpha: 0, duration: 380, ease: 'Cubic.easeOut', onComplete: () => g.destroy() });
|
||
|
||
// The hum itself is a looped fx managed by updateOverclockHum() for as
|
||
// long as an overclock-boosted ball stays in play (see the sim loop).
|
||
this.updateOverclockHum(this.state);
|
||
}
|
||
|
||
// Starts/stops the overclock hum to match whether a boosted ball is
|
||
// currently in play. Called every sim frame from update().
|
||
updateOverclockHum(st) {
|
||
const active = st.overclockShots > 0 && st.balls.length > 0;
|
||
if (active && !this._overclockHum) {
|
||
try {
|
||
this._overclockHum = this.sound.add(SFX.ENERGY_HUM, {
|
||
loop: true, volume: OVERCLOCK_HUM_VOLUME, rate: OVERCLOCK_HUM_MIN_RATE,
|
||
});
|
||
this._overclockHum.play();
|
||
} catch (_) { this._overclockHum = null; }
|
||
} else if (!active && this._overclockHum) {
|
||
this.stopOverclockHum();
|
||
}
|
||
}
|
||
|
||
// Nudges the hum's pitch up a notch — called on every peg hit; a no-op
|
||
// whenever the hum isn't currently playing.
|
||
bumpOverclockHum() {
|
||
const hum = this._overclockHum;
|
||
if (!hum) return;
|
||
const rate = Math.min(OVERCLOCK_HUM_MAX_RATE, hum.rate + OVERCLOCK_HUM_RATE_STEP);
|
||
this.tweens.add({ targets: hum, rate, duration: 120 });
|
||
}
|
||
|
||
stopOverclockHum() {
|
||
const hum = this._overclockHum;
|
||
if (!hum) return;
|
||
this._overclockHum = null;
|
||
this.tweens.killTweensOf(hum);
|
||
try { hum.stop(); hum.destroy(); } catch (_) { /* audio locked */ }
|
||
}
|
||
|
||
// Small sparkle burst on each blue peg Aiko's Cosmic Bloom converts.
|
||
onCosmicBloomBurst(e) {
|
||
for (const id of e.pegIds) {
|
||
const peg = this.state.pegs[id];
|
||
if (!peg) continue;
|
||
const em = this.add.particles(this.sx(peg.x), this.sy(peg.y), 'peggle-spark', {
|
||
emitting: false,
|
||
quantity: 8,
|
||
angle: { min: 0, max: 360 },
|
||
speed: { min: 80, max: 180 },
|
||
lifespan: 500,
|
||
scale: { start: 1.1, end: 0 },
|
||
blendMode: 'ADD',
|
||
tint: [POWER_COLOR.cosmicbloom, 0xffffff],
|
||
}).setDepth(D.fx);
|
||
this.layer.add(em);
|
||
em.explode(8);
|
||
this.time.delayedCall(600, () => em.destroy());
|
||
}
|
||
playSound(this, SFX.GEM_CHAIN);
|
||
}
|
||
|
||
onPegsCleared(e) {
|
||
for (const id of e.pegIds) {
|
||
const img = this.pegImages.get(id);
|
||
if (!img) continue;
|
||
this.pegImages.delete(id);
|
||
this.tweens.add({
|
||
targets: img, alpha: 0, scale: img.scale * 0.3, duration: 320, ease: 'Cubic.easeIn',
|
||
onComplete: () => img.destroy(),
|
||
});
|
||
}
|
||
}
|
||
|
||
onFeverStart() {
|
||
playSound(this, SFX.SCIFI_RISER);
|
||
this.banner('EXTREME FEVER!', '#ffd166', 44);
|
||
this.triggerHappy();
|
||
|
||
// Swap the moving bucket for the 5 fever buckets.
|
||
if (this.bucketC) { this.bucketC.destroy(); this.bucketC = null; }
|
||
const g = this.add.graphics().setDepth(D.bucket);
|
||
const n = SCORING.FEVER_BUCKETS.length;
|
||
const bw = BOARD_W / n;
|
||
this.feverBuckets = g;
|
||
for (let i = 0; i < n; i++) {
|
||
const x = BOARD_X + i * bw;
|
||
g.fillStyle(i === Math.floor(n / 2) ? 0xc9a44a : 0x2f5b8f, 0.9);
|
||
g.fillRect(x + 4, BOARD_Y + BOARD_H - 46, bw - 8, 46);
|
||
const label = this.add.text(x + bw / 2, BOARD_Y + BOARD_H - 23,
|
||
`${SCORING.FEVER_BUCKETS[i] / 1000}K`, {
|
||
fontFamily: 'Righteous', fontSize: '22px', color: '#ffffff',
|
||
}).setOrigin(0.5).setDepth(D.bucket + 1);
|
||
this.layer.add(label);
|
||
this._feverLabels = this._feverLabels ?? [];
|
||
this._feverLabels.push(label);
|
||
}
|
||
this.layer.add(g);
|
||
|
||
this.feverZoomed = true;
|
||
this.cameras.main.zoomTo(1.28, 700, 'Sine.easeInOut');
|
||
}
|
||
|
||
onFeverResolve(e) {
|
||
// Zoom back to full screen at normal speed, then hand off to the
|
||
// celebration once the camera has settled.
|
||
this.feverZoomed = false;
|
||
this.celebrating = true;
|
||
this.countDone = false;
|
||
// Force-restart: a fever follow-pan may still be running, and Phaser
|
||
// ignores a non-forced pan while one is active.
|
||
this.cameras.main.pan(GAME_WIDTH / 2, GAME_HEIGHT / 2, 500, 'Sine.easeInOut', true);
|
||
this.cameras.main.zoomTo(1, 500, 'Sine.easeInOut', true);
|
||
for (const id of e.pegIds) {
|
||
const img = this.pegImages.get(id);
|
||
if (!img) continue;
|
||
this.pegImages.delete(id);
|
||
this.tweens.add({
|
||
targets: img, alpha: 0, y: img.y - 30, duration: 500, delay: Math.random() * 250,
|
||
onComplete: () => img.destroy(),
|
||
});
|
||
}
|
||
this.time.delayedCall(550, () => {
|
||
// Guarantee the camera is exactly home (centered, zoom 1) before the
|
||
// celebration starts, whatever state the pan/zoom effects ended in.
|
||
const cam = this.cameras.main;
|
||
cam.panEffect?.reset();
|
||
cam.zoomEffect?.reset();
|
||
cam.setZoom(1);
|
||
cam.centerOn(GAME_WIDTH / 2, GAME_HEIGHT / 2);
|
||
this.startFeverCelebration(e);
|
||
});
|
||
}
|
||
|
||
// ── End-of-level celebration ────────────────────────────────────────────────
|
||
|
||
startFeverCelebration(e) {
|
||
if (!this.celebrating || !this.state) return;
|
||
const n = SCORING.FEVER_BUCKETS.length;
|
||
const bw = BOARD_W / n;
|
||
const bx = BOARD_X + (e.bucketIndex + 0.5) * bw;
|
||
const by = BOARD_Y + BOARD_H - 46;
|
||
|
||
playSound(this, SFX.FIREWORK);
|
||
|
||
// Pulsing glow on the winning bucket.
|
||
const glow = this.add.rectangle(bx, BOARD_Y + BOARD_H - 23, bw - 8, 46, 0xffe9b0, 0.55).setDepth(D.bucket + 2);
|
||
this.layer.add(glow);
|
||
this.tweens.add({ targets: glow, alpha: 0.08, duration: 240, yoyo: true, repeat: 9 });
|
||
|
||
// Geyser of lights gushing out of the scoring bucket.
|
||
this.celebrationFx = this.add.particles(bx, by, 'peggle-spark', {
|
||
angle: { min: 250, max: 290 },
|
||
speed: { min: 600, max: 950 },
|
||
gravityY: 900,
|
||
lifespan: { min: 1200, max: 1800 },
|
||
scale: { start: 1.6, end: 0 },
|
||
quantity: 3,
|
||
frequency: 18,
|
||
blendMode: 'ADD',
|
||
tint: [0xffd166, 0xffffff, 0xff8a1e, 0x3ed164, 0x4d8dee, 0xe3a8ff],
|
||
}).setDepth(D.fx);
|
||
this.layer.add(this.celebrationFx);
|
||
this.time.delayedCall(1500, () => { if (this.celebrating) playSound(this, SFX.FIREWORK); });
|
||
|
||
// Big score roll-up from zero to the level total.
|
||
const cx = BOARD_X + BOARD_W / 2;
|
||
const cy = BOARD_Y + 330;
|
||
const label = this.add.text(cx, cy - 84, 'TOTAL SCORE', {
|
||
fontFamily: 'Righteous', fontSize: '34px', color: '#ffffff',
|
||
stroke: '#000000', strokeThickness: 5,
|
||
}).setOrigin(0.5).setDepth(D.banner);
|
||
this.countText = this.add.text(cx, cy, '0', {
|
||
fontFamily: 'Righteous', fontSize: '104px', color: COLORS.goldHex,
|
||
stroke: '#000000', strokeThickness: 8,
|
||
}).setOrigin(0.5).setDepth(D.banner);
|
||
this.layer.add([label, this.countText]);
|
||
|
||
this.countTotal = this.state.score;
|
||
const counter = { v: 0 };
|
||
this.countTween = this.tweens.add({
|
||
targets: counter, v: this.countTotal, duration: 2800, ease: 'Cubic.easeOut',
|
||
onUpdate: () => this.countText?.setText(Math.floor(counter.v).toLocaleString()),
|
||
onComplete: () => this.endCountUp(),
|
||
});
|
||
|
||
// Scoring tick that rises in pitch as the counter climbs.
|
||
this.countTimer = this.time.addEvent({
|
||
delay: 90,
|
||
loop: true,
|
||
callback: () => {
|
||
const p = this.countTotal > 0 ? counter.v / this.countTotal : 1;
|
||
try { this.sound.play(SFX.SCIFI_PLINK, { rate: 0.9 + 1.3 * p, volume: 0.6 }); } catch (_) { /* audio locked */ }
|
||
},
|
||
});
|
||
|
||
// Click to skip straight to the result.
|
||
this.skipHandler = () => this.skipCelebration();
|
||
this.input.on('pointerup', this.skipHandler);
|
||
}
|
||
|
||
endCountUp() {
|
||
if (this.countDone) return;
|
||
this.countDone = true;
|
||
this.countTimer?.remove();
|
||
this.countTimer = null;
|
||
this.countText?.setText(this.countTotal.toLocaleString());
|
||
playSound(this, SFX.VICTORY_SHORT);
|
||
if (this.countText) {
|
||
this.tweens.add({ targets: this.countText, scale: 1.18, duration: 160, yoyo: true });
|
||
}
|
||
this.time.delayedCall(700, () => this.finishCelebration());
|
||
}
|
||
|
||
skipCelebration() {
|
||
if (!this.celebrating) return;
|
||
if (!this.countDone) {
|
||
this.countTween?.remove();
|
||
this.countTween = null;
|
||
this.countDone = true;
|
||
this.countTimer?.remove();
|
||
this.countTimer = null;
|
||
this.countText?.setText(this.countTotal.toLocaleString());
|
||
playSound(this, SFX.VICTORY_SHORT);
|
||
}
|
||
this.finishCelebration();
|
||
}
|
||
|
||
finishCelebration() {
|
||
if (!this.celebrating) return;
|
||
this.celebrating = false;
|
||
if (this.skipHandler) { this.input.off('pointerup', this.skipHandler); this.skipHandler = null; }
|
||
this.celebrationFx?.stop();
|
||
this.showResult(true);
|
||
}
|
||
|
||
// ── FX helpers ──────────────────────────────────────────────────────────────
|
||
|
||
floatText(x, y, str, color) {
|
||
const t = this.add.text(x, y, str, {
|
||
fontFamily: 'Righteous', fontSize: '20px', color,
|
||
}).setOrigin(0.5).setDepth(D.fx);
|
||
this.layer.add(t);
|
||
this.tweens.add({
|
||
targets: t, y: y - 46, alpha: 0, duration: 800, ease: 'Cubic.easeOut',
|
||
onComplete: () => t.destroy(),
|
||
});
|
||
}
|
||
|
||
banner(str, color, size = 34) {
|
||
const t = this.add.text(BOARD_X + BOARD_W / 2, BOARD_Y + 200, str, {
|
||
fontFamily: 'Righteous', fontSize: `${size}px`, color,
|
||
stroke: '#000000', strokeThickness: 5,
|
||
}).setOrigin(0.5).setDepth(D.banner).setScale(0.6).setAlpha(0);
|
||
this.layer.add(t);
|
||
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 180, ease: 'Back.easeOut' });
|
||
this.tweens.add({
|
||
targets: t, alpha: 0, y: t.y - 30, delay: 1050, duration: 350,
|
||
onComplete: () => t.destroy(),
|
||
});
|
||
}
|
||
|
||
// ── Result ──────────────────────────────────────────────────────────────────
|
||
|
||
showResult(won) {
|
||
if (this.overlayUp) return;
|
||
this.overlayUp = true;
|
||
this.previewG?.clear();
|
||
|
||
if (won && this.canPersist && !this.testLevel) {
|
||
if (this.entry.level > this.levelsCompleted) this.levelsCompleted = this.entry.level;
|
||
api.post('/puzzles/peggle/complete', { level: this.entry.level })
|
||
.then((res) => { if (res?.levelsCompleted != null) this.levelsCompleted = Math.max(this.levelsCompleted, res.levelsCompleted); })
|
||
.catch(() => { /* best effort */ });
|
||
api.post('/history/single-player', {
|
||
slug: 'peggle', score: this.state.score, opponentScores: [], result: 'win',
|
||
}).catch(() => { /* best effort */ });
|
||
} else if (!won && this.canPersist && !this.testLevel) {
|
||
api.post('/history/single-player', {
|
||
slug: 'peggle', score: this.state.score, opponentScores: [], result: 'loss',
|
||
}).catch(() => { /* best effort */ });
|
||
}
|
||
|
||
if (won) { playSound(this, SFX.VICTORY_SHORT); }
|
||
else { playSound(this, SFX.CASINO_LOSE); this.portrait?.playEmotion('upset'); }
|
||
|
||
const cx = GAME_WIDTH / 2;
|
||
const cy = GAME_HEIGHT / 2;
|
||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH * 2, GAME_HEIGHT * 2, 0x000000, 0.62).setDepth(D.overlay).setInteractive();
|
||
const panel = this.add.graphics().setDepth(D.overlay);
|
||
panel.fillStyle(COLORS.panel, 0.98);
|
||
panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20);
|
||
panel.lineStyle(3, won ? COLORS.gold : COLORS.danger, 1);
|
||
panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20);
|
||
const title = this.add.text(cx, cy - 140, won ? 'LEVEL CLEARED!' : 'OUT OF BALLS', {
|
||
fontFamily: 'Righteous', fontSize: '56px', color: won ? COLORS.goldHex : COLORS.dangerHex,
|
||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||
const stat = this.add.text(cx, cy - 60,
|
||
won
|
||
? `${this.entry.name} cleared!\nFinal score: ${this.state.score.toLocaleString()}`
|
||
: `${this.state.orangeTotal - this.state.orangeCleared} orange pegs remained.\nScore: ${this.state.score.toLocaleString()}`, {
|
||
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', lineSpacing: 8,
|
||
}).setOrigin(0.5).setDepth(D.overlayUI);
|
||
this.layer.add([dim, panel, title, stat]);
|
||
|
||
const btns = [];
|
||
if (this.returnToEditor) {
|
||
btns.push(new Button(this, cx, cy + 60, 'Back to Editor', () => this.exitLevel(),
|
||
{ width: 320, height: 60, fontSize: 24 }));
|
||
} else if (won) {
|
||
const idx = this.manifest.findIndex((m) => m.level === this.entry.level);
|
||
const next = this.manifest[idx + 1];
|
||
if (next) {
|
||
btns.push(new Button(this, cx, cy + 60, `Next Level (${next.level})`, () => this.playLevel(next),
|
||
{ width: 340, height: 60, fontSize: 26 }));
|
||
} else {
|
||
btns.push(this.add.text(cx, cy + 55, 'You cleared every level. Bravo!', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.goldHex,
|
||
}).setOrigin(0.5));
|
||
}
|
||
} else {
|
||
btns.push(new Button(this, cx, cy + 60, 'Try Again', () => this.playLevel(this.entry, { skipIntro: true }),
|
||
{ width: 320, height: 60, fontSize: 26 }));
|
||
}
|
||
if (!this.returnToEditor) {
|
||
btns.push(new Button(this, cx - 115, cy + 145, 'Replay', () => this.playLevel(this.entry, { skipIntro: true }),
|
||
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }));
|
||
btns.push(new Button(this, cx + 115, cy + 145, 'Levels', () => this.showLevelSelect(),
|
||
{ width: 200, height: 54, fontSize: 22, variant: 'ghost' }));
|
||
}
|
||
for (const b of btns) { b.setDepth?.(D.overlayUI); this.layer.add(b); }
|
||
}
|
||
}
|