fertig-classic-games/src/games/peggle/PeggleGame.js

1660 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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); }
}
}