678 lines
26 KiB
JavaScript
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);
|
|
}
|
|
}
|