fertig-classic-games/public/src/games/mastermind/MastermindGame.js

678 lines
26 KiB
JavaScript

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