feat: add Mastermind decryption duel game with 5 AI skill levels
- Introduce core game logic, AI engine, and Phaser UI for a cyberpunk-themed Mastermind implementation. - AI features 5 difficulty tiers using random, consistent, and Knuth minimax strategies. - Implement turn-based duel mechanics where player and AI race to crack each other's secret codes. - Add neon visuals, peg-fly animations, staggered feedback reveals, and 7 custom sound effects. - Register game in server registry, update menu/room scenes, preload assets, and include tutorial docs.
This commit is contained in:
parent
b0d6817c54
commit
676a4d3106
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,109 @@
|
||||||
|
// Mastermind AI — the code-cracker. Same 1..5 skill model as ChessAI:
|
||||||
|
// • strategy — how the next guess is chosen
|
||||||
|
// • consistency — chance the guess actually respects the feedback so far
|
||||||
|
// (otherwise it "blunders" a fully random code, wasting a turn)
|
||||||
|
// • delay — "thinking" pause (ms range) before guessing, for pacing
|
||||||
|
//
|
||||||
|
// The AI tracks the *consistent set*: every code whose feedback against each of
|
||||||
|
// its past guesses matches what it actually got back. Higher skill uses that set
|
||||||
|
// ever more rigorously, culminating in Knuth's minimax at skill 5.
|
||||||
|
|
||||||
|
import { allCodes, scoreGuess } from './MastermindLogic.js';
|
||||||
|
|
||||||
|
const SKILL_PROFILES = {
|
||||||
|
1: { strategy: 'random', consistency: 0.15, delay: [900, 1500] }, // mostly ignores feedback
|
||||||
|
2: { strategy: 'consistent', consistency: 0.55, delay: [800, 1300] },
|
||||||
|
3: { strategy: 'consistent', consistency: 0.85, delay: [700, 1100] },
|
||||||
|
4: { strategy: 'consistent', consistency: 1.00, delay: [550, 950] }, // always a valid guess
|
||||||
|
5: { strategy: 'minimax', consistency: 1.00, delay: [450, 850] }, // Knuth-optimal
|
||||||
|
};
|
||||||
|
|
||||||
|
// Above this code-space size, full Knuth minimax (candidates × set) is too slow
|
||||||
|
// for a snappy turn, so we restrict the candidate pool to the consistent set.
|
||||||
|
const MINIMAX_FULL_LIMIT = 4000;
|
||||||
|
|
||||||
|
function profileFor(skill) {
|
||||||
|
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextThinkDelay(skill) {
|
||||||
|
const [lo, hi] = profileFor(skill).delay;
|
||||||
|
return lo + Math.random() * (hi - lo);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomFrom(list) {
|
||||||
|
return list[Math.floor(Math.random() * list.length)];
|
||||||
|
}
|
||||||
|
|
||||||
|
// A strong, palette-agnostic opener (two pairs) when there's room for it.
|
||||||
|
function opener(config) {
|
||||||
|
const code = [];
|
||||||
|
for (let i = 0; i < config.pegs; i++) {
|
||||||
|
code.push(Math.min(config.colors - 1, Math.floor(i / 2)));
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Codes still consistent with every past AI guess + its feedback.
|
||||||
|
function consistentSet(config, history) {
|
||||||
|
const all = allCodes(config);
|
||||||
|
if (history.length === 0) return all;
|
||||||
|
return all.filter((candidate) =>
|
||||||
|
history.every((h) => {
|
||||||
|
const fb = scoreGuess(h.guess, candidate);
|
||||||
|
return fb.exact === h.exact && fb.partial === h.partial;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Knuth minimax: pick the guess whose worst-case feedback bucket leaves the
|
||||||
|
// fewest candidates remaining. Tie-break toward guesses that are themselves
|
||||||
|
// still-possible solutions.
|
||||||
|
function minimaxGuess(config, set) {
|
||||||
|
if (set.length <= 2) return set[0];
|
||||||
|
const candidates = set.length > MINIMAX_FULL_LIMIT ? set : allCodes(config);
|
||||||
|
const setKeys = new Set(set.map((c) => c.join(',')));
|
||||||
|
|
||||||
|
let best = null;
|
||||||
|
let bestWorst = Infinity;
|
||||||
|
let bestIsSolution = false;
|
||||||
|
for (const guess of candidates) {
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const code of set) {
|
||||||
|
const fb = scoreGuess(guess, code);
|
||||||
|
const key = fb.exact * 100 + fb.partial;
|
||||||
|
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
let worst = 0;
|
||||||
|
for (const v of buckets.values()) if (v > worst) worst = v;
|
||||||
|
const isSolution = setKeys.has(guess.join(','));
|
||||||
|
if (worst < bestWorst || (worst === bestWorst && isSolution && !bestIsSolution)) {
|
||||||
|
bestWorst = worst;
|
||||||
|
best = guess;
|
||||||
|
bestIsSolution = isSolution;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best ?? set[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose the AI's next guess at the player's code.
|
||||||
|
// state.aiGuesses — the AI's own feedback history (vs the player's code)
|
||||||
|
export function chooseGuess(state, skill) {
|
||||||
|
const prof = profileFor(skill);
|
||||||
|
const cfg = state.config;
|
||||||
|
const history = state.aiGuesses;
|
||||||
|
|
||||||
|
// Opening move.
|
||||||
|
if (history.length === 0) {
|
||||||
|
return prof.strategy === 'minimax' ? opener(cfg) : randomFrom(allCodes(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-skill blunder: ignore feedback and fire a random code.
|
||||||
|
if (prof.strategy === 'random' || Math.random() > prof.consistency) {
|
||||||
|
return randomFrom(allCodes(cfg));
|
||||||
|
}
|
||||||
|
|
||||||
|
const set = consistentSet(cfg, history);
|
||||||
|
if (set.length === 0) return randomFrom(allCodes(cfg)); // shouldn't happen
|
||||||
|
if (prof.strategy === 'minimax') return minimaxGuess(cfg, set);
|
||||||
|
return randomFrom(set);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,677 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
// Mastermind — pure game logic (no Phaser). A "duel": both sides hold a secret
|
||||||
|
// code; each turn the player guesses the AI's code and the AI guesses the
|
||||||
|
// player's. First to an all-exact match wins; if both exhaust their guesses the
|
||||||
|
// winner is whoever cracked it (or got more exact pegs / fewer guesses).
|
||||||
|
//
|
||||||
|
// A code is an array of `pegs` color indices in [0, colors). Feedback is
|
||||||
|
// { exact, partial } — exact = right color & position, partial = right color in
|
||||||
|
// the wrong position (classic count-based scoring, duplicates handled).
|
||||||
|
|
||||||
|
export const DEFAULT_CONFIG = { pegs: 4, colors: 6, duplicates: true, maxGuesses: 10 };
|
||||||
|
|
||||||
|
export function makeConfig(overrides = {}) {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...overrides };
|
||||||
|
cfg.pegs = Math.max(2, Math.min(6, cfg.pegs | 0));
|
||||||
|
cfg.colors = Math.max(4, Math.min(8, cfg.colors | 0));
|
||||||
|
cfg.maxGuesses = Math.max(4, Math.min(16, cfg.maxGuesses | 0));
|
||||||
|
// Without duplicates we can't make a code longer than the palette.
|
||||||
|
if (!cfg.duplicates && cfg.pegs > cfg.colors) cfg.pegs = cfg.colors;
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomCode(config) {
|
||||||
|
const { pegs, colors, duplicates } = config;
|
||||||
|
if (duplicates) {
|
||||||
|
return Array.from({ length: pegs }, () => Math.floor(Math.random() * colors));
|
||||||
|
}
|
||||||
|
// Sample without replacement.
|
||||||
|
const pool = Array.from({ length: colors }, (_, i) => i);
|
||||||
|
for (let i = pool.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||||||
|
}
|
||||||
|
return pool.slice(0, pegs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard count-based scoring. Exact matches first, then partials from the
|
||||||
|
// leftover color tallies on each side.
|
||||||
|
export function scoreGuess(guess, code) {
|
||||||
|
const n = code.length;
|
||||||
|
let exact = 0;
|
||||||
|
const codeLeft = {};
|
||||||
|
const guessLeft = {};
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
if (guess[i] === code[i]) {
|
||||||
|
exact++;
|
||||||
|
} else {
|
||||||
|
codeLeft[code[i]] = (codeLeft[code[i]] ?? 0) + 1;
|
||||||
|
guessLeft[guess[i]] = (guessLeft[guess[i]] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let partial = 0;
|
||||||
|
for (const color in guessLeft) {
|
||||||
|
if (codeLeft[color]) partial += Math.min(guessLeft[color], codeLeft[color]);
|
||||||
|
}
|
||||||
|
return { exact, partial };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enumerate the full code space. 6^4 = 1296; 8^5 = 32768 — both fine in JS.
|
||||||
|
export function allCodes(config) {
|
||||||
|
const { pegs, colors, duplicates } = config;
|
||||||
|
const out = [];
|
||||||
|
const cur = new Array(pegs);
|
||||||
|
const used = new Array(colors).fill(false);
|
||||||
|
(function rec(pos) {
|
||||||
|
if (pos === pegs) { out.push(cur.slice()); return; }
|
||||||
|
for (let c = 0; c < colors; c++) {
|
||||||
|
if (!duplicates && used[c]) continue;
|
||||||
|
cur[pos] = c;
|
||||||
|
if (!duplicates) used[c] = true;
|
||||||
|
rec(pos + 1);
|
||||||
|
if (!duplicates) used[c] = false;
|
||||||
|
}
|
||||||
|
})(0);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createInitialState(config = DEFAULT_CONFIG) {
|
||||||
|
const cfg = makeConfig(config);
|
||||||
|
return {
|
||||||
|
config: cfg,
|
||||||
|
playerCode: null, // set during setup by the human
|
||||||
|
aiCode: randomCode(cfg), // the code the human tries to crack
|
||||||
|
playerGuesses: [], // [{ guess, exact, partial }] — vs aiCode
|
||||||
|
aiGuesses: [], // [{ guess, exact, partial }] — vs playerCode
|
||||||
|
turn: 'player', // 'player' | 'ai'
|
||||||
|
winner: null, // 'player' | 'ai' | 'draw' | null
|
||||||
|
over: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPlayerCode(state, code) {
|
||||||
|
state.playerCode = code.slice();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function record(state, side, guess) {
|
||||||
|
const target = side === 'player' ? state.aiCode : state.playerCode;
|
||||||
|
const fb = scoreGuess(guess, target);
|
||||||
|
const entry = { guess: guess.slice(), exact: fb.exact, partial: fb.partial };
|
||||||
|
(side === 'player' ? state.playerGuesses : state.aiGuesses).push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply a guess for `side` ('player'|'ai'), update history, and resolve the
|
||||||
|
// game-over / winner state. Returns the feedback entry that was recorded.
|
||||||
|
export function applyGuess(state, side, guess) {
|
||||||
|
const entry = record(state, side, guess);
|
||||||
|
const pegs = state.config.pegs;
|
||||||
|
const cracked = entry.exact === pegs;
|
||||||
|
|
||||||
|
if (cracked) {
|
||||||
|
state.winner = side;
|
||||||
|
state.over = true;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both sides have spent all their guesses → resolve by who did better.
|
||||||
|
const used = Math.max(state.playerGuesses.length, state.aiGuesses.length);
|
||||||
|
if (used >= state.config.maxGuesses
|
||||||
|
&& state.playerGuesses.length >= state.config.maxGuesses
|
||||||
|
&& state.aiGuesses.length >= state.config.maxGuesses) {
|
||||||
|
state.over = true;
|
||||||
|
state.winner = resolveExhausted(state);
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bestExact(guesses) {
|
||||||
|
return guesses.reduce((m, g) => Math.max(m, g.exact), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExhausted(state) {
|
||||||
|
const p = bestExact(state.playerGuesses);
|
||||||
|
const a = bestExact(state.aiGuesses);
|
||||||
|
if (p > a) return 'player';
|
||||||
|
if (a > p) return 'ai';
|
||||||
|
return 'draw';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGameOver(state) { return state.over; }
|
||||||
|
export function getWinner(state) { return state.winner; }
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
## Mastermind — Decryption Duel
|
||||||
|
|
||||||
|
Two hackers, two secret codes, one race. You and the rival AI each lock in a
|
||||||
|
**secret code**. On every round you try to crack the rival's code while the rival
|
||||||
|
tries to crack yours. **First to fully crack the other's code wins.**
|
||||||
|
|
||||||
|
### The code
|
||||||
|
|
||||||
|
A code is a row of colored pegs — by default **4 pegs drawn from 6 neon colors**,
|
||||||
|
and **colors may repeat**. You compose your secret at the start by clicking color
|
||||||
|
chips into your slots, then press **LOCK IN**.
|
||||||
|
|
||||||
|
### Guessing
|
||||||
|
|
||||||
|
On your turn, click chips to build a guess at the rival's code and press
|
||||||
|
**SUBMIT**. You get feedback pegs back:
|
||||||
|
|
||||||
|
| Feedback peg | Meaning |
|
||||||
|
| --- | --- |
|
||||||
|
| **Green (exact)** | Right color **and** right position |
|
||||||
|
| **Yellow (partial)** | Right color, **wrong** position |
|
||||||
|
| **Dim** | That color isn't (further) in the code |
|
||||||
|
|
||||||
|
Feedback never tells you *which* peg is right — only *how many*. Use the pattern
|
||||||
|
across rounds to narrow it down.
|
||||||
|
|
||||||
|
### Winning
|
||||||
|
|
||||||
|
- Get **all green** (every peg exact) to crack the code and win immediately.
|
||||||
|
- If both sides use up all **10 rounds** without a full crack, whoever got the
|
||||||
|
most exact pegs in a single round wins; a true tie is a stalemate.
|
||||||
|
|
||||||
|
### The rival's skill (1–5)
|
||||||
|
|
||||||
|
Set in the lobby before the match. Skill controls how cleverly the rival uses its
|
||||||
|
feedback:
|
||||||
|
|
||||||
|
- **1** — sloppy; fires mostly random guesses and wastes rounds.
|
||||||
|
- **2–3** — keeps its guesses *consistent* with the clues most of the time.
|
||||||
|
- **4** — always plays a logically valid guess.
|
||||||
|
- **5** — runs Knuth's optimal strategy and will usually crack a 4×6 code in **five
|
||||||
|
rounds or fewer**. Lock in a tricky code with repeats!
|
||||||
|
|
@ -42,6 +42,7 @@ import SudokuGame from './games/sudoku/SudokuGame.js';
|
||||||
import OthelloGame from './games/othello/OthelloGame.js';
|
import OthelloGame from './games/othello/OthelloGame.js';
|
||||||
import GoGame from './games/go/GoGame.js';
|
import GoGame from './games/go/GoGame.js';
|
||||||
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
import BattleshipGame from './games/battleship/BattleshipGame.js';
|
||||||
|
import MastermindGame from './games/mastermind/MastermindGame.js';
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
type: Phaser.AUTO,
|
type: Phaser.AUTO,
|
||||||
|
|
@ -97,6 +98,7 @@ const config = {
|
||||||
OthelloGame,
|
OthelloGame,
|
||||||
GoGame,
|
GoGame,
|
||||||
BattleshipGame,
|
BattleshipGame,
|
||||||
|
MastermindGame,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -156,7 +156,10 @@ export default class GameMenuScene extends Phaser.Scene {
|
||||||
const panelH = (rows - 1) * ROW_SPACING + PADDING * 2;
|
const panelH = (rows - 1) * ROW_SPACING + PADDING * 2;
|
||||||
const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + QBTN_SIZE + QBTN_GAP + 40;
|
const panelW = (COLS - 1) * COL_SPACING + BTN_WIDTH + QBTN_SIZE + QBTN_GAP + 40;
|
||||||
const panelCenterY = GRID_TOP + (rows - 1) * ROW_SPACING / 2;
|
const panelCenterY = GRID_TOP + (rows - 1) * ROW_SPACING / 2;
|
||||||
const panel = this.add.rectangle(cx, panelCenterY, panelW, panelH, 0x000000, 0.7);
|
const panel = this.add.graphics();
|
||||||
|
panel.fillStyle(0x000000, 0.7);
|
||||||
|
panel.fillRoundedRect(-panelW / 2, -panelH / 2, panelW, panelH, 16);
|
||||||
|
panel.setPosition(cx, panelCenterY);
|
||||||
this._gameObjects.push(panel);
|
this._gameObjects.push(panel);
|
||||||
|
|
||||||
// Shared tooltip (one per category render)
|
// Shared tooltip (one per category render)
|
||||||
|
|
@ -189,7 +192,7 @@ export default class GameMenuScene extends Phaser.Scene {
|
||||||
games.forEach((game, i) => {
|
games.forEach((game, i) => {
|
||||||
const col = i % COLS;
|
const col = i % COLS;
|
||||||
const row = Math.floor(i / COLS);
|
const row = Math.floor(i / COLS);
|
||||||
const x = cx + (col - (COLS - 1) / 2) * COL_SPACING;
|
const x = cx - 20 + (col - (COLS - 1) / 2) * COL_SPACING;
|
||||||
const y = GRID_TOP + row * ROW_SPACING;
|
const y = GRID_TOP + row * ROW_SPACING;
|
||||||
const btn = new Button(this, x, y, game.name, () => this.openGame(game), { width: BTN_WIDTH });
|
const btn = new Button(this, x, y, game.name, () => this.openGame(game), { width: BTN_WIDTH });
|
||||||
this._gameObjects.push(btn);
|
this._gameObjects.push(btn);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
create() {
|
create() {
|
||||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame' };
|
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame' };
|
||||||
if (slugDispatch[this.game.slug]) {
|
if (slugDispatch[this.game.slug]) {
|
||||||
this.scene.start(slugDispatch[this.game.slug], {
|
this.scene.start(slugDispatch[this.game.slug], {
|
||||||
game: this.game,
|
game: this.game,
|
||||||
|
|
|
||||||
|
|
@ -373,7 +373,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
||||||
|
|
||||||
// Skill control: pips always show the level; the +/- buttons appear only
|
// Skill control: pips always show the level; the +/- buttons appear only
|
||||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go'].includes(this.gameDef.slug)) {
|
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind'].includes(this.gameDef.slug)) {
|
||||||
bio.style.webkitLineClamp = '1';
|
bio.style.webkitLineClamp = '1';
|
||||||
|
|
||||||
const skillRow = document.createElement('div');
|
const skillRow = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ export default class PreloadScene extends Phaser.Scene {
|
||||||
this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3');
|
this.load.audio('sfx-bingo-balls', '/assets/fx/bingo-balls.mp3');
|
||||||
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
|
this.load.audio('sfx-pencil-write', '/assets/fx/pencil-write.mp3');
|
||||||
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
|
this.load.audio('sfx-piece-click', '/assets/fx/piece-click.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-glitch-1', '/assets/fx/mastermind-glitch-01.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-glitch-2', '/assets/fx/mastermind-glitch-02.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-place', '/assets/fx/mastermind-place.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-access-granted', '/assets/fx/mastermind-access-granted.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-access-denied', '/assets/fx/mastermind-access-denied.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-color', '/assets/fx/mastermind-color.mp3');
|
||||||
|
this.load.audio('sfx-mastermind-match', '/assets/fx/mastermind-match.mp3');
|
||||||
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
|
this.load.audio('sfx-roulette', '/assets/fx/roulette.mp3');
|
||||||
this.load.audio('sfx-battleship-hit', '/assets/fx/battleship-hit.mp3');
|
this.load.audio('sfx-battleship-hit', '/assets/fx/battleship-hit.mp3');
|
||||||
this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.mp3');
|
this.load.audio('sfx-battleship-miss', '/assets/fx/battleship-miss.mp3');
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,13 @@ export const SFX = {
|
||||||
BATTLESHIP_HIT: 'sfx-battleship-hit',
|
BATTLESHIP_HIT: 'sfx-battleship-hit',
|
||||||
BATTLESHIP_MISS: 'sfx-battleship-miss',
|
BATTLESHIP_MISS: 'sfx-battleship-miss',
|
||||||
BATTLESHIP_LAUNCH: 'sfx-battleship-launch',
|
BATTLESHIP_LAUNCH: 'sfx-battleship-launch',
|
||||||
|
MASTERMIND_GLITCH_1: 'sfx-mastermind-glitch-1',
|
||||||
|
MASTERMIND_GLITCH_2: 'sfx-mastermind-glitch-2',
|
||||||
|
MASTERMIND_PLACE: 'sfx-mastermind-place',
|
||||||
|
MASTERMIND_GRANTED: 'sfx-mastermind-access-granted',
|
||||||
|
MASTERMIND_DENIED: 'sfx-mastermind-access-denied',
|
||||||
|
MASTERMIND_COLOR: 'sfx-mastermind-color',
|
||||||
|
MASTERMIND_MATCH: 'sfx-mastermind-match',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function playSound(scene, key) {
|
export function playSound(scene, key) {
|
||||||
|
|
|
||||||
|
|
@ -56,3 +56,4 @@ registerGame({ slug: 'sudoku', name: 'Sudoku', category: 'word', minPlayer
|
||||||
registerGame({ slug: 'othello', name: 'Othello', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
registerGame({ slug: 'othello', name: 'Othello', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||||
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
registerGame({ slug: 'battleship', name: 'Battleship', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
|
registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: true });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue