1207 lines
49 KiB
JavaScript
1207 lines
49 KiB
JavaScript
import * as Phaser from 'phaser';
|
||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||
import { playSound, SFX } from '../../ui/Sounds.js';
|
||
import { 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]);
|
||
}
|
||
}
|