import * as Phaser from 'phaser'; import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; import { Button } from '../../ui/Button.js'; import { createOpponentPortrait } from '../../ui/Portrait.js'; import { playSound, SFX } from '../../ui/Sounds.js'; import { MusicPlayer } from '../../ui/MusicPlayer.js'; import { makeConfig, createInitialState, setPlayerCode, applyGuess, isGameOver, } from './MastermindLogic.js'; import { chooseGuess, nextThinkDelay } from './MastermindAI.js'; // ── Cyberpunk palette ────────────────────────────────────────────────────────── const C = { bg: 0x05070d, bgGlow: 0x0a1530, grid: 0x123047, cyan: 0x00f0ff, magenta: 0xff2bd6, dim: 0x16323f, slot: 0x0c1822, slotEdge: 0x1f5e6e, exact: 0x39ff14, // a solved peg partial: 0xffd400, // right colour, wrong slot text: 0xd6f7ff, textDim: 0x5f8a9a, }; // Up to 8 neon code colours (we use config.colors of them). const CODE_COLORS = [ 0x00f0ff, // cyan 0xff2bd6, // magenta 0x39ff14, // green 0xffd400, // yellow 0xff6a00, // orange 0x9d4bff, // violet 0xff1f5a, // rose 0xffffff, // white ]; const DEPTH = { bg: 0, grid: 1, scan: 2, panel: 5, rows: 8, fx: 20, compose: 25, ui: 40, banner: 80, overlay: 90, }; // ── Layout ─────────────────────────────────────────────────────────────────── const PANEL_TOP = 200; // top of each trace panel frame const SECRET_Y = PANEL_TOP + 50; // secret-code row inside the panel header const ROWS_TOP = PANEL_TOP + 100; // first guess row const ROW_H = 46; const PEG_R = 16; const PEG_GAP = 46; const LEFT_CX = 540; const RIGHT_CX = 1380; const FB_R = 9; const SECRET_R = 13; // smaller pegs for the secret display const DRAFT_Y = 808; // y of the draft/compose peg row export default class MastermindGame extends Phaser.Scene { constructor() { super('MastermindGame'); } init(data) { this.gameDef = data.game; this.opponents = data.opponents ?? []; this.skill = this.opponents[0]?.skill ?? 3; // Fixed classic configuration: 4 pegs, 6 colours, duplicates, 10 guesses. this.config = makeConfig(data.codeConfig ?? { pegs: 4, colors: 6, duplicates: true, maxGuesses: 10 }); this.phase = 'setup'; // 'setup' | 'duel' | 'over' this.busy = false; this.draft = []; this._glitchToggle = 0; // alternates the two peg-click glitch SFX } create() { try { new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []); } catch (_) {} this.gs = createInitialState(this.config); this.buildTextures(); this.buildBackground(); this.buildScanlines(); this.buildTitle(); this.buildPanels(); this.buildPortrait(); this.buildTurnArrow(); this.fxLayer = this.add.container(0, 0).setDepth(DEPTH.fx); this.leftLayer = this.add.container(0, 0).setDepth(DEPTH.rows); this.rightLayer = this.add.container(0, 0).setDepth(DEPTH.rows); this.composeLayer = this.add.container(0, 0).setDepth(DEPTH.compose); new Button(this, GAME_WIDTH - 120, 60, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 160, height: 46, fontSize: 20, }).setDepth(DEPTH.ui); this.renderPanels(); this.beginSetup(); } // ── Textures ─────────────────────────────────────────────────────────────── buildTextures() { // Soft neon dot for particles. const g = this.make.graphics({ x: 0, y: 0, add: false }); g.fillStyle(0xffffff, 1); g.fillCircle(8, 8, 8); g.fillStyle(0xffffff, 0.5); g.fillCircle(8, 8, 4); g.generateTexture('mmDot', 16, 16); g.destroy(); // Scanline strip: faint horizontal lines we scroll vertically. const h = 6; const s = this.make.graphics({ x: 0, y: 0, add: false }); s.fillStyle(0x00f0ff, 0.05); s.fillRect(0, 0, GAME_WIDTH, 2); s.generateTexture('mmScan', GAME_WIDTH, h); s.destroy(); } buildBackground() { this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, C.bg) .setDepth(DEPTH.bg); // Radial-ish glow band behind the title. const glow = this.add.rectangle(GAME_WIDTH / 2, 120, GAME_WIDTH, 320, C.bgGlow, 0.35) .setDepth(DEPTH.bg); glow.setBlendMode(Phaser.BlendModes.ADD); // Faint perspective grid. const grid = this.add.graphics().setDepth(DEPTH.grid); grid.lineStyle(1, C.grid, 0.35); for (let x = 0; x <= GAME_WIDTH; x += 60) grid.lineBetween(x, 0, x, GAME_HEIGHT); for (let y = 0; y <= GAME_HEIGHT; y += 60) grid.lineBetween(0, y, GAME_WIDTH, y); grid.setAlpha(0.5); } buildScanlines() { const scan = this.add.tileSprite(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 'mmScan') .setDepth(DEPTH.scan).setAlpha(0.6); scan.setBlendMode(Phaser.BlendModes.ADD); this.tweens.add({ targets: scan, tilePositionY: GAME_HEIGHT, duration: 9000, repeat: -1, ease: 'Linear', }); this.scan = scan; } buildTitle() { const t = this.add.text(GAME_WIDTH / 2, 80, 'M A S T E R M I N D', { fontFamily: 'Righteous', fontSize: '58px', color: '#00f0ff', }).setOrigin(0.5).setDepth(DEPTH.ui); // Magenta ghost layer for a chromatic-aberration shimmer. const ghost = this.add.text(GAME_WIDTH / 2 + 3, 82, 'M A S T E R M I N D', { fontFamily: 'Righteous', fontSize: '58px', color: '#ff2bd6', }).setOrigin(0.5).setDepth(DEPTH.scan).setAlpha(0.35); ghost.setBlendMode(Phaser.BlendModes.ADD); this.tweens.add({ targets: ghost, x: GAME_WIDTH / 2 - 3, duration: 1700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); this.subtitle = this.add.text(GAME_WIDTH / 2, 140, '', { fontFamily: '"Julius Sans One"', fontSize: '22px', color: '#5f8a9a', }).setOrigin(0.5).setDepth(DEPTH.ui); } buildPanels() { this.panelHeaderL = this.makePanelFrame(LEFT_CX, 'YOUR DECRYPT // crack the rival code'); this.panelHeaderR = this.makePanelFrame(RIGHT_CX, 'RIVAL TRACE // cracking your code'); } makePanelFrame(cx, title) { const rows = this.config.maxGuesses; const w = 660; const top = PANEL_TOP; const bottom = ROWS_TOP + (rows - 1) * ROW_H + PEG_R + 16; const h = bottom - top; const gfx = this.add.graphics().setDepth(DEPTH.panel); gfx.fillStyle(C.slot, 0.55); gfx.fillRoundedRect(cx - w / 2, top, w, h, 14); gfx.lineStyle(2, C.slotEdge, 0.9); gfx.strokeRoundedRect(cx - w / 2, top, w, h, 14); // Divider separating the secret-code area from the guess rows. gfx.lineStyle(1, C.slotEdge, 0.6); gfx.lineBetween(cx - w / 2 + 20, SECRET_Y + 28, cx + w / 2 - 20, SECRET_Y + 28); const header = this.add.text(cx, top - 26, title, { fontFamily: '"Julius Sans One"', fontSize: '20px', color: '#00f0ff', }).setOrigin(0.5).setDepth(DEPTH.panel); return header; } buildPortrait() { try { this.portrait = createOpponentPortrait(this, this.opponents[0], GAME_WIDTH / 2, 250, 92, DEPTH.ui); } catch (_) { this.portrait = null; } this.add.text(GAME_WIDTH / 2, 360, `SKILL ${'▮'.repeat(this.skill)}${'▯'.repeat(5 - this.skill)}`, { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#ff2bd6', }).setOrigin(0.5).setDepth(DEPTH.ui); } // Whose-turn indicator: a pulsing yellow arrow centred between the two secret // windows. Built hidden; shown once secrets are locked in. DOM (not canvas) so // it always layers above the opponent portrait that sits in the same spot. buildTurnArrow() { const el = document.createElement('div'); el.textContent = '◀'; el.style.cssText = [ 'font-family:Righteous, sans-serif', 'font-size:64px', 'line-height:1', 'color:#ffd400', 'text-shadow:0 0 14px rgba(255,212,0,0.95)', 'pointer-events:none', 'user-select:none', ].join(';'); this._turnArrowEl = el; this.turnArrow = this.add.dom(GAME_WIDTH / 2, SECRET_Y, el).setVisible(false); } // Point the arrow toward the active panel and pulse it back and forth that way. setTurn(side) { if (!this.turnArrow) return; this._turnTween?.stop(); const baseX = GAME_WIDTH / 2; const left = side === 'player'; this._turnArrowEl.textContent = left ? '◀' : '▶'; this.turnArrow.setVisible(true).setPosition(baseX, SECRET_Y).setScale(1); this._turnTween = this.tweens.add({ targets: this.turnArrow, x: baseX + (left ? -22 : 22), scaleX: 1.18, scaleY: 1.18, duration: 520, yoyo: true, repeat: -1, ease: 'Sine.easeInOut', }); } hideTurnArrow() { this._turnTween?.stop(); this._turnTween = null; this.turnArrow?.destroy(); this.turnArrow = null; } // ── Geometry helpers ───────────────────────────────────────────────────────── rowY(i) { return ROWS_TOP + i * ROW_H; } guessPegX(cx, col) { const pegs = this.config.pegs; return cx - 95 - ((pegs - 1) * PEG_GAP) / 2 + col * PEG_GAP; } // ── Rendering ────────────────────────────────────────────────────────────── renderPanels(animate, opts = {}) { const { pegsPop = true, hideFeedbackRow = -1 } = opts; // Left trace: you cracking the rival's code — keep their secret HIDDEN until // the game is over (i.e. you've discovered it). this.renderSide(this.leftLayer, LEFT_CX, this.gs.playerGuesses, { label: 'RIVAL SECRET', code: this.gs.aiCode, hidden: this.phase !== 'over' }, animate === 'left' ? this.gs.playerGuesses.length - 1 : -1, pegsPop, animate === 'left' ? hideFeedbackRow : -1); // Right trace: the rival cracking your code — your own secret is yours to see. this.renderSide(this.rightLayer, RIGHT_CX, this.gs.aiGuesses, { label: 'YOUR SECRET', code: this.gs.playerCode, hidden: false }, animate === 'right' ? this.gs.aiGuesses.length - 1 : -1, pegsPop, animate === 'right' ? hideFeedbackRow : -1); } renderSide(layer, cx, guesses, secret, animateRow, pegsPop = true, hideFeedbackRow = -1) { layer.removeAll(true); const rows = this.config.maxGuesses; this.renderSecret(layer, cx, secret); for (let r = 0; r < rows; r++) { const y = this.rowY(r); const entry = guesses[r]; // Row index chip. const idx = this.add.text(cx - 300, y, String(r + 1).padStart(2, '0'), { fontFamily: '"Julius Sans One"', fontSize: '14px', color: entry ? '#00f0ff' : '#2a4654', }).setOrigin(0.5); layer.add(idx); for (let c = 0; c < this.config.pegs; c++) { const x = this.guessPegX(cx, c); if (entry) { const peg = this.makePeg(x, y, entry.guess[c], PEG_R); layer.add(peg); if (r === animateRow && pegsPop) this.popIn(peg, c * 60); } else { layer.add(this.makeEmptySlot(x, y)); } } if (entry && r !== hideFeedbackRow) this.renderFeedback(layer, cx, y, entry, r === animateRow); } } // The secret-code area at the top of a trace panel. renderSecret(layer, cx, secret) { const heading = this.add.text(cx - 300, SECRET_Y, secret.label, { fontFamily: '"Julius Sans One"', fontSize: '14px', color: '#5f8a9a', }).setOrigin(0, 0.5); layer.add(heading); if (!secret.code) { // Not locked in yet (your secret, during setup). for (let c = 0; c < this.config.pegs; c++) { layer.add(this.makeEmptySlot(this.guessPegX(cx, c), SECRET_Y, SECRET_R)); } return; } if (secret.hidden) { const txt = this.add.text(cx - 95, SECRET_Y, 'HIDDEN', { fontFamily: 'Righteous', fontSize: '26px', color: '#ff2bd6', }).setOrigin(0.5); layer.add(txt); return; } for (let c = 0; c < this.config.pegs; c++) { layer.add(this.makePeg(this.guessPegX(cx, c), SECRET_Y, secret.code[c], SECRET_R)); } } makePeg(x, y, colorIndex, radius) { const color = CODE_COLORS[colorIndex] ?? 0x888888; const peg = this.add.circle(x, y, radius, color); peg.setStrokeStyle(2, 0xffffff, 0.65); return peg; } makeEmptySlot(x, y, radius = PEG_R) { const slot = this.add.circle(x, y, radius, C.slot, 0.4); slot.setStrokeStyle(2, C.slotEdge, 0.7); return slot; } // One feedback marker. Order is exact (green) first, then partial (yellow), // then dim blanks. makeFeedbackMarker(cx, y, i, entry) { const pegs = this.config.pegs; const startX = cx + 95; const fx = startX + (i % 4) * (FB_R * 2 + 6); const fy = y - (pegs > 4 ? (i < 4 ? 8 : -8) : 0); let mk; if (i < entry.exact) { mk = this.add.circle(fx, fy, FB_R, C.exact); mk.setStrokeStyle(2, 0xffffff, 0.8); } else if (i < entry.exact + entry.partial) { mk = this.add.circle(fx, fy, FB_R, C.partial, 0); mk.setStrokeStyle(3, C.partial, 1); } else { mk = this.add.circle(fx, fy, FB_R - 3, C.dim, 0.6); } return mk; } renderFeedback(layer, cx, y, entry, animate) { const pegs = this.config.pegs; for (let i = 0; i < pegs; i++) { const mk = this.makeFeedbackMarker(cx, y, i, entry); layer.add(mk); if (animate) this.popIn(mk, pegs * 60 + i * 50); } } // Reveal a row's feedback markers one at a time (0.5s apart), sounding a tone // for each colour-only match and exact match. Calls onDone after the last one. revealFeedback(layer, cx, rowIndex, entry, onDone) { const pegs = this.config.pegs; const y = this.rowY(rowIndex); let i = 0; const step = () => { if (i >= pegs) { onDone(); return; } const mk = this.makeFeedbackMarker(cx, y, i, entry); layer.add(mk); this.popIn(mk, 0); if (i < entry.exact) playSound(this, SFX.MASTERMIND_MATCH); else if (i < entry.exact + entry.partial) playSound(this, SFX.MASTERMIND_COLOR); i++; this.time.delayedCall(500, step); }; step(); } popIn(obj, delay) { obj.setScale(0); this.tweens.add({ targets: obj, scale: 1, duration: 240, delay, ease: 'Back.easeOut' }); } // ── Compose area (shared by setup + duel) ──────────────────────────────────── beginSetup() { this.phase = 'setup'; this.draft = []; this.subtitle.setText('SET YOUR SECRET CODE — the rival will try to crack it'); this.renderCompose(); } beginDuel() { this.phase = 'duel'; this.draft = []; this.subtitle.setText('CRACK THE RIVAL CODE — your move'); this.gs.turn = 'player'; this.renderPanels(); this.renderCompose(); // Slide the compose interface from under the rival panel (where you set your // secret) across to under your own decrypt panel. this.composeLayer.x = RIGHT_CX - LEFT_CX; this.tweens.add({ targets: this.composeLayer, x: 0, duration: 450, ease: 'Cubic.easeInOut' }); this.setTurn('player'); } renderCompose() { this.composeLayer.removeAll(true); const pegs = this.config.pegs; // Dock the interface under the panel it relates to: while setting your secret // it lives under the rival's trace (which shows YOUR SECRET); once locked in // it sits under your own decrypt panel. const pcx = this.phase === 'setup' ? RIGHT_CX : LEFT_CX; const draftY = DRAFT_Y; const palY = 876; const btnY = 948; const label = this.add.text(pcx, draftY - 50, this.phase === 'setup' ? 'YOUR SECRET' : 'YOUR GUESS', { fontFamily: '"Julius Sans One"', fontSize: '18px', color: '#5f8a9a', }).setOrigin(0.5); this.composeLayer.add(label); // Draft slots. const gap = PEG_GAP + 6; const startX = pcx - ((pegs - 1) * gap) / 2; for (let i = 0; i < pegs; i++) { const x = startX + i * gap; if (this.draft[i] != null) { const peg = this.makePeg(x, draftY, this.draft[i], PEG_R + 3); peg.setInteractive({ useHandCursor: true }); peg.on('pointerdown', () => this.removeDraftAt(i)); this.composeLayer.add(peg); } else { this.composeLayer.add(this.makeEmptySlot(x, draftY)); } } // Palette swatches. const sw = 50, sgap = 16; const totalW = this.config.colors * sw + (this.config.colors - 1) * sgap; const sx0 = pcx - totalW / 2 + sw / 2; for (let c = 0; c < this.config.colors; c++) { const x = sx0 + c * (sw + sgap); const chip = this.add.circle(x, palY, sw / 2, CODE_COLORS[c]); chip.setStrokeStyle(3, 0xffffff, 0.6); chip.setInteractive({ useHandCursor: true }); chip.on('pointerover', () => chip.setScale(1.15)); chip.on('pointerout', () => chip.setScale(1)); chip.on('pointerdown', () => this.addDraft(c)); this.composeLayer.add(chip); } // Action buttons, centred in a row beneath the palette. const clear = new Button(this, pcx - 110, btnY, 'CLEAR', () => { this.draft = []; this.renderCompose(); }, { variant: 'ghost', width: 160, height: 50, fontSize: 20 }); const submit = new Button(this, pcx + 110, btnY, this.phase === 'setup' ? 'LOCK IN' : 'SUBMIT', () => this.onSubmit(), { width: 200, height: 54, fontSize: 24 }); submit.setEnabled(this.draft.filter((d) => d != null).length === pegs && !this.busy); this.composeLayer.add([clear, submit]); } addDraft(colorIndex) { if (this.busy) return; if (this.phase === 'duel' && this.gs.turn !== 'player') return; if (this.draft.filter((d) => d != null).length >= this.config.pegs) return; // fill first empty slot for (let i = 0; i < this.config.pegs; i++) { if (this.draft[i] == null) { this.draft[i] = colorIndex; break; } } this.playPegClick(); this.renderCompose(); } // Alternate the two glitch SFX on each peg click. playPegClick() { const key = this._glitchToggle ? SFX.MASTERMIND_GLITCH_2 : SFX.MASTERMIND_GLITCH_1; this._glitchToggle = this._glitchToggle ? 0 : 1; playSound(this, key); } removeDraftAt(i) { if (this.busy) return; this.draft[i] = null; this.renderCompose(); } onSubmit() { const filled = this.draft.filter((d) => d != null); if (filled.length !== this.config.pegs || this.busy) return; if (this.phase === 'setup') { setPlayerCode(this.gs, this.draft.slice()); this.busy = true; this.animateLockIn(() => { this.busy = false; this.beginDuel(); }); return; } this.playerGuess(this.draft.slice()); } // Lock-in: fly each secret peg up to the YOUR SECRET row of the rival panel. animateLockIn(done) { this.composeLayer.removeAll(true); this.flyPegs(RIGHT_CX, this.gs.playerCode, SECRET_Y, SECRET_R, done); } // Fly a row of pegs from the draft slots (under panel `cx`) up to their // destination row, resizing as they travel, with a glitch shake the moment // each one lands. Used for both locking in a secret and submitting a guess. // Calls done() once the last peg arrives. flyPegs(cx, code, destY, destRadius, done) { const pegs = this.config.pegs; const gap = PEG_GAP + 6; const startX = cx - ((pegs - 1) * gap) / 2; const srcScale = (PEG_R + 3) / destRadius; this.fxLayer.removeAll(true); let landed = 0; for (let i = 0; i < pegs; i++) { const peg = this.makePeg(startX + i * gap, DRAFT_Y, code[i], destRadius); peg.setScale(srcScale); this.fxLayer.add(peg); this.tweens.add({ targets: peg, x: this.guessPegX(cx, i), y: destY, scale: 1, duration: 520, delay: i * 500, ease: 'Cubic.easeInOut', onStart: () => playSound(this, SFX.MASTERMIND_PLACE), onComplete: () => { this.glitch(140); if (++landed === pegs) { this.fxLayer.removeAll(true); done(); } }, }); } } // ── Turn flow ─────────────────────────────────────────────────────────────── playerGuess(guess) { this.busy = true; this.draft = []; this.renderCompose(); // empties the draft slots; palette stays disabled playSound(this, SFX.PIECE_CLICK); const rowIndex = this.gs.playerGuesses.length; this.flyPegs(LEFT_CX, guess, this.rowY(rowIndex), PEG_R, () => { applyGuess(this.gs, 'player', guess); // Pegs are already flown in; hold the feedback back for a staggered reveal. this.renderPanels('left', { pegsPop: false, hideFeedbackRow: rowIndex }); this.revealFeedback(this.leftLayer, LEFT_CX, rowIndex, this.gs.playerGuesses[rowIndex], () => { // The opponent only guesses once our reveal has fully finished. if (isGameOver(this.gs)) { this.endGame(); return; } this.setTurn('ai'); this.aiTurn(); }); }); } aiTurn() { this.gs.turn = 'ai'; this.subtitle.setText('RIVAL IS DECRYPTING…'); this.renderCompose(); const guess = chooseGuess(this.gs, this.skill); const rowIndex = this.gs.aiGuesses.length; this.time.delayedCall(nextThinkDelay(this.skill), () => { this.scramble(rowIndex, guess, () => { applyGuess(this.gs, 'ai', guess); this.renderPanels('right', { hideFeedbackRow: rowIndex }); this.glitch(160); this.revealFeedback(this.rightLayer, RIGHT_CX, rowIndex, this.gs.aiGuesses[rowIndex], () => { if (isGameOver(this.gs)) { this.endGame(); return; } this.setTurn('player'); this.busy = false; this.subtitle.setText('CRACK THE RIVAL CODE — your move'); this.gs.turn = 'player'; this.renderCompose(); }); }); }); } // Decrypting scramble: cycle random colours in the rival's next row, settle. scramble(rowIndex, finalGuess, done) { this.fxLayer.removeAll(true); const pegs = this.config.pegs; const y = this.rowY(rowIndex); const pegsObjs = []; for (let c = 0; c < pegs; c++) { const peg = this.makePeg(this.guessPegX(RIGHT_CX, c), y, Math.floor(Math.random() * this.config.colors), PEG_R, true); this.fxLayer.add(peg); pegsObjs.push(peg); } let ticks = 0; const maxTicks = 9; const ev = this.time.addEvent({ delay: 70, repeat: maxTicks, callback: () => { ticks++; pegsObjs.forEach((p, c) => { const ci = ticks >= maxTicks ? finalGuess[c] : Math.floor(Math.random() * this.config.colors); p.setFillStyle(CODE_COLORS[ci] ?? 0x888888); }); if (ticks >= maxTicks) { ev.remove(); this.fxLayer.removeAll(true); done(); } }, }); } // ── Effects ──────────────────────────────────────────────────────────────── glitch(duration = 150) { this.cameras.main.shake(duration, 0.004); if (this.scan) { this.scan.setAlpha(1); this.tweens.add({ targets: this.scan, alpha: 0.6, duration: 300 }); } } burst(x, y, color) { const e = this.add.particles(x, y, 'mmDot', { speed: { min: 120, max: 360 }, lifespan: 700, scale: { start: 1.1, end: 0 }, alpha: { start: 1, end: 0 }, quantity: 24, angle: { min: 0, max: 360 }, tint: [color, 0xffffff, C.cyan], blendMode: 'ADD', }).setDepth(DEPTH.fx); this.time.delayedCall(750, () => e.destroy()); } // ── End ──────────────────────────────────────────────────────────────────── endGame() { this.phase = 'over'; this.busy = true; this.gs.over = true; this.hideTurnArrow(); this.composeLayer.removeAll(true); this.renderPanels(); const win = this.gs.winner === 'player'; const draw = this.gs.winner === 'draw'; const msg = draw ? 'STALEMATE' : (win ? 'ACCESS GRANTED' : 'ACCESS DENIED'); const color = draw ? C.partial : (win ? C.exact : C.magenta); playSound(this, win ? SFX.MASTERMIND_GRANTED : SFX.MASTERMIND_DENIED); try { this.portrait?.playEmotion?.(win ? 'upset' : 'happy'); } catch (_) {} const overlay = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) .setDepth(DEPTH.overlay - 1); const colorHex = '#' + color.toString(16).padStart(6, '0'); const banner = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 30, msg, { fontFamily: 'Righteous', fontSize: '96px', color: colorHex, }).setOrigin(0.5).setDepth(DEPTH.banner); // Chromatic-aberration ghosts. const mk = (dx, hex) => this.add.text(GAME_WIDTH / 2 + dx, GAME_HEIGHT / 2 - 30, msg, { fontFamily: 'Righteous', fontSize: '96px', color: hex, }).setOrigin(0.5).setDepth(DEPTH.banner - 1).setAlpha(0.5).setBlendMode(Phaser.BlendModes.ADD); const g1 = mk(-5, '#ff2bd6'); const g2 = mk(5, '#00f0ff'); this.tweens.add({ targets: [g1, g2], x: '+=10', duration: 90, yoyo: true, repeat: 6 }); this.cameras.main.shake(400, 0.01); this.burst(GAME_WIDTH / 2, GAME_HEIGHT / 2 - 30, color); new Button(this, GAME_WIDTH / 2 - 170, GAME_HEIGHT / 2 + 110, 'Rematch', () => this.scene.restart(), { width: 280, height: 64, fontSize: 28 }).setDepth(DEPTH.banner); new Button(this, GAME_WIDTH / 2 + 170, GAME_HEIGHT / 2 + 110, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 280, height: 64, fontSize: 28 }).setDepth(DEPTH.banner); } }