import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { api } from '../../services/api.js'; import { auth } from '../../services/auth.js'; import { Button } from '../../ui/Button.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { createInitialState, evaluateGuess, submitGuess, isGameOver, getLetterStatuses, } from './WordleLogic.js'; import { createAIState, chooseGuess, nextThinkDelay } from './WordleAI.js'; // ── Layout constants ─────────────────────────────────────────────────────────── const TW = 74; // tile width const TH = 74; // tile height const TG = 8; // tile gap const BOARD_W = 5 * TW + 4 * TG; // 402 const BOARD_H = 6 * TH + 5 * TG; // 484 const PLR_CX = 490; // player board center X const AI_CX = 1430; // AI board center X const BOARD_Y = 165; // board top Y const KEY_W = 52; const KEY_H = 62; const KEY_WW = 82; // wide key (Enter / ⌫) const KEY_G = 7; const KBD_Y = BOARD_Y + BOARD_H + 24; // Score display: in the gap between each board's inner edge and the center divider, // vertically halfway between the board top and the VS badge (y = BOARD_Y + BOARD_H/2). const VS_Y = BOARD_Y + BOARD_H / 2; // 407 const SCORE_Y = Math.round((BOARD_Y + VS_Y) / 2); // 286 const PLR_SCORE_X = Math.round((PLR_CX + BOARD_W / 2 + GAME_WIDTH / 2) / 2); // 826 const AI_SCORE_X = Math.round((GAME_WIDTH / 2 + AI_CX - BOARD_W / 2) / 2); // 1095 const DEPTH = { bg: 0, board: 1, tile: 2, ui: 10 }; const VD = DEPTH.ui + 20; // victory screen depth base // Tile colors const C = { empty: 0x121213, border: 0x565758, active: 0x818384, filled: 0x1a1a1b, correct: 0x538d4e, present: 0xb59f3b, absent: 0x3a3a3c, key: 0x818384, keyBg: 0x1e1e1f, }; const KEYBOARD_ROWS = [ ['Q','W','E','R','T','Y','U','I','O','P'], ['A','S','D','F','G','H','J','K','L'], ['ENTER','Z','X','C','V','B','N','M','⌫'], ]; // ── Scene ────────────────────────────────────────────────────────────────────── export default class WordleGame extends Phaser.Scene { constructor() { super('WordleGame'); } init(data) { this._initData = { ...data }; // saved for round restarts this.gameDef = data.game; this.opponent = data.opponents?.[0] ?? null; this.skill = this.opponent?.skill ?? 3; this.playfield = data.playfield ?? null; this.playerWins = data.playerWins ?? 0; // series score this.aiWins = data.aiWins ?? 0; this.skipIntro = data.skipIntro ?? false; // suppress intro speech on round restarts // round-level state this.gs = null; this.aiGs = null; this.wordPool = null; this.currentInput = ''; this.animating = false; this.playerDone = false; this.aiDone = false; this.gameEnded = false; this.aiTimer = null; this.opponentPortrait = null; } async create() { new MusicPlayer(this, this.cache.json.get('music').tracks); this.buildParticleTexture(); let answer, validWords; try { const res = await api.get('/words/wordle/start'); answer = res.answer; validWords = res.validWords; } catch (err) { console.error('[wordle] failed to fetch word:', err); answer = 'CRANE'; validWords = ['CRANE']; } this.wordPool = new Set(validWords); this.gs = createInitialState(answer); this.aiGs = createAIState(answer, validWords); this.buildBackground(); this.buildPortraits(); this.buildBoards(); this.buildKeyboard(); this.buildStatusText(); this.buildScoreDisplay(); this.buildControls(); this._startCountdown(); } // ── Build ────────────────────────────────────────────────────────────────── buildParticleTexture() { if (this.textures.exists('wordleParticle')) return; const g = this.make.graphics({ add: false }); g.fillStyle(0xffffff, 1); g.fillCircle(5, 5, 5); g.generateTexture('wordleParticle', 10, 10); g.destroy(); } buildBackground() { const cx = GAME_WIDTH / 2; this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, COLORS.bg) .setDepth(DEPTH.bg); // Divider line const g = this.add.graphics().setDepth(DEPTH.bg); g.lineStyle(1, 0x2a2a2c, 1); g.lineBetween(cx, 0, cx, GAME_HEIGHT); // Section labels const labelStyle = { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex }; this.add.text(PLR_CX, BOARD_Y - 32, 'YOU', labelStyle).setOrigin(0.5, 1).setDepth(DEPTH.ui); this.add.text(AI_CX, BOARD_Y - 32, this.opponent?.name?.toUpperCase() ?? 'CPU', labelStyle) .setOrigin(0.5, 1).setDepth(DEPTH.ui); // "VS" badge this.add.text(cx, VS_Y, 'VS', { fontFamily: 'Righteous', fontSize: '32px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(DEPTH.ui); } buildPortraits() { const r = 52; const depth = DEPTH.ui; const py = BOARD_Y + BOARD_H / 2; // vertically centered with the playfield const ppx = 120; createPlayerPortrait(this, ppx, py, r, depth, 'WordleGame'); this.add.text(ppx, py + r + 10, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5, 0).setDepth(depth + 1); const opx = GAME_WIDTH - 120; this.opponentPortrait = createOpponentPortrait(this, this.opponent, opx, py, r, depth, { playIntro: !this.skipIntro }); this.add.text(opx, py + r + 10, this.opponent?.name ?? 'CPU', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.textHex, }).setOrigin(0.5, 0).setDepth(depth + 1); this.add.text(GAME_WIDTH / 2, 55, 'WORDLE', { fontFamily: 'Righteous', fontSize: '48px', color: COLORS.textHex, }).setOrigin(0.5).setDepth(DEPTH.ui); } buildScoreDisplay() { this.playerScoreText = this.add.text(PLR_SCORE_X, SCORE_Y, String(this.playerWins), { fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff', }).setOrigin(0.5).setDepth(DEPTH.ui); this.aiScoreText = this.add.text(AI_SCORE_X, SCORE_Y, String(this.aiWins), { fontFamily: 'Righteous', fontSize: '80px', color: '#ffffff', }).setOrigin(0.5).setDepth(DEPTH.ui); this.add.text(GAME_WIDTH / 2, SCORE_Y + 48, 'first to 3', { fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.ui); } updateScoreDisplay() { this.playerScoreText?.setText(String(this.playerWins)); this.aiScoreText?.setText(String(this.aiWins)); } buildBoards() { this.playerTiles = this.createTileGrid(PLR_CX - BOARD_W / 2, BOARD_Y); this.aiTiles = this.createTileGrid(AI_CX - BOARD_W / 2, BOARD_Y); } createTileGrid(startX, startY) { const grid = []; for (let r = 0; r < 6; r++) { const row = []; for (let c = 0; c < 5; c++) { const x = startX + c * (TW + TG) + TW / 2; const y = startY + r * (TH + TG) + TH / 2; const container = this.add.container(x, y).setDepth(DEPTH.tile); const bg = this.add.rectangle(0, 0, TW, TH, C.empty) .setStrokeStyle(2, C.border); const label = this.add.text(0, 0, '', { fontFamily: 'Righteous', fontSize: '36px', color: '#ffffff', fontStyle: 'bold', }).setOrigin(0.5); container.add([bg, label]); row.push({ container, bg, label }); } grid.push(row); } return grid; } buildKeyboard() { this.keyObjs = {}; KEYBOARD_ROWS.forEach((keys, rowIdx) => { const isWide = (k) => k === 'ENTER' || k === '⌫'; const rowW = keys.reduce((s, k) => s + (isWide(k) ? KEY_WW : KEY_W) + KEY_G, -KEY_G); let x = PLR_CX - rowW / 2; keys.forEach(key => { const kw = isWide(key) ? KEY_WW : KEY_W; const cx = x + kw / 2; const cy = KBD_Y + rowIdx * (KEY_H + KEY_G) + KEY_H / 2; x += kw + KEY_G; const container = this.add.container(cx, cy).setDepth(DEPTH.ui); const bg = this.add.rectangle(0, 0, kw, KEY_H, C.key).setStrokeStyle(0); bg.setInteractive({ cursor: 'pointer', useHandCursor: true }); const lbl = this.add.text(0, 0, key, { fontFamily: '"Julius Sans One"', fontSize: key.length > 1 ? '16px' : '22px', color: '#ffffff', fontStyle: 'bold', }).setOrigin(0.5); container.add([bg, lbl]); this.keyObjs[key] = { container, bg, lbl }; bg.on('pointerdown', () => { if (key === 'ENTER') this.submitGuess(); else if (key === '⌫') this.removeLetter(); else this.addLetter(key); }); bg.on('pointerover', () => { if (!this.keyObjs[key]._locked) bg.setFillStyle(0x9a9b9c); }); bg.on('pointerout', () => { if (!this.keyObjs[key]._locked) bg.setFillStyle(this.keyObjs[key]._color ?? C.key); }); }); }); } buildStatusText() { this.statusText = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT - 36, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.ui); this.aiThinkText = this.add.text(AI_CX, KBD_Y + 31, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(DEPTH.ui); } buildControls() { new Button(this, GAME_WIDTH - 100, GAME_HEIGHT - 44, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 160, height: 44, fontSize: 20, }).setDepth(DEPTH.ui); } // ── Countdown ───────────────────────────────────────────────────────────── _startCountdown() { const steps = ['3', '2', '1', 'GO!']; const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const OVERLAY_D = DEPTH.ui + 5; const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.50) .setDepth(OVERLAY_D); const label = this.add.text(cx, cy, '', { fontFamily: 'Righteous', fontSize: '240px', color: '#f7c948', stroke: '#b08800', strokeThickness: 8, }).setOrigin(0.5).setDepth(OVERLAY_D + 1).setAlpha(0); const runStep = (i) => { if (i >= steps.length) { dim.destroy(); label.destroy(); this.setupInput(); this.scheduleAITurn(); return; } label.setText(steps[i]).setScale(1.6).setAlpha(1); playSound(this, steps[i] === 'GO!' ? SFX.COUNTDOWN_GO : SFX.COUNTDOWN_TICK); this.tweens.add({ targets: label, scaleX: 1, scaleY: 1, duration: 260, ease: 'Back.easeOut', }); this.time.delayedCall(steps[i] === 'GO!' ? 560 : 740, () => { this.tweens.add({ targets: label, alpha: 0, duration: 180, onComplete: () => runStep(i + 1), }); }); }; runStep(0); } // ── Input ────────────────────────────────────────────────────────────────── setupInput() { this.input.keyboard.on('keydown', (evt) => { if (this.animating || this.playerDone) return; if (/^[a-zA-Z]$/.test(evt.key)) this.addLetter(evt.key.toUpperCase()); else if (evt.key === 'Backspace') this.removeLetter(); else if (evt.key === 'Enter') this.submitGuess(); }); } addLetter(l) { if (this.currentInput.length >= 5 || this.playerDone || this.animating) return; this.currentInput += l; const row = this.gs.currentRow; const col = this.currentInput.length - 1; const tile = this.playerTiles[row][col]; tile.label.setText(l); tile.bg.setFillStyle(C.filled); tile.bg.setStrokeStyle(2, C.active); this.bounceTile(tile); } removeLetter() { if (this.currentInput.length === 0 || this.playerDone || this.animating) return; const row = this.gs.currentRow; const col = this.currentInput.length - 1; const tile = this.playerTiles[row][col]; tile.label.setText(''); tile.bg.setFillStyle(C.empty); tile.bg.setStrokeStyle(2, C.border); this.currentInput = this.currentInput.slice(0, -1); } async submitGuess() { if (this.animating || this.playerDone) return; if (this.currentInput.length < 5) { this.shakeRow(this.playerTiles[this.gs.currentRow]); this.statusText.setText('Not enough letters'); this.time.delayedCall(1200, () => this.statusText.setText('')); return; } if (!this.wordPool.has(this.currentInput)) { this.shakeRow(this.playerTiles[this.gs.currentRow]); this.statusText.setText('Not in word list'); this.time.delayedCall(1200, () => this.statusText.setText('')); return; } this.animating = true; const word = this.currentInput; this.currentInput = ''; const row = this.gs.currentRow; this.gs = submitGuess(this.gs, word); await this.animateTileFlip(this.playerTiles[row], word, this.gs.guesses[row].evaluation); this.updateKeyboardColors(this.gs); playSound(this, SFX.UI_ACTIVATE); const { over } = isGameOver(this.gs); if (over) { this.playerDone = true; this.animating = false; this.checkAndHandleGameOver(); } else { this.animating = false; } } // ── AI ──────────────────────────────────────────────────────────────────── scheduleAITurn() { if (this.aiDone) return; const delay = nextThinkDelay(this.skill, this.aiGs.guesses.length); this.aiThinkText.setText('...'); this.aiTimer = this.time.delayedCall(delay, () => this.doAITurn()); } async doAITurn() { if (this.aiDone || this.gameEnded) return; this.aiThinkText.setText(''); const guess = chooseGuess(this.aiGs, this.skill); const row = this.aiGs.currentRow; const evaluation = evaluateGuess(guess, this.aiGs.target); for (let c = 0; c < 5; c++) { this.aiTiles[row][c].label.setText(guess[c]); this.aiTiles[row][c].bg.setFillStyle(C.filled); } await this.delay(350); this.aiGs = submitGuess(this.aiGs, guess); await this.animateTileFlip(this.aiTiles[row], guess, evaluation); playSound(this, SFX.PIECE_CLICK); const { over } = isGameOver(this.aiGs); if (over) { this.aiDone = true; this.checkAndHandleGameOver(); } else { this.scheduleAITurn(); } } // ── Animations ──────────────────────────────────────────────────────────── animateTileFlip(rowTiles, word, evaluation) { return new Promise(resolve => { const STAGGER = 100; const HALF = 200; rowTiles.forEach((tile, i) => { this.time.delayedCall(i * STAGGER, () => { this.tweens.add({ targets: tile.container, scaleY: 0, duration: HALF, ease: 'Linear', onComplete: () => { const status = evaluation[i]; const color = status === 'correct' ? C.correct : status === 'present' ? C.present : C.absent; tile.bg.setFillStyle(color); tile.bg.setStrokeStyle(0); tile.label.setText(word[i]); this.tweens.add({ targets: tile.container, scaleY: 1, duration: HALF, ease: 'Linear', onComplete: i === 4 ? resolve : undefined, }); }, }); }); }); }); } shakeRow(rowTiles) { playSound(this, SFX.PIECE_CLICK); const containers = rowTiles.map(t => t.container); const startXs = containers.map(c => c.x); this.tweens.add({ targets: containers, x: (target, _key, _value, index) => startXs[index] + 8, duration: 50, yoyo: true, repeat: 3, ease: 'Sine.easeInOut', onComplete: () => containers.forEach((c, i) => { c.x = startXs[i]; }), }); } bounceTile(tile) { this.tweens.add({ targets: tile.container, scaleY: 1.08, duration: 80, yoyo: true, ease: 'Sine.easeOut', }); } // ── Keyboard color tracking ─────────────────────────────────────────────── updateKeyboardColors(state) { const statuses = getLetterStatuses(state); for (const [letter, status] of Object.entries(statuses)) { const keyObj = this.keyObjs[letter]; if (!keyObj) continue; const color = status === 'correct' ? C.correct : status === 'present' ? C.present : C.absent; keyObj.bg.setFillStyle(color); keyObj._color = color; keyObj._locked = true; } } // ── Game over → series logic ─────────────────────────────────────────────── checkAndHandleGameOver() { if (this.gameEnded) return; const { over: pOver, won: pWon } = isGameOver(this.gs); const { over: aOver, won: aWon } = isGameOver(this.aiGs); const shouldEnd = pWon || aWon || (pOver && aOver); if (!shouldEnd) return; this.gameEnded = true; this.playerDone = true; if (this.aiTimer) { this.aiTimer.remove(); this.aiTimer = null; } // Determine round outcome let roundWon, isDraw; if (pWon && aWon) { const pg = this.gs.guesses.length; const ag = this.aiGs.guesses.length; if (pg < ag) { roundWon = true; isDraw = false; } else if (pg > ag) { roundWon = false; isDraw = false; } else { isDraw = true; } } else if (pWon) { roundWon = true; isDraw = false; } else if (aWon) { roundWon = false; isDraw = false; } else { isDraw = true; } // Opponent reacts to the finished round: happy if the AI won, upset if it lost. if (!isDraw) this.opponentPortrait?.playEmotion(roundWon ? 'upset' : 'happy'); // Update series scores (draws give no point) if (!isDraw) { if (roundWon) this.playerWins++; else this.aiWins++; this.updateScoreDisplay(); } // Animate score bump for non-draw rounds if (!isDraw) { const scoreObj = roundWon ? this.playerScoreText : this.aiScoreText; this.tweens.add({ targets: scoreObj, scaleX: 1.4, scaleY: 1.4, duration: 160, yoyo: true, ease: 'Back.easeOut' }); } this.recordResult(isDraw ? 'draw' : roundWon ? 'win' : 'loss'); this.time.delayedCall(700, () => { if (this.playerWins >= 3 || this.aiWins >= 3) { const playerWon = this.playerWins >= 3; playSound(this, playerWon ? SFX.VICTORY_SHORT : SFX.SCIFI_RISER); this.showVictoryScreen(playerWon); } else { if (!isDraw) playSound(this, roundWon ? SFX.CASINO_WIN : SFX.CASINO_LOSE); this.showRoundResult(roundWon, isDraw); } }); } // ── Round result overlay (auto-dismissing) ──────────────────────────────── showRoundResult(roundWon, isDraw) { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const oppName = this.opponent?.name ?? 'CPU'; const headline = isDraw ? 'Draw — no point awarded' : roundWon ? 'Round won!' : 'Round lost'; const seriesLine = `Series ${this.playerWins} – ${this.aiWins} ${oppName}`; const wordLine = `The word was ${this.gs.target}`; const panel = this.add.rectangle(cx, cy, 640, 200, 0x0a0e14, 0.92) .setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.ui + 10); const t1 = this.add.text(cx, cy - 56, headline, { fontFamily: 'Righteous', fontSize: '38px', color: isDraw ? COLORS.mutedHex : roundWon ? '#6aff88' : COLORS.dangerHex, }).setOrigin(0.5).setDepth(DEPTH.ui + 11); const t2 = this.add.text(cx, cy + 2, wordLine, { fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(DEPTH.ui + 11); const t3 = this.add.text(cx, cy + 44, seriesLine, { fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.mutedHex, }).setOrigin(0.5).setDepth(DEPTH.ui + 11); this.time.delayedCall(2400, () => { panel.destroy(); t1.destroy(); t2.destroy(); t3.destroy(); this.startNextRound(); }); } startNextRound() { this.scene.restart({ ...this._initData, playerWins: this.playerWins, aiWins: this.aiWins, skipIntro: true }); } // ── Fireworks victory screen ─────────────────────────────────────────────── showVictoryScreen(playerWon) { const cx = GAME_WIDTH / 2; const cy = GAME_HEIGHT / 2; const PW = 840; const PH = 560; const top = cy - PH / 2; // 260 const bot = cy + PH / 2; // 820 // ── Fireworks ────────────────────────────────────────────────────────── const fwEmitter = this.add.particles(cx, cy, 'wordleParticle', { speed: { min: 80, max: 480 }, lifespan: 1400, scale: { start: 1.2, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 3, frequency: 35, tint: [0xffd700, 0xff6644, 0xffffff, 0x44aaff, 0xff44aa, 0x88ff44], angle: { min: 0, max: 360 }, emitZone: { type: 'random', source: new Phaser.Geom.Rectangle(-GAME_WIDTH / 2, -GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT), }, }).setDepth(VD - 1); this.time.delayedCall(3200, () => { fwEmitter.stop(); this.time.delayedCall(1400, () => fwEmitter.destroy()); }); // ── Panel ────────────────────────────────────────────────────────────── this.add.rectangle(cx, cy, PW, PH, 0x0a0e14, 0.94) .setStrokeStyle(3, COLORS.accent).setDepth(VD); // ── Title ────────────────────────────────────────────────────────────── this.add.text(cx, top + 62, playerWon ? 'You Win the Series!' : `${this.opponent?.name ?? 'CPU'} Wins!`, { fontFamily: 'Righteous', fontSize: '44px', color: playerWon ? '#ffd700' : COLORS.textHex, }).setOrigin(0.5).setDepth(VD + 1); // ── Portraits ────────────────────────────────────────────────────────── const WINNER_R = 88; const LOSER_R = 62; const portY = top + 210; // 470 const plrX = cx - 195; // 765 const oppX = cx + 195; // 1155 const plrR = playerWon ? WINNER_R : LOSER_R; const oppR = playerWon ? LOSER_R : WINNER_R; // Player portrait (left) const plrPortrait = createPlayerPortrait(this, plrX, portY, plrR, VD + 2, 'WordleGame'); this.add.text(plrX, portY + plrR + 12, auth.user?.username ?? 'You', { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, }).setOrigin(0.5, 0).setDepth(VD + 2); if (!playerWon) plrPortrait.fadeToEliminated(900); // Opponent portrait (right) const oppPortrait = createOpponentPortrait(this, this.opponent, oppX, portY, oppR, VD + 2, { playIntro: false }); this.add.text(oppX, portY + oppR + 12, this.opponent?.name ?? 'CPU', { fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex, }).setOrigin(0.5, 0).setDepth(VD + 2); if (playerWon) oppPortrait.fadeToEliminated(900); else oppPortrait.playEmotion('happy'); // ── Series score ─────────────────────────────────────────────────────── this.add.text(cx, portY + WINNER_R + 68, `${this.playerWins} – ${this.aiWins}`, { fontFamily: 'Righteous', fontSize: '52px', color: COLORS.accentHex, }).setOrigin(0.5).setDepth(VD + 1); // ── Buttons ──────────────────────────────────────────────────────────── const btnsY = bot - 64; new Button(this, cx - 130, btnsY, 'Play Again', () => { this.scene.restart({ ...this._initData, playerWins: 0, aiWins: 0, skipIntro: false }); }, { width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1); new Button(this, cx + 130, btnsY, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 230, height: 52, fontSize: 22 }).setDepth(VD + 1); } // ── History recording ────────────────────────────────────────────────────── async recordResult(result) { try { const guessesUsed = this.gs.guesses.length; const score = result === 'win' ? Math.max(17, Math.round(100 - (guessesUsed - 1) * 14)) : 0; await api.post('/history/single-player', { slug: 'wordle', score, opponentScores: [0], result, }); } catch { /* best effort */ } } // ── Utility ─────────────────────────────────────────────────────────────── delay(ms) { return new Promise(resolve => this.time.delayedCall(ms, resolve)); } shutdown() { this.opponentPortrait?.destroy(); } }