feat(mastermind): add Exact Placement scoring mode
Introduces a per-peg feedback system where each marker corresponds to the specific slot's result (exact, partial, or none) instead of aggregate counts. Added `scoreGuessPerPeg` logic, updated UI rendering and sound triggers to handle per-peg data, and added a scene toggle for switching between Standard and Exact Placement modes.
This commit is contained in:
parent
2c5bcbd478
commit
0bb32359b2
|
|
@ -65,6 +65,7 @@ export default class MastermindGame extends Phaser.Scene {
|
|||
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.secretRevealType = data.secretRevealType ?? 'standard';
|
||||
this.phase = 'setup'; // 'setup' | 'duel' | 'over'
|
||||
this.busy = false;
|
||||
this.draft = [];
|
||||
|
|
@ -329,20 +330,36 @@ export default class MastermindGame extends Phaser.Scene {
|
|||
|
||||
// One feedback marker. Order is exact (green) first, then partial (yellow),
|
||||
// then dim blanks.
|
||||
// In "Exact Placement" mode, each marker corresponds to the per-peg result.
|
||||
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);
|
||||
// Per-peg mode (Exact Placement): each slot has its own result.
|
||||
if (entry.perPeg) {
|
||||
const result = entry.perPeg[i];
|
||||
if (result === 'exact') {
|
||||
mk = this.add.circle(fx, fy, FB_R, C.exact);
|
||||
mk.setStrokeStyle(2, 0xffffff, 0.8);
|
||||
} else if (result === '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);
|
||||
}
|
||||
} else {
|
||||
mk = this.add.circle(fx, fy, FB_R - 3, C.dim, 0.6);
|
||||
// Standard count-based mode.
|
||||
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;
|
||||
}
|
||||
|
|
@ -367,8 +384,13 @@ export default class MastermindGame extends Phaser.Scene {
|
|||
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);
|
||||
if (entry.perPeg) {
|
||||
if (entry.perPeg[i] === 'exact') playSound(this, SFX.MASTERMIND_MATCH);
|
||||
else if (entry.perPeg[i] === 'partial') playSound(this, SFX.MASTERMIND_COLOR);
|
||||
} else {
|
||||
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);
|
||||
};
|
||||
|
|
@ -548,7 +570,7 @@ export default class MastermindGame extends Phaser.Scene {
|
|||
|
||||
const rowIndex = this.gs.playerGuesses.length;
|
||||
this.flyPegs(LEFT_CX, guess, this.rowY(rowIndex), PEG_R, () => {
|
||||
applyGuess(this.gs, 'player', guess);
|
||||
applyGuess(this.gs, 'player', guess, this.secretRevealType);
|
||||
// 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], () => {
|
||||
|
|
@ -569,7 +591,7 @@ export default class MastermindGame extends Phaser.Scene {
|
|||
|
||||
this.time.delayedCall(nextThinkDelay(this.skill), () => {
|
||||
this.scramble(rowIndex, guess, () => {
|
||||
applyGuess(this.gs, 'ai', guess);
|
||||
applyGuess(this.gs, 'ai', guess, this.secretRevealType);
|
||||
this.renderPanels('right', { hideFeedbackRow: rowIndex });
|
||||
this.glitch(160);
|
||||
this.revealFeedback(this.rightLayer, RIGHT_CX, rowIndex, this.gs.aiGuesses[rowIndex], () => {
|
||||
|
|
|
|||
|
|
@ -33,6 +33,40 @@ export function randomCode(config) {
|
|||
return pool.slice(0, pegs);
|
||||
}
|
||||
|
||||
// Per-peg scoring for "Exact Placement" mode. Evaluates each slot in order:
|
||||
// 'exact' = right color & position, 'partial' = right color elsewhere,
|
||||
// 'none' = color not in code. Each code position can only be matched once.
|
||||
export function scoreGuessPerPeg(guess, code) {
|
||||
const n = guess.length;
|
||||
const result = new Array(n);
|
||||
const codeUsed = new Array(n).fill(false);
|
||||
|
||||
// First pass: exact matches consume the matching code position.
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (guess[i] === code[i]) {
|
||||
result[i] = 'exact';
|
||||
codeUsed[i] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: for non-exact slots, look for the colour in unused code positions.
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (result[i] === 'exact') continue;
|
||||
for (let j = 0; j < n; j++) {
|
||||
if (!codeUsed[j] && guess[i] === code[j]) {
|
||||
result[i] = 'partial';
|
||||
codeUsed[j] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (result[i] !== 'partial') {
|
||||
result[i] = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Standard count-based scoring. Exact matches first, then partials from the
|
||||
// leftover color tallies on each side.
|
||||
export function scoreGuess(guess, code) {
|
||||
|
|
@ -93,20 +127,28 @@ export function setPlayerCode(state, code) {
|
|||
return state;
|
||||
}
|
||||
|
||||
function record(state, side, guess) {
|
||||
function record(state, side, guess, secretRevealType) {
|
||||
const target = side === 'player' ? state.aiCode : state.playerCode;
|
||||
const fb = scoreGuess(guess, target);
|
||||
const entry = { guess: guess.slice(), exact: fb.exact, partial: fb.partial };
|
||||
let fb;
|
||||
if (secretRevealType === 'exact') {
|
||||
fb = { perPeg: scoreGuessPerPeg(guess, target) };
|
||||
} else {
|
||||
fb = scoreGuess(guess, target);
|
||||
}
|
||||
const entry = Object.assign({ guess: guess.slice() }, fb);
|
||||
(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);
|
||||
export function applyGuess(state, side, guess, secretRevealType) {
|
||||
const entry = record(state, side, guess, secretRevealType);
|
||||
const pegs = state.config.pegs;
|
||||
const cracked = entry.exact === pegs;
|
||||
const exactCount = entry.perPeg
|
||||
? entry.perPeg.filter((p) => p === 'exact').length
|
||||
: entry.exact;
|
||||
const cracked = exactCount === pegs;
|
||||
|
||||
if (cracked) {
|
||||
state.winner = side;
|
||||
|
|
@ -126,7 +168,12 @@ export function applyGuess(state, side, guess) {
|
|||
}
|
||||
|
||||
function bestExact(guesses) {
|
||||
return guesses.reduce((m, g) => Math.max(m, g.exact), 0);
|
||||
return guesses.reduce((m, g) => {
|
||||
if (g.perPeg) {
|
||||
return Math.max(m, g.perPeg.filter((p) => p === 'exact').length);
|
||||
}
|
||||
return Math.max(m, g.exact);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function resolveExhausted(state) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
this.expansion = data.expansion ?? 'base';
|
||||
this.scenario = data.scenario ?? null;
|
||||
this.deckMode = data.deckMode ?? 'standard';
|
||||
this.wordLength = data.wordLength ?? 4;
|
||||
this.wordLength = data.wordLength ?? 4;
|
||||
this.secretRevealType = data.secretRevealType ?? 'standard';
|
||||
}
|
||||
|
||||
create() {
|
||||
|
|
@ -31,7 +32,8 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
expansion: this.expansion,
|
||||
scenario: this.scenario,
|
||||
deckMode: this.deckMode,
|
||||
wordLength: this.wordLength,
|
||||
wordLength: this.wordLength,
|
||||
secretRevealType: this.secretRevealType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
|
||||
this.selectedDeckMode = 'standard';
|
||||
this.selectedWordLength = 4;
|
||||
this.selectedSecretRevealType = 'standard';
|
||||
this._initializing = false;
|
||||
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
|
||||
}
|
||||
|
|
@ -128,6 +129,8 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
if (this.gameDef.slug === 'wordladder') this.buildWordLengthSection(340, 1013);
|
||||
|
||||
if (this.gameDef.slug === 'mastermind') this.buildSecretRevealTypeSection(340, 1013);
|
||||
|
||||
if (!isWordGame) {
|
||||
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
|
||||
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
|
||||
|
|
@ -862,6 +865,52 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
});
|
||||
}
|
||||
|
||||
// ── Mastermind: secret reveal type toggle ─────────────────────────────────
|
||||
buildSecretRevealTypeSection(centerX, centerY) {
|
||||
const options = [
|
||||
{ id: 'standard', label: 'Standard Rules' },
|
||||
{ id: 'exact', label: 'Exact Placement' },
|
||||
];
|
||||
const pillW = 150, pillH = 40, pillGap = 12;
|
||||
const totalW = options.length * pillW + (options.length - 1) * pillGap;
|
||||
const labelY = centerY - 28;
|
||||
const pillY = centerY + 10;
|
||||
|
||||
const labelText = this.add.text(centerX, labelY, 'Secret Reveal Type', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '20px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
const labelBg = this.add.rectangle(centerX, labelY, labelText.width + 32, labelText.height + 14, 0x000000, 0.72);
|
||||
this.children.moveBelow(labelBg, labelText);
|
||||
|
||||
this._secretRevealTypeBtns = [];
|
||||
options.forEach((opt, i) => {
|
||||
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
|
||||
const isSelected = this.selectedSecretRevealType === opt.id;
|
||||
const bg = this.add.rectangle(x, pillY, pillW, pillH, COLORS.panel)
|
||||
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
|
||||
.setInteractive({ useHandCursor: true });
|
||||
const pillBg = this.add.rectangle(x, pillY, pillW, pillH, 0x000000, 0.72);
|
||||
this.children.moveBelow(labelBg, bg);
|
||||
this.add.text(x, pillY, opt.label, {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '16px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
const refresh = () => {
|
||||
this._secretRevealTypeBtns.forEach(({ bg: b, id }) =>
|
||||
b.setStrokeStyle(3, id === this.selectedSecretRevealType ? COLORS.accent : COLORS.muted)
|
||||
);
|
||||
};
|
||||
bg.on('pointerup', () => { this.selectedSecretRevealType = opt.id; refresh(); });
|
||||
bg.on('pointerover', () => { if (this.selectedSecretRevealType !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
|
||||
bg.on('pointerout', () => { if (this.selectedSecretRevealType !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
|
||||
this._secretRevealTypeBtns.push({ bg, id: opt.id });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Generic option section builder ─────────────────────────────────────────
|
||||
|
||||
buildOptionSection(label, labelY, items, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H, tileGap = TILE_GAP) {
|
||||
|
|
@ -986,8 +1035,9 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
expansion: this.selectedExpansion,
|
||||
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
|
||||
? this.selectedScenario : null,
|
||||
deckMode: this.selectedDeckMode,
|
||||
wordLength: this.selectedWordLength,
|
||||
deckMode: this.selectedDeckMode,
|
||||
wordLength: this.selectedWordLength,
|
||||
secretRevealType: this.selectedSecretRevealType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue