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