fertig-classic-games/public/src/scenes/OpponentSelectScene.js

1101 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as Phaser from 'phaser';
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
import { Button } from '../ui/Button.js';
import { playMenuMusic, stopMenuMusic, setMenuMusicVolume } from '../ui/MenuMusic.js';
import { enqueue as enqueueSpeech, resetQueue as resetSpeechQueue } from '../ui/SpeechQueue.js';
// Option tile dimensions — playfield rows
const TILE_W = 190;
const TILE_H = 90;
const TILE_GAP = 16;
// Card-back tile dimensions — sized to preserve 320:420 card aspect ratio
// thumbW = CARD_TILE_W - 16 = 84; thumbH = 84 * (420/320) ≈ 110; CARD_TILE_H = 110 + 30 = 140
const CARD_TILE_W = 100;
const CARD_TILE_H = 141;
const CARD_TILE_GAP = 14;
// Opponent grid scroll area
const OPP_SCROLL_W = 1780;
const OPP_SCROLL_H = 440;
const OPP_SCROLL_TOP = 155; // top edge of scroll area
export default class OpponentSelectScene extends Phaser.Scene {
constructor() { super('OpponentSelect'); }
init(data) {
this.gameDef = data.game;
this.selected = new Set();
this.cards = []; // [{ opp, el }]
this.selectedPlayfield = null;
this.playfieldTiles = [];
this.selectedCardBack = null;
this.cardBackTiles = [];
this.selectedTilePlacement = 'standard';
this.selectedMatchVariant = 4;
this.selectedExpansion = 'base';
this.selectedScenario = 'new-shores'; // Catan Seafarers scenario
this.selectedDeckMode = 'standard';
this.selectedWordLength = 4;
this.selectedSecretRevealType = 'standard';
this.selectedDifficulty = 'normal'; // Forbidden Island water-level start
this._initializing = false;
this.skillByOpp = {}; // opp.id → AI skill level 1..5 (Nerts only)
}
async create() {
playMenuMusic();
setMenuMusicVolume(0.25);
const cx = GAME_WIDTH / 2;
const isCatan = this.gameDef.slug === 'catan';
const isGoFish = this.gameDef.slug === 'gofish';
const isWordGame = this.gameDef.category === 'word';
const bgKey = this.gameDef.category === 'casino' ? 'bg-casino' : 'bg-room';
this.add.image(cx, GAME_HEIGHT / 2, bgKey).setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(-1);
this.add.rectangle(cx, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.45).setDepth(-1);
const titleText = this.add.text(cx, 60, this.gameDef.name, {
fontFamily: 'Righteous',
fontSize: '52px',
color: COLORS.textHex,
}).setOrigin(0.5);
const titlePill = this.add.rectangle(cx, 60, titleText.width + 48, titleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(titlePill, titleText);
const subtitleText = this.add.text(cx, 122, 'Choose your opponent', {
fontFamily: 'Righteous',
fontSize: '36px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const subtitlePill = this.add.rectangle(cx, 122, subtitleText.width + 48, subtitleText.height + 20, 0x000000, 0.72);
this.children.moveBelow(subtitlePill, subtitleText);
let opponents = [];
try {
const res = await fetch('/data/opponents.json');
const json = await res.json();
opponents = json.opponents ?? [];
} catch {
this.add.text(cx, GAME_HEIGHT / 2, 'Failed to load opponents.', {
fontSize: '28px', color: COLORS.dangerHex,
}).setOrigin(0.5);
return;
}
const min = this.gameDef.minOpponents ?? 1;
new Button(this, cx - 150, 1013, 'Back', () => this.scene.start('GameMenu'), {
variant: 'ghost',
width: 280,
});
this.startBtn = new Button(this, cx + 150, 1013, 'Start Game', () => this.startGame(), {
width: 280,
bg: COLORS.accent,
bgHover: COLORS.gold,
textColor: COLORS.textDarkHex,
textHoverColor: COLORS.textDarkHex,
});
this.startBtn.setEnabled(min === 0);
for (let i = opponents.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[opponents[i], opponents[j]] = [opponents[j], opponents[i]];
}
const hasOptions = isCatan || isGoFish || this.gameDef.slug === 'dominion'
|| (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind' && (this.cache.json.get('playfields')?.playfields?.length > 0))
|| this.gameDef.cardGame;
const oppScrollH = hasOptions ? OPP_SCROLL_H : 700;
this.buildOpponentGrid(opponents, oppScrollH);
const max = this.gameDef.maxOpponents ?? 1;
const defaultCount = this.gameDef.slug === 'nerts' ? 1
: this.gameDef.slug === 'tickettoride' ? Math.min(3, max)
: max;
this._initializing = true;
this.cards.slice(0, defaultCount).forEach(({ opp, el }) => this.toggleOpponent(opp, el));
this._initializing = false;
this.events.once('shutdown', () => {
resetSpeechQueue();
this.cards.forEach(c => c.destroyViz?.());
if (!this._startingGame) setMenuMusicVolume(0.6);
});
if (isCatan) this.buildCatanExpansionSection(340, 1013);
if (isGoFish) this.buildMatchVariantSection(340, 1013);
if (this.gameDef.slug === 'dominion') this.buildDeckModeSection(340, 1013);
if (this.gameDef.slug === 'wordladder') this.buildWordLengthSection(340, 1013);
if (this.gameDef.slug === 'mastermind') this.buildSecretRevealTypeSection(340, 1013);
if (this.gameDef.slug === 'forbiddenisland') this.buildDifficultySection(340, 1013);
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
}
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
this.buildOptionSection('Card Back', 798, this.cache.json.get('card-backs')?.cardBacks ?? [],
'selectedCardBack', 'cardBackTiles', (cb) => this.selectCardBack(cb),
CARD_TILE_W, CARD_TILE_H, CARD_TILE_GAP);
}
// Apply playfield default; card back is chosen randomly
if (!isWordGame && this.gameDef.slug !== 'battleship' && this.gameDef.slug !== 'mastermind') {
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
}
if (this.gameDef.cardGame && this.gameDef.slug !== 'splendor') {
const cardBacks = this.cache.json.get('card-backs')?.cardBacks ?? [];
if (cardBacks.length > 0) {
const randomCb = cardBacks[Math.floor(Math.random() * cardBacks.length)];
this.selectedCardBack = randomCb;
this.highlightTile('cardBackTiles', randomCb.id);
}
}
}
// ── Opponent grid (DOM-based, 2-column scrollable list) ────────────────────
buildOpponentGrid(opponents, scrollH = OPP_SCROLL_H) {
const cx = GAME_WIDTH / 2;
const CARD_H = 118;
const GAP = 16;
// Outer scroll container — just height + overflow, no layout opinions
const wrapper = document.createElement('div');
wrapper.style.cssText = [
`width:${OPP_SCROLL_W}px`,
`height:${scrollH}px`,
'overflow-y:auto',
'overflow-x:hidden',
'display:flex',
'flex-direction:column',
`gap:${GAP}px`,
'padding:2px',
'box-sizing:border-box',
'scrollbar-width:thin',
`scrollbar-color:${COLORS.accentHex} ${COLORS.panelHex}`,
].join(';');
// Group opponents into pairs and build one row per pair
for (let i = 0; i < opponents.length; i += 2) {
const row = document.createElement('div');
row.style.cssText = `display:flex;gap:${GAP}px;flex-shrink:0;`;
const left = this.buildOpponentCardEl(opponents[i], CARD_H);
row.appendChild(left);
if (opponents[i + 1]) {
const right = this.buildOpponentCardEl(opponents[i + 1], CARD_H);
row.appendChild(right);
} else {
// Odd opponent out — add an invisible spacer so the card stays half-width
const spacer = document.createElement('div');
spacer.style.cssText = 'flex:1;';
row.appendChild(spacer);
}
wrapper.appendChild(row);
}
this.add.dom(cx, OPP_SCROLL_TOP + scrollH / 2, wrapper);
}
buildOpponentCardEl(opp, cardH) {
// Default each opponent to a random skill. Forbidden Island is co-op, so its
// "opponents" are partners — default them strong (4 or 5); every other game
// keeps the standard 2-or-3 default.
if (this.skillByOpp[opp.id] === undefined) {
const strong = this.gameDef.slug === 'forbiddenisland';
this.skillByOpp[opp.id] = strong
? (Math.random() < 0.5 ? 4 : 5)
: (Math.random() < 0.5 ? 2 : 3);
}
const el = document.createElement('div');
el.style.cssText = [
'flex:1', // fill exactly half of the row
`height:${cardH}px`,
`background:${COLORS.panelHex}`,
`border:2px solid ${COLORS.mutedHex}`,
'border-radius:8px',
'display:flex',
'align-items:center',
'gap:16px',
'padding:12px 16px',
'cursor:pointer',
'box-sizing:border-box',
'user-select:none',
'transition:border 0.12s,background 0.15s,box-shadow 0.18s,color 0.12s',
].join(';');
// Portrait wrapper — fixed size so canvas/video can be stacked inside
const portraitSize = cardH - 24;
const portraitWrap = document.createElement('div');
portraitWrap.style.cssText = `position:relative;width:${portraitSize}px;height:${portraitSize}px;flex-shrink:0;`;
// Static canvas (sprite frame with circular clip)
const canvas = document.createElement('canvas');
canvas.width = portraitSize;
canvas.height = portraitSize;
canvas.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;background:${COLORS.panelHex};`;
const ctx = canvas.getContext('2d');
const r = portraitSize / 2;
ctx.save();
ctx.beginPath();
ctx.arc(r, r, r, 0, Math.PI * 2);
ctx.clip();
if (this.textures.exists('opponents')) {
const frame = this.textures.getFrame('opponents', opp.spriteIndex ?? 0);
ctx.drawImage(
frame.source.image,
frame.cutX, frame.cutY, frame.cutWidth, frame.cutHeight,
0, 0, portraitSize, portraitSize,
);
} else {
ctx.fillStyle = COLORS.panelHex;
ctx.fillRect(0, 0, portraitSize, portraitSize);
ctx.fillStyle = COLORS.accentHex;
ctx.font = `bold ${Math.round(r * 0.8)}px Righteous,sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText((opp.name ?? '?').charAt(0).toUpperCase(), r, r);
}
ctx.restore();
// Idle video (shown when selected, hidden otherwise)
const video = document.createElement('video');
video.src = `/assets/videos/${opp.id}-idle.mp4`;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;object-fit:cover;display:none;`;
// If the video file doesn't exist, restore the canvas
video.addEventListener('error', () => {
video.style.display = 'none';
canvas.style.display = 'block';
});
// Spectrum visualizer canvas — overlays portrait when pick audio plays
const vizCanvas = document.createElement('canvas');
vizCanvas.width = portraitSize;
vizCanvas.height = portraitSize;
vizCanvas.style.cssText = `position:absolute;top:0;left:0;width:${portraitSize}px;height:${portraitSize}px;border-radius:50%;pointer-events:none;opacity:0;transition:opacity 0.2s ease;`;
const vizCtx = vizCanvas.getContext('2d');
const vsz = portraitSize;
const BAR_COUNT = 6;
const bars = Array.from({ length: BAR_COUNT }, () => ({ cur: 0.1, target: 0.5 }));
let vizActive = false;
let rafId = null;
let retargetTimer = null;
function _vizFrame() {
if (!vizActive) { rafId = null; vizCtx.clearRect(0, 0, vsz, vsz); return; }
vizCtx.clearRect(0, 0, vsz, vsz);
const gy = vsz * 0.58;
const grad = vizCtx.createLinearGradient(0, gy, 0, vsz);
grad.addColorStop(0, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,0.38)');
vizCtx.fillStyle = grad;
vizCtx.fillRect(0, gy, vsz, vsz - gy);
const barW = vsz * 0.072;
const gap = vsz * 0.03;
const totalW = BAR_COUNT * barW + (BAR_COUNT - 1) * gap;
const startX = (vsz - totalW) / 2;
const maxH = vsz * 0.36;
const baseY = vsz * 0.88;
bars.forEach((bar, i) => {
bar.cur += (bar.target - bar.cur) * 0.14;
const h = Math.max(bar.cur * maxH, vsz * 0.04);
const x = startX + i * (barW + gap);
vizCtx.shadowColor = 'rgba(0,200,255,0.7)';
vizCtx.shadowBlur = 5;
vizCtx.fillStyle = 'rgba(0,200,255,0.88)';
vizCtx.beginPath();
vizCtx.roundRect(x, baseY - h, barW, h, 2);
vizCtx.fill();
});
vizCtx.shadowBlur = 0;
rafId = requestAnimationFrame(_vizFrame);
}
function startViz() {
vizActive = true;
vizCanvas.style.opacity = '1';
if (!retargetTimer) retargetTimer = setInterval(() => { bars.forEach(b => { b.target = 0.2 + Math.random() * 0.8; }); }, 120);
if (!rafId) rafId = requestAnimationFrame(_vizFrame);
}
function stopViz() {
vizActive = false;
vizCanvas.style.opacity = '0';
clearInterval(retargetTimer);
retargetTimer = null;
}
function destroyViz() {
stopViz();
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
}
portraitWrap.appendChild(canvas);
portraitWrap.appendChild(video);
portraitWrap.appendChild(vizCanvas);
// Text block
const info = document.createElement('div');
info.style.cssText = 'display:flex;flex-direction:column;gap:6px;min-width:0;flex:1;';
const name = document.createElement('div');
name.textContent = opp.name ?? '';
name.style.cssText = [
'font-family:"Julius Sans One"',
'font-size:22px',
'font-weight:600',
`color:${COLORS.textHex}`,
'white-space:nowrap',
'overflow:hidden',
'text-overflow:ellipsis',
].join(';');
const bio = document.createElement('div');
bio.textContent = opp.bio ?? '';
bio.style.cssText = [
'font-family:"Julius Sans One"',
'font-size:15px',
`color:${COLORS.mutedHex}`,
'line-height:1.4',
'overflow:hidden',
'display:-webkit-box',
'-webkit-line-clamp:2',
'-webkit-box-orient:vertical',
].join(';');
info.appendChild(name);
info.appendChild(bio);
// 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.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');
skillRow.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:2px;';
const skillLabel = document.createElement('span');
skillLabel.textContent = 'Skill';
skillLabel.style.cssText = `font-family:"Julius Sans One";font-size:14px;color:${COLORS.mutedHex};`;
const btnCss = `width:26px;height:26px;border-radius:6px;border:1px solid ${COLORS.accentHex};`
+ `background:${COLORS.panelHex};color:${COLORS.accentHex};font-size:18px;line-height:1;`
+ `cursor:pointer;display:none;padding:0;`;
const minus = document.createElement('button');
minus.textContent = '−';
minus.style.cssText = btnCss;
const plus = document.createElement('button');
plus.textContent = '+';
plus.style.cssText = btnCss;
const pips = document.createElement('div');
pips.style.cssText = 'display:flex;gap:5px;align-items:center;';
const pipEls = [];
for (let i = 0; i < 5; i++) {
const p = document.createElement('span');
p.style.cssText = 'width:12px;height:12px;border-radius:50%;display:inline-block;';
pips.appendChild(p);
pipEls.push(p);
}
const renderPips = () => {
const lvl = this.skillByOpp[opp.id];
pipEls.forEach((p, i) => {
p.style.background = i < lvl ? COLORS.accentHex : 'transparent';
p.style.border = `1px solid ${i < lvl ? COLORS.accentHex : COLORS.mutedHex}`;
});
};
renderPips();
const change = (delta) => {
const cur = this.skillByOpp[opp.id] ?? 3;
this.skillByOpp[opp.id] = Math.max(1, Math.min(5, cur + delta));
renderPips();
};
minus.addEventListener('click', (e) => { e.stopPropagation(); change(-1); });
plus.addEventListener('click', (e) => { e.stopPropagation(); change(1); });
skillRow.appendChild(skillLabel);
skillRow.appendChild(minus);
skillRow.appendChild(pips);
skillRow.appendChild(plus);
info.appendChild(skillRow);
el._skillBtns = [minus, plus];
el._setSkillEditable = (on) => {
for (const b of el._skillBtns) b.style.display = on ? 'inline-block' : 'none';
};
}
el.appendChild(portraitWrap);
el.appendChild(info);
el._nameEl = name;
el.addEventListener('click', () => this.toggleOpponent(opp, el));
this.cards.push({ opp, el, canvas, video, startViz, stopViz, destroyViz });
return el;
}
toggleOpponent(opp, el) {
const min = this.gameDef.minOpponents ?? 1;
const max = this.gameDef.maxOpponents ?? 1;
if (this.selected.has(opp.id)) {
this.selected.delete(opp.id);
this.applyOpponentStyle(el, false);
el._setSkillEditable?.(false);
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.stopViz();
card.video.pause();
card.video.style.display = 'none';
card.canvas.style.display = 'block';
}
resetSpeechQueue();
} else {
// If already at max, bump the oldest selection out first
if (this.selected.size >= max) {
const oldestId = this.selected.values().next().value;
this.selected.delete(oldestId);
const oldCard = this.cards.find((c) => c.opp.id === oldestId);
if (oldCard) {
oldCard.stopViz();
this.applyOpponentStyle(oldCard.el, false);
oldCard.el._setSkillEditable?.(false);
oldCard.video.pause();
oldCard.video.style.display = 'none';
oldCard.canvas.style.display = 'block';
}
}
this.selected.add(opp.id);
this.applyOpponentStyle(el, true);
el._setSkillEditable?.(true);
const card = this.cards.find((c) => c.opp.id === opp.id);
if (card) {
card.canvas.style.display = 'none';
card.video.style.display = 'block';
card.video.play().catch(() => {
card.video.style.display = 'none';
card.canvas.style.display = 'block';
});
}
if (!this._initializing) {
const pickClips = opp.speech?.pick;
if (pickClips?.length) {
resetSpeechQueue();
enqueueSpeech(pickClips[Math.floor(Math.random() * pickClips.length)], {
onStart: () => this.cards.find(c => c.opp.id === opp.id)?.startViz(),
onEnd: () => this.cards.find(c => c.opp.id === opp.id)?.stopViz(),
});
}
}
}
this.startBtn.setEnabled(this.selected.size >= min);
}
// ── Catan: Expansion + (Scenario | Tile placement) selection ───────────────
static CATAN_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'seafarers', label: 'Seafarers' },
];
// Scenario ids must match the keys in games/catan/expansions/seafarers.js.
static CATAN_SCENARIOS = [
{ id: 'new-shores', label: 'New Shores' },
{ id: 'four-islands', label: 'Four Islands' },
{ id: 'oceania', label: 'Oceania' },
{ id: 'fog-island', label: 'Fog Island' },
];
buildCatanExpansionSection(centerX, centerY) {
const C = OpponentSelectScene;
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._catanSubLabelY = centerY - 14;
this._catanSubRow0Y = centerY + 12;
this._catanSubRow1Y = centerY + 46;
this._catanCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker (Base / Seafarers).
mkLabel(expLabelY, 'Expansion');
const pillW = 150, pillH = 36, gap = 14;
const rowW = C.CATAN_EXPANSIONS.length * pillW + (C.CATAN_EXPANSIONS.length - 1) * gap;
this._catanExpBtns = [];
C.CATAN_EXPANSIONS.forEach((opt, i) => {
const x = centerX - rowW / 2 + i * (pillW + gap) + pillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
this._catanExpBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderCatanSubsection();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanExpBtns.push({ bg, id: opt.id });
});
this._catanSubObjs = [];
this.renderCatanSubsection();
}
// Base → Tile Placement pills; Seafarers → Scenario pills.
renderCatanSubsection() {
const C = OpponentSelectScene;
(this._catanSubObjs ?? []).forEach((o) => o.destroy());
this._catanSubObjs = [];
const centerX = this._catanCenterX;
const seafarers = this.selectedExpansion === 'seafarers';
const t = this.add.text(centerX, this._catanSubLabelY, seafarers ? 'Scenario' : 'Tile Placement', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const lbg = this.add.rectangle(centerX, this._catanSubLabelY, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(lbg, t);
this._catanSubObjs.push(t, lbg);
const options = seafarers
? C.CATAN_SCENARIOS
: [{ id: 'random', label: 'Random' }, { id: 'standard', label: 'Standard' }];
const selKey = () => (seafarers ? this.selectedScenario : this.selectedTilePlacement);
const setSel = (id) => { if (seafarers) this.selectedScenario = id; else this.selectedTilePlacement = id; };
const pillW = 150, pillH = 34, gap = 12, cols = 2;
const rowW = cols * pillW + (cols - 1) * gap;
this._catanSubBtns = [];
options.forEach((opt, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + gap) + pillW / 2;
const y = row === 0 ? this._catanSubRow0Y : this._catanSubRow1Y;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, selKey() === opt.id ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._catanSubBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === selKey() ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { setSel(opt.id); refresh(); });
bg.on('pointerover', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (selKey() !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._catanSubBtns.push({ bg, id: opt.id });
this._catanSubObjs.push(bg, pillBg, label);
});
}
// ── Catan: tile placement toggle (legacy; superseded by the expansion picker)
buildTilePlacementSection(centerX, centerY) {
const options = [
{ id: 'random', label: 'Random' },
{ id: 'standard', label: 'Standard' },
];
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, 'Tile Placement', {
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._tilePlacementBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedTilePlacement === 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(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._tilePlacementBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedTilePlacement ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedTilePlacement = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedTilePlacement !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedTilePlacement !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._tilePlacementBtns.push({ bg, id: opt.id });
});
}
// ── Dominion: Expansion + Kingdom selection ────────────────────────────────
// Kingdom preset options per expansion. Each id must match a preset key in the
// corresponding expansion module (or the base KINGDOM_PRESETS), plus 'random'.
static DOMINION_EXPANSIONS = [
{ id: 'base', label: 'Base Game' },
{ id: 'prosperity', label: 'Prosperity' },
];
static DOMINION_KINGDOMS = {
base: [
{ id: 'standard', label: 'Standard' },
{ id: 'size-distortion', label: 'Size Distortion' },
{ id: 'deck-top', label: 'Deck Top' },
{ id: 'silver-gold', label: 'Silver & Gold' },
{ id: 'helpful-actions', label: 'Helpful Actions' },
{ id: 'random', label: 'Random' },
],
prosperity: [
{ id: 'beginners', label: 'Beginners' },
{ id: 'friendly-interactive', label: 'Friendly' },
{ id: 'bigger-treasures', label: 'Bigger Treasures' },
{ id: 'the-king', label: 'The King' },
{ id: 'random', label: 'Random' },
],
};
buildDeckModeSection(centerX, centerY) {
const C = OpponentSelectScene;
// Vertical budget in the left column (~y 930..1078).
const expLabelY = centerY - 80;
const expRowY = centerY - 52;
this._kingdomLabelY = centerY - 14;
this._kingdomRow0Y = centerY + 12;
this._kingdomRow1Y = centerY + 46;
this._kingdomCenterX = centerX;
const mkLabel = (y, text) => {
const t = this.add.text(centerX, y, text, {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5);
const bg = this.add.rectangle(centerX, y, t.width + 28, t.height + 12, 0x000000, 0.72);
this.children.moveBelow(bg, t);
};
// Expansion picker.
mkLabel(expLabelY, 'Expansion');
const expPillW = 150, expPillH = 36, expGap = 14;
const expRowW = C.DOMINION_EXPANSIONS.length * expPillW + (C.DOMINION_EXPANSIONS.length - 1) * expGap;
this._expansionBtns = [];
C.DOMINION_EXPANSIONS.forEach((opt, i) => {
const x = centerX - expRowW / 2 + i * (expPillW + expGap) + expPillW / 2;
const sel = this.selectedExpansion === opt.id;
const bg = this.add.rectangle(x, expRowY, expPillW, expPillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, expRowY, expPillW, expPillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
this.add.text(x, expRowY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
bg.on('pointerup', () => {
if (this.selectedExpansion === opt.id) return;
this.selectedExpansion = opt.id;
// Reset Kingdom to the first preset of the newly-chosen expansion.
this.selectedDeckMode = (C.DOMINION_KINGDOMS[opt.id] ?? C.DOMINION_KINGDOMS.base)[0].id;
this._expansionBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedExpansion ? COLORS.accent : COLORS.muted));
this.renderKingdomPills();
});
bg.on('pointerover', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedExpansion !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._expansionBtns.push({ bg, id: opt.id });
});
// Kingdom label + (dynamic) pills.
mkLabel(this._kingdomLabelY, 'Kingdom');
this._kingdomObjs = [];
this.renderKingdomPills();
}
// (Re)build the Kingdom preset pills for the current expansion.
renderKingdomPills() {
const C = OpponentSelectScene;
(this._kingdomObjs ?? []).forEach((o) => o.destroy());
this._kingdomObjs = [];
this._deckModeBtns = [];
const options = C.DOMINION_KINGDOMS[this.selectedExpansion] ?? C.DOMINION_KINGDOMS.base;
if (!options.some((o) => o.id === this.selectedDeckMode)) this.selectedDeckMode = options[0].id;
const centerX = this._kingdomCenterX;
const pillW = 150, pillH = 34, pillGap = 12, cols = 3;
const rowW = cols * pillW + (cols - 1) * pillGap;
options.forEach((opt, i) => {
const col = i % cols, row = Math.floor(i / cols);
const x = centerX - rowW / 2 + col * (pillW + pillGap) + pillW / 2;
const y = row === 0 ? this._kingdomRow0Y : this._kingdomRow1Y;
const sel = this.selectedDeckMode === opt.id;
const bg = this.add.rectangle(x, y, pillW, pillH, COLORS.panel)
.setStrokeStyle(3, sel ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const pillBg = this.add.rectangle(x, y, pillW, pillH, 0x000000, 0.72);
this.children.moveBelow(pillBg, bg);
const label = this.add.text(x, y, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '15px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._deckModeBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDeckMode ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { this.selectedDeckMode = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedDeckMode !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._deckModeBtns.push({ bg, id: opt.id });
this._kingdomObjs.push(bg, pillBg, label);
});
}
// ── Go Fish: match variant toggle ─────────────────────────────────────────
buildMatchVariantSection(centerX, centerY) {
const options = [
{ id: 2, label: '2-Card' },
{ id: 4, label: '4-Card' },
];
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, 'Match Variant', {
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._matchVariantBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedMatchVariant === 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(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._matchVariantBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedMatchVariant ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedMatchVariant = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedMatchVariant !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedMatchVariant !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._matchVariantBtns.push({ bg, id: opt.id });
});
}
// ── Word Ladder: word-length toggle ───────────────────────────────────────
buildWordLengthSection(centerX, centerY) {
const options = [
{ id: 3, label: '3-Letter' },
{ id: 4, label: '4-Letter' },
];
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, 'Word Length', {
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._wordLengthBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedWordLength === 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(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"',
fontSize: '16px',
color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => {
this._wordLengthBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedWordLength ? COLORS.accent : COLORS.muted)
);
};
bg.on('pointerup', () => { this.selectedWordLength = opt.id; refresh(); });
bg.on('pointerover', () => { if (this.selectedWordLength !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedWordLength !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._wordLengthBtns.push({ bg, id: opt.id });
});
}
// ── 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 });
});
}
// ── Forbidden Island: difficulty (starting water level) ───────────────────
buildDifficultySection(centerX, centerY) {
const options = [
{ id: 'novice', label: 'Novice', desc: 'Water starts low (level 1) — the gentlest island.' },
{ id: 'normal', label: 'Normal', desc: 'Water starts at level 2 — the standard challenge.' },
{ id: 'elite', label: 'Elite', desc: 'Water starts at level 3 — floods come fast.' },
{ id: 'legendary', label: 'Legendary', desc: 'Water starts at level 4 — only for veterans.' },
];
const pillW = 134, pillH = 38, pillGap = 10;
const totalW = options.length * pillW + (options.length - 1) * pillGap;
const labelY = centerY - 44;
const pillY = centerY - 6;
const descY = centerY + 26;
const labelText = this.add.text(centerX, labelY, 'Difficulty', {
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);
const descText = this.add.text(centerX, descY, options.find((o) => o.id === this.selectedDifficulty)?.desc ?? '', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.textHex,
}).setOrigin(0.5);
const descBg = this.add.rectangle(centerX, descY, 640, descText.height + 10, 0x000000, 0.6);
this.children.moveBelow(descBg, descText);
this._difficultyBtns = [];
options.forEach((opt, i) => {
const x = centerX - totalW / 2 + i * (pillW + pillGap) + pillW / 2;
const isSelected = this.selectedDifficulty === 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(pillBg, bg);
this.add.text(x, pillY, opt.label, {
fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.textHex,
}).setOrigin(0.5);
const refresh = () => this._difficultyBtns.forEach(({ bg: b, id }) =>
b.setStrokeStyle(3, id === this.selectedDifficulty ? COLORS.accent : COLORS.muted));
bg.on('pointerup', () => { this.selectedDifficulty = opt.id; descText.setText(opt.desc); refresh(); });
bg.on('pointerover', () => { if (this.selectedDifficulty !== opt.id) bg.setStrokeStyle(3, COLORS.text); });
bg.on('pointerout', () => { if (this.selectedDifficulty !== opt.id) bg.setStrokeStyle(3, COLORS.muted); });
this._difficultyBtns.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) {
const cx = GAME_WIDTH / 2;
const labelText = this.add.text(cx, labelY, label, {
fontFamily: '"Julius Sans One"',
fontSize: '24px',
color: COLORS.mutedHex,
}).setOrigin(0.5);
const labelPill = this.add.rectangle(cx, labelY, labelText.width + 40, labelText.height + 18, 0x000000, 0.72);
this.children.moveBelow(labelPill, labelText);
const tileY = labelY + Math.max(82, Math.round(tileH / 2) + 18);
const totalW = items.length * tileW + (items.length - 1) * tileGap;
const startX = cx - totalW / 2 + tileW / 2;
items.forEach((item, i) => {
const x = startX + i * (tileW + tileGap);
this.buildOptionTile(item, x, tileY, selectedProp, tilesProp, onSelect, tileW, tileH);
});
}
buildOptionTile(item, x, y, selectedProp, tilesProp, onSelect, tileW = TILE_W, tileH = TILE_H) {
const container = this.add.container(x, y);
const isSelected = this[selectedProp]?.id === item.id;
const bg = this.add.rectangle(0, 0, tileW, tileH, COLORS.panel)
.setStrokeStyle(3, isSelected ? COLORS.accent : COLORS.muted)
.setInteractive({ useHandCursor: true });
const overlay = this.add.rectangle(0, 0, tileW, tileH, COLORS.accent, 0);
const thumbW = tileW - 16;
const thumbH = tileH - 30;
let thumb;
const thumbKey = item.key;
if (thumbKey && this.textures.exists(thumbKey)) {
const frame = item.spriteIndex ?? undefined;
thumb = this.add.image(0, -8, thumbKey, frame)
.setDisplaySize(thumbW, thumbH)
.setOrigin(0.5);
} else {
const fallbackHex = item.fallbackColor ?? '#1a3a6b';
const fallback = parseInt(fallbackHex.replace('#', ''), 16);
thumb = this.add.rectangle(0, -8, thumbW, thumbH, fallback);
}
const nameText = this.add.text(0, tileH / 2 - 11, item.name, {
fontFamily: '"Julius Sans One"',
fontSize: '13px',
color: COLORS.textHex,
}).setOrigin(0.5);
container.add([bg, overlay, thumb, nameText]);
bg.on('pointerup', () => {
this[selectedProp] = item;
this.highlightTile(tilesProp, item.id);
onSelect(item);
});
bg.on('pointerover', () => {
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.text);
});
bg.on('pointerout', () => {
if (this[selectedProp]?.id !== item.id) bg.setStrokeStyle(3, COLORS.muted);
});
this[tilesProp].push({ item, bg, overlay, nameText });
}
applyDefault(cacheKey, defaultId, selectedProp, tilesProp) {
const data = this.cache.json.get(cacheKey);
const items = data?.playfields ?? data?.cardBacks ?? [];
const def = items.find((i) => i.id === defaultId) ?? items[0] ?? null;
if (!def) return;
this[selectedProp] = def;
this.highlightTile(tilesProp, def.id);
}
applyOpponentStyle(el, selected) {
if (selected) {
el.style.border = `3px solid ${COLORS.accentHex}`;
el.style.background = `linear-gradient(rgba(200,168,75,0.22),rgba(200,168,75,0.22)),${COLORS.panelHex}`;
el.style.boxShadow = `0 0 0 2px rgba(200,168,75,0.22), 0 0 24px rgba(200,168,75,0.42)`;
if (el._nameEl) el._nameEl.style.color = COLORS.accentHex;
} else {
el.style.border = `2px solid ${COLORS.mutedHex}`;
el.style.background = COLORS.panelHex;
el.style.boxShadow = 'none';
if (el._nameEl) el._nameEl.style.color = COLORS.textHex;
}
}
highlightTile(tilesProp, selectedId) {
for (const t of this[tilesProp]) {
const sel = t.item.id === selectedId;
t.overlay.setFillStyle(COLORS.accent, sel ? 0.22 : 0);
t.bg.setStrokeStyle(sel ? 4 : 3, sel ? COLORS.accent : COLORS.muted);
t.nameText.setColor(sel ? COLORS.accentHex : COLORS.textHex);
}
}
selectPlayfield(pf) { this.selectedPlayfield = pf; }
selectCardBack(cb) { this.selectedCardBack = cb; }
// ── Start game ─────────────────────────────────────────────────────────────
startGame() {
if (this.selected.size < (this.gameDef.minOpponents ?? 1)) return;
this._startingGame = true;
stopMenuMusic();
const opponents = this.cards
.filter(({ opp }) => this.selected.has(opp.id))
.map(({ opp }) => ({ ...opp, skill: this.skillByOpp[opp.id] ?? 3 }));
this.scene.start('GameRoom', {
game: this.gameDef,
opponents,
playfield: this.selectedPlayfield,
cardBack: this.selectedCardBack,
tilePlacement: this.selectedTilePlacement,
matchVariant: this.selectedMatchVariant,
expansion: this.selectedExpansion,
scenario: (this.gameDef.slug === 'catan' && this.selectedExpansion !== 'base')
? this.selectedScenario : null,
deckMode: this.selectedDeckMode,
wordLength: this.selectedWordLength,
secretRevealType: this.selectedSecretRevealType,
difficulty: this.selectedDifficulty,
});
}
}