fertig-classic-games/src/games/bejeweled/BejeweledGame.js

1207 lines
49 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 { api } from '../../services/api.js';
import {
COLS, ROWS, SPECIAL, BLITZ_SECONDS, GEM_COLORS,
newGame, applyMove, lastHurrah, findMove, shuffleBoard,
} from './BejeweledLogic.js';
// ── Layout ──────────────────────────────────────────────────────────────────
const CELL = 96;
const BOARD_W = COLS * CELL; // 768
const BOARD_X = GAME_WIDTH / 2 - BOARD_W / 2; // left edge
const BOARD_Y = 196; // top edge
const TIMER_Y = 142;
// Jewel palette: base body, bright facet, dark rim, and a text-friendly hex.
const GEMS = {
red: { base: 0xe23b4e, hi: 0xffaab4, lo: 0x7d1322, hex: '#ff6d7e' },
orange: { base: 0xf08c1e, hi: 0xffd9a0, lo: 0x8a4d06, hex: '#ffb259' },
yellow: { base: 0xf2cf1d, hi: 0xfff7bb, lo: 0x8f7a08, hex: '#ffe75e' },
green: { base: 0x2ecc71, hi: 0xaef7cf, lo: 0x126b3b, hex: '#5fe89d' },
blue: { base: 0x2e9bf0, hi: 0xaadcff, lo: 0x0e4e83, hex: '#6cc1ff' },
purple: { base: 0xa64ce8, hi: 0xe2bbff, lo: 0x551a80, hex: '#c98aff' },
white: { base: 0xdde4f2, hi: 0xffffff, lo: 0x7d87a0, hex: '#ffffff' },
};
const COMBO_WORDS = [null, null, 'Good!', 'Excellent!', 'Awesome!', 'Spectacular!', 'Extraordinary!'];
const COMBO_COLORS = ['', '', '#5fe89d', '#6cc1ff', '#c98aff', '#ffb259', '#ffe75e'];
const D = { bg: -5, panel: 0, gems: 5, fx: 14, hud: 30, banner: 42, overlay: 60, overlayUI: 62 };
const BEST_KEY = 'bejeweled-best';
function mixColor(a, b, t) {
const ar = (a >> 16) & 255, ag = (a >> 8) & 255, ab = a & 255;
const br = (b >> 16) & 255, bg = (b >> 8) & 255, bb = b & 255;
const r = Math.round(ar + (br - ar) * t);
const g = Math.round(ag + (bg - ag) * t);
const bl = Math.round(ab + (bb - ab) * t);
return (r << 16) | (g << 8) | bl;
}
// Each colour gets its own silhouette so gems read at a glance.
function unitShape(color) {
const poly = (n, rotDeg) => {
const pts = [];
for (let i = 0; i < n; i++) {
const a = (Math.PI / 180) * (rotDeg + (360 / n) * i);
pts.push({ x: Math.cos(a), y: Math.sin(a) });
}
return pts;
};
switch (color) {
case 'red': return poly(4, 45);
case 'orange': return poly(5, -90);
case 'yellow': return [{ x: 0, y: -0.92 }, { x: 0.92, y: 0 }, { x: 0, y: 0.92 }, { x: -0.92, y: 0 }];
case 'green': return poly(6, 0);
case 'purple': return [{ x: 0, y: -1.15 }, { x: 1.0, y: 0.85 }, { x: -1.0, y: 0.85 }];
case 'white': return poly(8, 22.5);
default: return null; // blue → circle
}
}
export default class BejeweledGame extends Phaser.Scene {
constructor() { super('BejeweledGame'); }
init(data) {
this.gameDef = data.game ?? { slug: 'bejeweled', name: 'Bejeweled Blitz' };
this.view = 'menu';
this.state = null;
this.grid = []; // sprite containers, [r][c]
this.busy = false;
this.timeUp = false;
this.hurrahStarted = false;
this.score = 0;
this.displayScore = 0;
this.selected = null;
this.dragFrom = null;
this.lastAction = 0;
this.maxCascade = 0;
this.peakMultiplier = 1;
this.gemToggle = false;
}
create() {
try {
const music = this.cache.json.get('music');
if (music?.tracks) new MusicPlayer(this, music.tracks);
} catch (_) { /* optional */ }
this.createTextures();
this.buildBackground();
this.layer = this.add.container(0, 0);
this.input.on('pointerdown', this.onPointerDown, this);
this.input.on('pointermove', this.onPointerMove, this);
this.input.on('pointerup', () => { this.dragFrom = null; });
this.showMenu();
}
// ── Procedural textures ───────────────────────────────────────────────────
createTextures() {
if (!this.textures.exists('bj-bg')) {
const tex = this.textures.createCanvas('bj-bg', 16, 540);
const ctx = tex.getContext();
const grad = ctx.createLinearGradient(0, 0, 0, 540);
grad.addColorStop(0, '#1c1038');
grad.addColorStop(0.38, '#241349');
grad.addColorStop(0.72, '#101437');
grad.addColorStop(1, '#06070f');
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 16, 540);
tex.refresh();
}
if (!this.textures.exists('bj-beam')) {
const tex = this.textures.createCanvas('bj-beam', 256, 64);
const ctx = tex.getContext();
const gx = ctx.createLinearGradient(0, 0, 256, 0);
gx.addColorStop(0, 'rgba(255,255,255,0)');
gx.addColorStop(0.18, 'rgba(255,255,255,0.85)');
gx.addColorStop(0.5, 'rgba(255,255,255,1)');
gx.addColorStop(0.82, 'rgba(255,255,255,0.85)');
gx.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gx;
ctx.fillRect(0, 0, 256, 64);
ctx.globalCompositeOperation = 'destination-in';
const gy = ctx.createLinearGradient(0, 0, 0, 64);
gy.addColorStop(0, 'rgba(255,255,255,0)');
gy.addColorStop(0.5, 'rgba(255,255,255,1)');
gy.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = gy;
ctx.fillRect(0, 0, 256, 64);
tex.refresh();
}
if (this.textures.exists('bj-gem-red')) return;
// Soft radial glow (additive-blended everywhere for halos and flashes).
let g = this.add.graphics();
for (let i = 16; i >= 1; i--) {
const t = i / 16;
g.fillStyle(0xffffff, 0.022 + 0.085 * (1 - t));
g.fillCircle(64, 64, 64 * t);
}
g.generateTexture('bj-glow', 128, 128);
g.destroy();
g = this.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillCircle(4, 4, 4);
g.generateTexture('bj-dot', 8, 8);
g.destroy();
g = this.add.graphics();
g.fillStyle(0xffffff, 1);
g.fillPoints([
{ x: 16, y: 0 }, { x: 19, y: 13 }, { x: 32, y: 16 }, { x: 19, y: 19 },
{ x: 16, y: 32 }, { x: 13, y: 19 }, { x: 0, y: 16 }, { x: 13, y: 13 },
], true);
g.generateTexture('bj-spark', 32, 32);
g.destroy();
g = this.add.graphics();
g.lineStyle(5, 0xffffff, 1);
g.strokeCircle(32, 32, 27);
g.generateTexture('bj-ring', 64, 64);
g.destroy();
// 8-spike starburst for Star gems.
g = this.add.graphics();
const burst = [];
for (let i = 0; i < 16; i++) {
const a = (Math.PI / 8) * i - Math.PI / 2;
const r = i % 2 === 0 ? 30 : 11;
burst.push({ x: 32 + Math.cos(a) * r, y: 32 + Math.sin(a) * r });
}
g.fillStyle(0xffffff, 1);
g.fillPoints(burst, true);
g.generateTexture('bj-burst', 64, 64);
g.destroy();
g = this.add.graphics();
g.fillStyle(0xffffff, 0.55);
g.fillRoundedRect(0, 0, 64, 16, 8);
g.generateTexture('bj-sheen', 64, 16);
g.destroy();
// Faceted gems, one silhouette per colour.
const S = 108, CC = S / 2, R = 46;
const at = (pts, r, ox, oy) => pts.map((p) => ({ x: CC + ox + p.x * r, y: CC + oy + p.y * r }));
for (const color of GEM_COLORS) {
const def = GEMS[color];
const midC = mixColor(def.base, def.hi, 0.35);
const coreC = mixColor(def.base, def.hi, 0.72);
const rimC = mixColor(def.lo, 0x000000, 0.35);
const pts = unitShape(color);
g = this.add.graphics();
if (pts) {
g.fillStyle(def.lo, 1); g.fillPoints(at(pts, R, 0, 1), true);
g.fillStyle(def.base, 1); g.fillPoints(at(pts, R - 5, 0, -1), true);
g.fillStyle(midC, 1); g.fillPoints(at(pts, (R - 5) * 0.66, -3, -5), true);
g.fillStyle(coreC, 1); g.fillPoints(at(pts, (R - 5) * 0.36, -5, -8), true);
g.lineStyle(2.5, rimC, 0.9); g.strokePoints(at(pts, R, 0, 1), true, true);
} else {
g.fillStyle(def.lo, 1); g.fillCircle(CC, CC + 1, R);
g.fillStyle(def.base, 1); g.fillCircle(CC, CC - 1, R - 5);
g.fillStyle(midC, 1); g.fillCircle(CC - 3, CC - 5, (R - 5) * 0.66);
g.fillStyle(coreC, 1); g.fillCircle(CC - 5, CC - 8, (R - 5) * 0.36);
g.lineStyle(2.5, rimC, 0.9); g.strokeCircle(CC, CC + 1, R);
}
g.fillStyle(0xffffff, 0.35);
g.fillEllipse(CC - 12, CC - 19, 26, 13);
g.fillStyle(0xffffff, 0.9);
g.fillCircle(CC - 17, CC - 21, 3.5);
g.generateTexture(`bj-gem-${color}`, S, S);
g.destroy();
}
// Hypercube: an iridescent orb.
g = this.add.graphics();
const wheel = ['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'white'];
wheel.forEach((c, i) => {
const a0 = (Math.PI * 2 / wheel.length) * i - Math.PI / 2;
const a1 = a0 + Math.PI * 2 / wheel.length;
g.fillStyle(GEMS[c].base, 0.95);
g.slice(CC, CC, R, a0, a1, false);
g.fillPath();
});
g.fillStyle(0x16102e, 0.92);
g.fillCircle(CC, CC, R * 0.62);
g.fillStyle(0xffffff, 0.95);
g.fillCircle(CC, CC, R * 0.24);
g.lineStyle(3, 0xffffff, 0.8);
g.strokeCircle(CC, CC, R);
g.fillStyle(0xffffff, 0.3);
g.fillEllipse(CC - 12, CC - 19, 26, 13);
g.generateTexture('bj-hyper', S, S);
g.destroy();
}
// ── Cosmic backdrop ───────────────────────────────────────────────────────
buildBackground() {
const bgKey = this.textures.exists('bg-bejeweled') ? 'bg-bejeweled' : 'bj-bg';
this.add.image(0, 0, bgKey).setOrigin(0).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(D.bg);
const nebulas = [
{ x: 420, y: 280, tint: 0x6633ff, alpha: 0.17, scale: 9 },
{ x: 1520, y: 760, tint: 0x2255ff, alpha: 0.15, scale: 10 },
{ x: 1080, y: 170, tint: 0xff3aa0, alpha: 0.10, scale: 7 },
{ x: 250, y: 900, tint: 0x00c2a8, alpha: 0.08, scale: 6 },
];
for (const n of nebulas) {
const img = this.add.image(n.x, n.y, 'bj-glow')
.setScale(n.scale).setTint(n.tint).setAlpha(n.alpha)
.setBlendMode(Phaser.BlendModes.ADD).setDepth(D.bg);
this.tweens.add({
targets: img,
x: n.x + Phaser.Math.Between(-70, 70),
y: n.y + Phaser.Math.Between(-50, 50),
scale: n.scale * 1.12,
duration: Phaser.Math.Between(11000, 17000),
yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
for (let i = 0; i < 90; i++) {
const star = this.add.image(
Phaser.Math.Between(0, GAME_WIDTH), Phaser.Math.Between(0, GAME_HEIGHT),
i % 9 === 0 ? 'bj-spark' : 'bj-dot',
).setScale(Phaser.Math.FloatBetween(0.15, 0.55))
.setAlpha(Phaser.Math.FloatBetween(0.15, 0.8))
.setDepth(D.bg).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({
targets: star, alpha: 0.05,
duration: Phaser.Math.Between(700, 2600),
delay: Phaser.Math.Between(0, 2000),
yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
}
}
clearLayer() {
this.stopTimer();
for (const row of this.grid) {
for (const sprite of row ?? []) {
if (!sprite) continue;
this.tweens.killTweensOf(sprite);
sprite.each((child) => this.tweens.killTweensOf(child));
}
}
this.tweens.killTweensOf(this.layer.list);
this.layer.removeAll(true);
if (this.boardMask) { this.boardMask.destroy(); this.boardMask = null; }
this.grid = [];
this.selected = null;
this.selRing = null;
this.scoreText = null;
this.multText = null;
this.timerFill = null;
this.timerText = null;
}
// ── Menu ──────────────────────────────────────────────────────────────────
showMenu() {
this.view = 'menu';
this.clearLayer();
const cx = GAME_WIDTH / 2;
const halo = this.add.image(cx, 200, 'bj-glow').setScale(8, 3.2)
.setTint(0xd4a017).setAlpha(0.35).setBlendMode(Phaser.BlendModes.ADD);
const title = this.add.text(cx, 168, 'BEJEWELED', {
fontFamily: 'Righteous', fontSize: '116px', color: '#ffffff',
}).setOrigin(0.5);
title.setTint(0xfff3c0, 0xffe14d, 0xd4a017, 0xb8741a);
const blitz = this.add.text(cx, 282, 'B L I T Z', {
fontFamily: 'Righteous', fontSize: '64px', color: '#6cc1ff',
}).setOrigin(0.5);
blitz.setTint(0xaadcff, 0xaadcff, 0x2e9bf0, 0x6633ff);
this.layer.add([halo, title, blitz]);
this.tweens.add({ targets: halo, alpha: 0.2, scaleX: 8.6, duration: 2400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
// A row of slowly bobbing gems under the title.
GEM_COLORS.forEach((color, i) => {
const x = cx - 330 + i * 110;
const gem = this.add.image(x, 420, `bj-gem-${color}`).setScale(0.95);
const glow = this.add.image(x, 420, 'bj-glow').setScale(1.1)
.setTint(GEMS[color].base).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD);
this.layer.add([glow, gem]);
this.tweens.add({
targets: [gem, glow], y: 404, duration: 1500, delay: i * 160,
yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
});
const sub = this.add.text(cx, 532, '60 seconds. Match gems. Chase the cascade.', {
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
}).setOrigin(0.5);
const rules = this.add.text(cx, 588,
'Match 4 → Flame Gem • L or T shape → Star Gem • Match 5 → Hypercube', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5);
this.layer.add([sub, rules]);
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
if (best > 0) {
const bestText = this.add.text(cx, 648, `Best score: ${best.toLocaleString()}`, {
fontFamily: 'Righteous', fontSize: '30px', color: COLORS.goldHex,
}).setOrigin(0.5);
this.layer.add(bestText);
}
const play = new Button(this, cx, 770, 'Play', () => this.startGame(),
{ width: 340, height: 76, fontSize: 32 });
const back = new Button(this, cx, 880, 'Back', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 240, height: 60, fontSize: 24 });
this.layer.add([play, back]);
}
// ── Game setup ────────────────────────────────────────────────────────────
startGame() {
this.view = 'play';
this.clearLayer();
this.state = newGame();
this.score = 0;
this.displayScore = 0;
this.timeLeft = BLITZ_SECONDS;
this.timeUp = false;
this.hurrahStarted = false;
this.busy = true; // until the intro drop settles
this.maxCascade = 0;
this.peakMultiplier = 1;
this.lastAction = this.time.now;
this.drawBoardPanel();
this.buildHud();
this.gemLayer = this.add.container(0, 0).setDepth(D.gems);
this.layer.add(this.gemLayer);
this.boardMask = this.make.graphics({ add: false });
this.boardMask.fillRect(BOARD_X - 4, BOARD_Y - 4, BOARD_W + 8, BOARD_W + 8);
this.gemLayer.setMask(this.boardMask.createGeometryMask());
this.buildGems(true);
this.time.delayedCall(900, () => playSound(this, SFX.GEM_BIG_DROP));
this.time.delayedCall(950, () => {
this.busy = false;
this.lastAction = this.time.now;
this.startTimer();
});
}
cellXY(c, r) {
return { x: BOARD_X + c * CELL + CELL / 2, y: BOARD_Y + r * CELL + CELL / 2 };
}
drawBoardPanel() {
const p = this.add.graphics().setDepth(D.panel);
// Outer aura.
for (let i = 4; i >= 1; i--) {
p.lineStyle(i * 5, 0x7b5cff, 0.05 * (5 - i));
p.strokeRoundedRect(BOARD_X - 16, BOARD_Y - 16, BOARD_W + 32, BOARD_W + 32, 26);
}
p.fillStyle(0x0a0c22, 0.78);
p.fillRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24);
p.lineStyle(2, 0x9d8bff, 0.85);
p.strokeRoundedRect(BOARD_X - 14, BOARD_Y - 14, BOARD_W + 28, BOARD_W + 28, 24);
// Checkered cells.
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
p.fillStyle(0xffffff, (c + r) % 2 === 0 ? 0.045 : 0.085);
p.fillRoundedRect(BOARD_X + c * CELL + 2, BOARD_Y + r * CELL + 2, CELL - 4, CELL - 4, 10);
}
}
this.layer.add(p);
}
buildHud() {
const leftX = 250;
const panel = this.add.graphics().setDepth(D.hud);
panel.fillStyle(0x0a0c22, 0.72);
panel.fillRoundedRect(leftX - 190, 196, 380, 470, 22);
panel.lineStyle(2, 0x9d8bff, 0.6);
panel.strokeRoundedRect(leftX - 190, 196, 380, 470, 22);
this.layer.add(panel);
const mk = (y, txt, opts) => {
const t = this.add.text(leftX, y, txt, opts).setOrigin(0.5).setDepth(D.hud);
this.layer.add(t);
return t;
};
mk(150, 'BEJEWELED BLITZ', { fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex });
mk(250, 'SCORE', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex });
this.scoreText = mk(310, '0', { fontFamily: 'Righteous', fontSize: '62px', color: COLORS.goldHex });
mk(400, 'MULTIPLIER', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex });
this.multText = mk(452, '×1', { fontFamily: 'Righteous', fontSize: '46px', color: '#6cc1ff' }).setAlpha(0.45);
mk(540, 'BEST', { fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex });
const best = Number(localStorage.getItem(BEST_KEY) ?? 0);
mk(588, best.toLocaleString(), { fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex });
const hint = new Button(this, leftX, 730, 'Hint', () => this.showHint(),
{ width: 240, height: 56, fontSize: 22, variant: 'ghost' });
const restart = new Button(this, leftX, 806, 'New Game', () => { if (!this.busy) this.startGame(); },
{ width: 240, height: 56, fontSize: 22 });
const menu = new Button(this, leftX, 882, 'Menu', () => { if (!this.busy) this.showMenu(); },
{ width: 240, height: 56, fontSize: 22, variant: 'ghost' });
[hint, restart, menu].forEach((b) => { b.setDepth(D.hud); this.layer.add(b); });
this.buildLegend();
this.buildTimerBar();
}
buildLegend() {
const x = GAME_WIDTH - 250;
const panel = this.add.graphics().setDepth(D.hud);
panel.fillStyle(0x0a0c22, 0.72);
panel.fillRoundedRect(x - 190, 196, 380, 470, 22);
panel.lineStyle(2, 0x9d8bff, 0.6);
panel.strokeRoundedRect(x - 190, 196, 380, 470, 22);
this.layer.add(panel);
const title = this.add.text(x, 236, 'SPECIAL GEMS', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.hud);
this.layer.add(title);
const rows = [
{ tex: 'bj-gem-red', glow: 0xff7a1a, name: 'Flame Gem', desc: 'Match 4 — blasts a 3×3 area', burst: false },
{ tex: 'bj-gem-blue', glow: 0xffffff, name: 'Star Gem', desc: 'L or T match — clears row + column', burst: true },
{ tex: 'bj-hyper', glow: 0xc98aff, name: 'Hypercube', desc: 'Match 5 — swap to zap a whole colour', burst: false },
{ tex: 'bj-gem-green', glow: 0xffd24a, name: 'Multiplier', desc: 'Drops in big cascades — boosts scoring', mult: true },
];
rows.forEach((rowDef, i) => {
const y = 312 + i * 92;
const glow = this.add.image(x - 130, y, 'bj-glow').setScale(0.85)
.setTint(rowDef.glow).setAlpha(0.7).setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud);
const icon = this.add.image(x - 130, y, rowDef.tex).setScale(0.62).setDepth(D.hud);
const name = this.add.text(x - 80, y - 18, rowDef.name, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
const desc = this.add.text(x - 80, y + 12, rowDef.desc, {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
wordWrap: { width: 250 },
}).setOrigin(0, 0.5).setDepth(D.hud);
this.layer.add([glow, icon, name, desc]);
if (rowDef.burst) {
const b = this.add.image(x - 130, y, 'bj-burst').setScale(0.8).setAlpha(0.95)
.setBlendMode(Phaser.BlendModes.ADD).setDepth(D.hud);
this.layer.add(b);
}
if (rowDef.mult) {
const badge = this.add.text(x - 130, y, '×', {
fontFamily: 'Righteous', fontSize: '30px', color: '#ffd24a', stroke: '#101226', strokeThickness: 5,
}).setOrigin(0.5).setDepth(D.hud);
this.layer.add(badge);
}
});
}
buildTimerBar() {
const bg = this.add.graphics().setDepth(D.hud);
bg.fillStyle(0x0a0c22, 0.85);
bg.fillRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16);
bg.lineStyle(2, 0x9d8bff, 0.6);
bg.strokeRoundedRect(BOARD_X, TIMER_Y - 16, BOARD_W, 32, 16);
this.layer.add(bg);
this.timerFill = this.add.graphics().setDepth(D.hud);
this.layer.add(this.timerFill);
this.timerText = this.add.text(BOARD_X + BOARD_W + 24, TIMER_Y, '60', {
fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex,
}).setOrigin(0, 0.5).setDepth(D.hud);
this.layer.add(this.timerText);
this.redrawTimer();
}
redrawTimer() {
if (!this.timerFill) return;
const t = Math.max(0, this.timeLeft) / BLITZ_SECONDS;
const color = t > 0.5
? mixColor(0xffd24a, 0x3ddc84, (t - 0.5) * 2)
: mixColor(0xff4d5e, 0xffd24a, t * 2);
this.timerFill.clear();
const w = Math.max(0, (BOARD_W - 8) * t);
if (w > 16) {
this.timerFill.fillStyle(color, 1);
this.timerFill.fillRoundedRect(BOARD_X + 4, TIMER_Y - 12, w, 24, 12);
}
this.timerText.setText(String(Math.ceil(Math.max(0, this.timeLeft))));
this.timerText.setColor(this.timeLeft <= 10 ? '#ff4d5e' : COLORS.textHex);
}
startTimer() {
this.stopTimer();
this.timerEvent = this.time.addEvent({
delay: 100, loop: true,
callback: () => {
if (this.timeUp) return;
this.timeLeft -= 0.1;
if (this.timeLeft <= 0) {
this.timeLeft = 0;
this.timeUp = true;
this.stopTimer();
this.redrawTimer();
if (!this.busy) this.beginLastHurrah();
return;
}
this.redrawTimer();
},
});
}
stopTimer() {
if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; }
}
// ── Gem sprites ───────────────────────────────────────────────────────────
makeGem(cell, c, r) {
const { x, y } = this.cellXY(c, r);
const cont = this.add.container(x, y);
cont._color = cell.color;
cont._special = cell.special;
if (cell.special === SPECIAL.HYPER) {
const glow = this.add.image(0, 0, 'bj-glow').setScale(1.3)
.setTint(0xc98aff).setAlpha(0.85).setBlendMode(Phaser.BlendModes.ADD);
const orb = this.add.image(0, 0, 'bj-hyper').setScale(CELL / 108 * 0.94);
const sheen = this.add.image(0, -10, 'bj-sheen').setAlpha(0.8).setAngle(-30);
cont.add([glow, orb, sheen]);
this.tweens.add({ targets: glow, scale: 1.6, alpha: 0.45, duration: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: sheen, angle: 330, duration: 2600, repeat: -1 });
} else {
if (cell.special === SPECIAL.FLAME) {
const glow = this.add.image(0, 0, 'bj-glow').setScale(1.15)
.setTint(0xff7a1a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD);
const core = this.add.image(0, 0, 'bj-glow').setScale(0.55)
.setTint(0xffd24a).setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD);
cont.add([glow, core]);
this.tweens.add({ targets: glow, scale: 1.45, alpha: 0.55, duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
this.tweens.add({ targets: core, scale: 0.8, duration: 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
} else if (cell.special === SPECIAL.STAR) {
const glow = this.add.image(0, 0, 'bj-glow').setScale(1.2)
.setAlpha(0.9).setBlendMode(Phaser.BlendModes.ADD);
cont.add(glow);
this.tweens.add({ targets: glow, scale: 1.55, alpha: 0.5, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
const img = this.add.image(0, 0, `bj-gem-${cell.color}`).setScale(CELL / 108 * 0.94);
cont.add(img);
if (cell.special === SPECIAL.STAR) {
const star = this.add.image(0, 0, 'bj-burst').setScale(1.05)
.setAlpha(0.95).setBlendMode(Phaser.BlendModes.ADD);
cont.add(star);
this.tweens.add({ targets: star, scale: 1.25, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
if (cell.special === SPECIAL.MULT) {
const badge = this.add.circle(26, 26, 17, 0x101226, 0.94).setStrokeStyle(2.5, 0xffd24a, 1);
const sym = this.add.text(26, 26, '×', {
fontFamily: 'Righteous', fontSize: '27px', color: '#ffd24a',
}).setOrigin(0.5, 0.56);
cont.add([badge, sym]);
this.tweens.add({ targets: [badge, sym], scale: 1.18, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
}
this.gemLayer.add(cont);
return cont;
}
destroyGem(cont) {
if (!cont) return;
this.tweens.killTweensOf(cont);
cont.each((child) => this.tweens.killTweensOf(child));
cont.destroy();
}
buildGems(intro = false) {
this.grid = [];
for (let r = 0; r < ROWS; r++) {
this.grid[r] = [];
for (let c = 0; c < COLS; c++) {
const sprite = this.makeGem(this.state.board[r][c], c, r);
this.grid[r][c] = sprite;
if (intro) {
const finalY = sprite.y;
sprite.y = finalY - (ROWS + 2) * CELL;
this.tweens.add({
targets: sprite, y: finalY,
delay: c * 45 + r * 22,
duration: 430, ease: 'Bounce.easeOut',
});
}
}
}
}
// Safety net: after every move make sprites agree with the board exactly.
resyncSprites() {
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const cell = this.state.board[r][c];
const sprite = this.grid[r][c];
const { x, y } = this.cellXY(c, r);
if (sprite && sprite._color === cell.color && sprite._special === cell.special) {
sprite.setPosition(x, y);
continue;
}
this.destroyGem(sprite);
this.grid[r][c] = this.makeGem(cell, c, r);
}
}
}
// ── Input ─────────────────────────────────────────────────────────────────
cellAt(x, y) {
const c = Math.floor((x - BOARD_X) / CELL);
const r = Math.floor((y - BOARD_Y) / CELL);
if (c < 0 || c >= COLS || r < 0 || r >= ROWS) return null;
return { c, r };
}
onPointerDown(pointer) {
if (this.view !== 'play' || this.busy || this.timeUp) return;
this.lastAction = this.time.now;
const cell = this.cellAt(pointer.x, pointer.y);
if (!cell) { this.clearSelection(); return; }
if (this.selected) {
const d = Math.abs(this.selected.c - cell.c) + Math.abs(this.selected.r - cell.r);
if (d === 1) {
const from = this.selected;
this.clearSelection();
this.attemptSwap(from, cell);
return;
}
if (d === 0) { this.clearSelection(); return; }
}
this.select(cell);
this.dragFrom = cell;
this.dragStart = { x: pointer.x, y: pointer.y };
}
onPointerMove(pointer) {
if (!this.dragFrom || !pointer.isDown || this.busy || this.timeUp || this.view !== 'play') return;
const dx = pointer.x - this.dragStart.x;
const dy = pointer.y - this.dragStart.y;
if (Math.max(Math.abs(dx), Math.abs(dy)) < 32) return;
const from = this.dragFrom;
this.dragFrom = null;
const to = Math.abs(dx) > Math.abs(dy)
? { c: from.c + Math.sign(dx), r: from.r }
: { c: from.c, r: from.r + Math.sign(dy) };
if (to.c < 0 || to.c >= COLS || to.r < 0 || to.r >= ROWS) return;
this.clearSelection();
this.attemptSwap(from, to);
}
select(cell) {
this.clearSelection();
this.selected = cell;
const { x, y } = this.cellXY(cell.c, cell.r);
this.selRing = this.add.image(x, y, 'bj-ring').setScale(CELL / 64 * 0.92)
.setTint(0xffffff).setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({
targets: this.selRing, scale: CELL / 64 * 1.02, alpha: 0.6,
duration: 420, yoyo: true, repeat: -1, ease: 'Sine.easeInOut',
});
playSound(this, SFX.PIECE_CLICK);
}
clearSelection() {
this.selected = null;
if (this.selRing) { this.tweens.killTweensOf(this.selRing); this.selRing.destroy(); this.selRing = null; }
}
// ── Moves ─────────────────────────────────────────────────────────────────
attemptSwap(a, b) {
if (this.busy || this.timeUp || this.view !== 'play') return;
const board = this.state.board;
const isHyperSwap = board[a.r][a.c]?.special === SPECIAL.HYPER
|| board[b.r][b.c]?.special === SPECIAL.HYPER;
const phases = applyMove(this.state, a, b);
this.lastAction = this.time.now;
if (!phases) { this.invalidSwap(a, b); return; }
this.busy = true;
const sa = this.grid[a.r][a.c];
const sb = this.grid[b.r][b.c];
const pa = this.cellXY(a.c, a.r);
const pb = this.cellXY(b.c, b.r);
if (isHyperSwap) {
// The hypercube fires in place: pull the gems together, then detonate.
this.tweens.add({ targets: sa, x: pa.x + (pb.x - pa.x) * 0.3, y: pa.y + (pb.y - pa.y) * 0.3, duration: 110, yoyo: true });
this.tweens.add({
targets: sb, x: pb.x + (pa.x - pb.x) * 0.3, y: pb.y + (pa.y - pb.y) * 0.3, duration: 110, yoyo: true,
onComplete: () => this.runPhases(phases, () => this.afterMove()),
});
} else {
this.grid[a.r][a.c] = sb;
this.grid[b.r][b.c] = sa;
this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 170, ease: 'Quad.easeInOut' });
this.tweens.add({
targets: sb, x: pa.x, y: pa.y, duration: 170, ease: 'Quad.easeInOut',
onComplete: () => this.runPhases(phases, () => this.afterMove()),
});
}
}
invalidSwap(a, b) {
this.busy = true;
const sa = this.grid[a.r][a.c];
const sb = this.grid[b.r][b.c];
const pa = this.cellXY(a.c, a.r);
const pb = this.cellXY(b.c, b.r);
this.tweens.add({ targets: sa, x: pb.x, y: pb.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut' });
this.tweens.add({
targets: sb, x: pa.x, y: pa.y, duration: 130, yoyo: true, ease: 'Quad.easeInOut',
onComplete: () => { this.busy = false; },
});
}
afterMove() {
this.resyncSprites();
this.busy = false;
this.lastAction = this.time.now;
if (this.timeUp) { this.beginLastHurrah(); return; }
if (this.state.noMoves) this.doReshuffle();
}
doReshuffle() {
this.busy = true;
this.showBanner('NO MORE MOVES', '#ff6d7e', 'reshuffling the gems…');
for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) {
const s = this.grid[r][c];
this.tweens.add({
targets: s, alpha: 0, scale: 0.3, delay: (c + r) * 18, duration: 240, ease: 'Quad.easeIn',
onComplete: () => this.destroyGem(s),
});
}
this.time.delayedCall(620, () => {
shuffleBoard(this.state);
this.buildGems(true);
this.time.delayedCall(900, () => {
this.busy = false;
if (this.timeUp) this.beginLastHurrah();
});
});
}
// ── Phase animation ───────────────────────────────────────────────────────
runPhases(phases, done) {
const step = (i) => {
if (i >= phases.length) { done(); return; }
this.animatePhase(phases[i], () => step(i + 1));
};
step(0);
}
animatePhase(phase, done) {
this.maxCascade = Math.max(this.maxCascade, phase.cascade);
this.peakMultiplier = Math.max(this.peakMultiplier, phase.multiplier);
// Special-gem fireworks, staggered so chains read as chains.
phase.events.forEach((e, i) => {
this.time.delayedCall(i * 90, () => this.playEvent(e));
});
// Apply total time bonus after all events in the phase.
const totalTimeBonus = phase.events.reduce((sum, e) => sum + (e.timeBonus ?? 0), 0);
if (totalTimeBonus > 0) {
this.timeLeft = Math.min(this.timeLeft + totalTimeBonus, BLITZ_SECONDS);
this.redrawTimer();
}
// Clear matched gems with a burst — sound depends on match size.
if (phase.cleared.length) {
const hasHyper = phase.spawns.some((s) => s.special === SPECIAL.HYPER);
const hasFlame = phase.spawns.some((s) => s.special === SPECIAL.FLAME);
if (hasHyper) {
playSound(this, SFX.GEM_MATCH_5);
} else if (hasFlame) {
playSound(this, SFX.GEM_MATCH_4);
} else {
playSound(this, this.gemToggle ? SFX.GEM_MATCH_2 : SFX.GEM_MATCH_1);
this.gemToggle = !this.gemToggle;
}
}
const sparse = phase.cleared.length > 14;
phase.cleared.forEach((cell, i) => {
const sprite = this.grid[cell.r][cell.c];
this.grid[cell.r][cell.c] = null;
if (!sprite) return;
this.tweens.add({
targets: sprite, scale: 0, alpha: 0, duration: 180, ease: 'Back.easeIn',
onComplete: () => this.destroyGem(sprite),
});
if (!sparse || i % 2 === 0) this.gemBurst(sprite.x, sprite.y, cell.color);
});
// Score & combo callout at the centroid of the clear.
if (phase.points > 0 && phase.cleared.length) {
let mx = 0, my = 0;
for (const cell of phase.cleared) { const p = this.cellXY(cell.c, cell.r); mx += p.x; my += p.y; }
mx /= phase.cleared.length; my /= phase.cleared.length;
this.addScore(phase.points);
this.time.delayedCall(70, () => this.scorePopup(mx, my, phase.points, phase.cascade));
if (phase.cascade >= 2) this.time.delayedCall(120, () => this.comboCallout(phase.cascade));
}
// Newly earned specials flash into existence.
phase.spawns.forEach((s) => {
this.time.delayedCall(190, () => {
const old = this.grid[s.r][s.c];
this.destroyGem(old);
const sprite = this.makeGem({ color: s.color, special: s.special }, s.c, s.r);
this.grid[s.r][s.c] = sprite;
sprite.setScale(1.7).setAlpha(0);
this.tweens.add({ targets: sprite, scale: 1, alpha: 1, duration: 260, ease: 'Back.easeOut' });
const flash = this.add.image(sprite.x, sprite.y, 'bj-glow').setScale(0.6)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({
targets: flash, scale: 2.2, alpha: 0, duration: 380,
onComplete: () => flash.destroy(),
});
});
});
// Gravity: surviving gems slide down, fresh ones rain in from above.
const FALL_AT = 250;
let maxFall = 0;
this.time.delayedCall(FALL_AT, () => {
for (const f of phase.falls) {
const sprite = this.grid[f.fromR][f.c];
this.grid[f.toR][f.c] = sprite;
this.grid[f.fromR][f.c] = null;
if (!sprite) continue;
const { y } = this.cellXY(f.c, f.toR);
this.tweens.add({
targets: sprite, y,
duration: 110 + 58 * (f.toR - f.fromR), ease: 'Bounce.easeOut',
});
}
for (const f of phase.refills) {
const sprite = this.makeGem({ color: f.color, special: f.special }, f.c, f.fromR);
this.grid[f.r][f.c] = sprite;
const { y } = this.cellXY(f.c, f.r);
this.tweens.add({
targets: sprite, y,
duration: 110 + 58 * (f.r - f.fromR), ease: 'Bounce.easeOut',
});
}
});
for (const f of phase.falls) maxFall = Math.max(maxFall, 110 + 58 * (f.toR - f.fromR));
for (const f of phase.refills) maxFall = Math.max(maxFall, 110 + 58 * (f.r - f.fromR));
if (maxFall > 0 && (phase.falls.length || phase.refills.length)) {
this.time.delayedCall(FALL_AT + maxFall, () => playSound(this, SFX.GEM_DROP));
}
this.time.delayedCall(FALL_AT + maxFall + 70, done);
}
playEvent(e) {
if (e.type === 'mult') {
playSound(this, SFX.COINS);
this.multText.setText(`×${e.multiplier}`).setAlpha(1);
this.tweens.add({ targets: this.multText, scale: 1.5, duration: 160, yoyo: true, ease: 'Quad.easeOut' });
this.showBanner(`MULTIPLIER ×${e.multiplier}!`, '#6cc1ff');
if (e.timeBonus > 0) this.time.delayedCall(300, () => this.timeBonusBanner(e.timeBonus));
}
const { x, y } = this.cellXY(e.c, e.r);
if (e.type === 'flame') {
playSound(this, SFX.GEM_CHAIN);
playSound(this, SFX.SCIFI_EXPLODE);
this.cameras.main.shake(110, 0.0045);
const flash = this.add.image(x, y, 'bj-glow').setScale(1).setTint(0xff7a1a)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: flash, scale: 4.5, alpha: 0, duration: 420, onComplete: () => flash.destroy() });
const ring = this.add.image(x, y, 'bj-ring').setScale(0.6).setTint(0xffd24a)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: ring, scale: 4, alpha: 0, duration: 380, onComplete: () => ring.destroy() });
const em = this.add.particles(x, y, 'bj-dot', {
speed: { min: 130, max: 420 }, lifespan: 600, quantity: 26,
scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 },
tint: [0xff7a1a, 0xffd24a, 0xff4d5e, 0xffffff],
blendMode: 'ADD',
}).setDepth(D.fx);
this.time.delayedCall(60, () => em.stop());
this.time.delayedCall(800, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(500, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'star') {
playSound(this, SFX.GEM_CHAIN);
this.cameras.main.shake(90, 0.003);
const h = this.add.image(BOARD_X + BOARD_W / 2, y, 'bj-beam')
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110);
const v = this.add.image(x, BOARD_Y + BOARD_W / 2, 'bj-beam').setAngle(90)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD).setDisplaySize(BOARD_W + 60, 110);
this.tweens.add({ targets: [h, v], alpha: 0, duration: 360, ease: 'Quad.easeIn',
onComplete: () => { h.destroy(); v.destroy(); } });
const em = this.add.particles(x, y, 'bj-spark', {
speed: { min: 80, max: 260 }, lifespan: 500, quantity: 14,
scale: { start: 0.8, end: 0 }, alpha: { start: 1, end: 0 },
tint: 0xffffff, blendMode: 'ADD',
}).setDepth(D.fx);
this.time.delayedCall(60, () => em.stop());
this.time.delayedCall(700, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(400, () => this.timeBonusBanner(e.timeBonus));
} else if (e.type === 'hyper') {
playSound(this, SFX.GEM_CHAIN);
playSound(this, SFX.SCIFI_REVEAL);
this.cameras.main.shake(170, 0.006);
const tint = e.color ? GEMS[e.color].base : 0xffffff;
const flash = this.add.image(x, y, 'bj-glow').setScale(1.4).setTint(tint)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: flash, scale: 7, alpha: 0, duration: 520, onComplete: () => flash.destroy() });
// Lightning to each zapped gem.
const bolts = this.add.graphics().setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
const targets = (e.cells ?? []).slice(0, 20);
for (const [c, r] of targets) {
const p = this.cellXY(c, r);
bolts.lineStyle(3, tint, 0.9);
bolts.beginPath();
bolts.moveTo(x, y);
const segs = 3;
for (let i = 1; i <= segs; i++) {
const t = i / segs;
const jx = (i < segs) ? Phaser.Math.Between(-22, 22) : 0;
const jy = (i < segs) ? Phaser.Math.Between(-22, 22) : 0;
bolts.lineTo(x + (p.x - x) * t + jx, y + (p.y - y) * t + jy);
}
bolts.strokePath();
}
this.tweens.add({ targets: bolts, alpha: 0, duration: 300, onComplete: () => bolts.destroy() });
const em = this.add.particles(x, y, 'bj-spark', {
speed: { min: 140, max: 480 }, lifespan: 700, quantity: 30,
scale: { start: 1, end: 0 }, alpha: { start: 1, end: 0 },
tint: [tint, 0xffffff], blendMode: 'ADD',
}).setDepth(D.fx);
this.time.delayedCall(80, () => em.stop());
this.time.delayedCall(900, () => em.destroy());
if (e.timeBonus > 0) this.time.delayedCall(600, () => this.timeBonusBanner(e.timeBonus));
}
}
gemBurst(x, y, color) {
const tint = GEMS[color]?.base ?? 0xffffff;
const em = this.add.particles(x, y, 'bj-dot', {
speed: { min: 60, max: 200 }, lifespan: 420, quantity: 8,
scale: { start: 0.9, end: 0 }, alpha: { start: 1, end: 0 },
tint: [tint, mixColor(tint, 0xffffff, 0.6)], blendMode: 'ADD',
}).setDepth(D.fx);
this.time.delayedCall(40, () => em.stop());
this.time.delayedCall(520, () => em.destroy());
}
addScore(points) {
this.score += points;
if (this.scoreTween) this.scoreTween.stop();
const from = this.displayScore;
const counter = { v: from };
this.scoreTween = this.tweens.add({
targets: counter, v: this.score, duration: 320, ease: 'Quad.easeOut',
onUpdate: () => {
this.displayScore = Math.round(counter.v);
if (this.scoreText) this.scoreText.setText(this.displayScore.toLocaleString());
},
});
}
scorePopup(x, y, points, cascade) {
const size = Math.min(30 + cascade * 7, 64);
const t = this.add.text(x, y, `+${points.toLocaleString()}`, {
fontFamily: 'Righteous', fontSize: `${size}px`, color: COLORS.goldHex,
stroke: '#100c04', strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.banner);
this.tweens.add({
targets: t, y: y - 70, alpha: 0, duration: 800, ease: 'Quad.easeOut',
onComplete: () => t.destroy(),
});
}
timeBonusBanner(seconds) {
const cx = GAME_WIDTH / 2;
const cy = BOARD_Y + BOARD_W / 2 - 40 - 75;
const t = this.add.text(cx, cy, `+${seconds} Seconds Added!`, {
fontFamily: 'Righteous', fontSize: '52px', color: '#5eff8a',
stroke: '#0a2210', strokeThickness: 8,
}).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
this.tweens.add({ targets: t, alpha: 0, delay: 950, duration: 300, onComplete: () => t.destroy() });
}
comboCallout(cascade) {
const idx = Math.min(cascade, COMBO_WORDS.length - 1);
const word = cascade >= 7 ? 'UNBELIEVABLE!' : COMBO_WORDS[idx];
const color = cascade >= 7 ? '#ffe75e' : COMBO_COLORS[idx];
if (!word) return;
const t = this.add.text(GAME_WIDTH / 2, BOARD_Y + 240, word, {
fontFamily: 'Righteous', fontSize: `${46 + cascade * 6}px`, color,
stroke: '#0a0c22', strokeThickness: 8,
}).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 200, ease: 'Back.easeOut' });
this.tweens.add({
targets: t, alpha: 0, y: t.y - 40, delay: 600, duration: 320,
onComplete: () => t.destroy(),
});
}
showBanner(text, color, subText) {
const cx = GAME_WIDTH / 2;
const cy = BOARD_Y + BOARD_W / 2 - 40;
const t = this.add.text(cx, cy, text, {
fontFamily: 'Righteous', fontSize: '64px', color,
stroke: '#0a0c22', strokeThickness: 10,
}).setOrigin(0.5).setDepth(D.banner).setScale(0.3).setAlpha(0);
this.tweens.add({ targets: t, scale: 1, alpha: 1, duration: 220, ease: 'Back.easeOut' });
this.tweens.add({ targets: t, alpha: 0, delay: 950, duration: 300, onComplete: () => t.destroy() });
if (subText) {
const s = this.add.text(cx, cy + 56, subText, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
stroke: '#0a0c22', strokeThickness: 6,
}).setOrigin(0.5).setDepth(D.banner).setAlpha(0);
this.tweens.add({ targets: s, alpha: 1, duration: 220 });
this.tweens.add({ targets: s, alpha: 0, delay: 950, duration: 300, onComplete: () => s.destroy() });
}
}
// ── Hint ──────────────────────────────────────────────────────────────────
showHint() {
if (this.view !== 'play' || this.busy || this.timeUp) return;
const mv = findMove(this.state.board);
if (!mv) return;
this.lastAction = this.time.now;
for (const cell of [mv.a, mv.b]) {
const sprite = this.grid[cell.r][cell.c];
if (!sprite) continue;
this.tweens.add({ targets: sprite, scale: 1.18, duration: 180, yoyo: true, repeat: 2, ease: 'Sine.easeInOut' });
const { x, y } = this.cellXY(cell.c, cell.r);
const glow = this.add.image(x, y, 'bj-glow').setScale(1.4).setAlpha(0.8)
.setDepth(D.fx).setBlendMode(Phaser.BlendModes.ADD);
this.tweens.add({ targets: glow, alpha: 0, scale: 1.9, duration: 900, onComplete: () => glow.destroy() });
}
}
update(time) {
if (this.view !== 'play') return;
// Low-clock pulse.
if (this.timerFill && this.timeLeft <= 10 && this.timeLeft > 0) {
this.timerFill.setAlpha(0.65 + 0.35 * Math.sin(time / 90));
}
// Gentle automatic hint when the player stalls.
if (!this.busy && !this.timeUp && time - this.lastAction > 7000) {
this.lastAction = time;
this.showHint();
}
}
// ── Endgame ───────────────────────────────────────────────────────────────
beginLastHurrah() {
if (this.hurrahStarted) return;
this.hurrahStarted = true;
this.busy = true;
this.clearSelection();
const phases = lastHurrah(this.state);
if (!phases.length) { this.time.delayedCall(700, () => this.gameOver()); this.showBanner("TIME'S UP!", '#ff6d7e'); return; }
this.showBanner('LAST HURRAH!', '#ffe75e', 'every special gem detonates');
this.time.delayedCall(1000, () => {
this.runPhases(phases, () => this.time.delayedCall(300, () => this.gameOver()));
});
}
gameOver() {
this.view = 'over';
playSound(this, SFX.VICTORY_SHORT);
const prevBest = Number(localStorage.getItem(BEST_KEY) ?? 0);
const newBest = this.score > prevBest;
if (newBest) localStorage.setItem(BEST_KEY, String(this.score));
api.post('/history/single-player', {
slug: 'bejeweled', score: this.score, opponentScores: [], result: 'win',
}).catch(() => { /* best effort */ });
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x05060f, 0.7)
.setDepth(D.overlay).setInteractive();
this.layer.add(dim);
const panel = this.add.graphics().setDepth(D.overlay);
panel.fillStyle(0x0a0c22, 0.97);
panel.fillRoundedRect(cx - 390, cy - 270, 780, 540, 26);
panel.lineStyle(3, newBest ? 0xffd24a : 0x9d8bff, 1);
panel.strokeRoundedRect(cx - 390, cy - 270, 780, 540, 26);
this.layer.add(panel);
const title = this.add.text(cx, cy - 198, "TIME'S UP!", {
fontFamily: 'Righteous', fontSize: '64px', color: '#ff6d7e',
}).setOrigin(0.5).setDepth(D.overlayUI);
const scoreLabel = this.add.text(cx, cy - 116, 'FINAL SCORE', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
const scoreText = this.add.text(cx, cy - 48, '0', {
fontFamily: 'Righteous', fontSize: '84px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add([title, scoreLabel, scoreText]);
const counter = { v: 0 };
this.tweens.add({
targets: counter, v: this.score, duration: 1100, ease: 'Cubic.easeOut',
onUpdate: () => scoreText.setText(Math.round(counter.v).toLocaleString()),
});
const stats = this.add.text(cx, cy + 38,
`Biggest cascade ×${this.maxCascade} • Top multiplier ×${this.peakMultiplier}`, {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(stats);
if (newBest) {
const nb = this.add.text(cx, cy + 92, '★ NEW BEST SCORE ★', {
fontFamily: 'Righteous', fontSize: '36px', color: '#ffd24a',
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(nb);
this.tweens.add({ targets: nb, scale: 1.1, duration: 480, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
const em = this.add.particles(cx, cy - 270, 'bj-dot', {
x: { min: -360, max: 360 }, speedY: { min: 120, max: 260 }, speedX: { min: -40, max: 40 },
lifespan: 2400, quantity: 2, frequency: 70, scale: { start: 0.8, end: 0.2 },
alpha: { start: 1, end: 0 },
tint: Object.values(GEMS).map((gem) => gem.base), blendMode: 'ADD',
}).setDepth(D.overlayUI);
this.time.delayedCall(3600, () => em.destroy());
} else if (prevBest > 0) {
const bb = this.add.text(cx, cy + 92, `Best: ${prevBest.toLocaleString()}`, {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(D.overlayUI);
this.layer.add(bb);
}
const again = new Button(this, cx - 170, cy + 188, 'Play Again', () => this.startGame(),
{ width: 290, height: 64, fontSize: 26 }).setDepth(D.overlayUI);
const menu = new Button(this, cx + 170, cy + 188, 'Menu', () => this.showMenu(),
{ width: 290, height: 64, fontSize: 26, variant: 'ghost' }).setDepth(D.overlayUI);
this.layer.add([again, menu]);
}
}