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

646 lines
24 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._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 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]];
}
this.buildOpponentGrid(opponents);
const max = this.gameDef.maxOpponents ?? 1;
this._initializing = true;
this.cards.slice(0, max).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.buildTilePlacementSection(340, 1013);
this.buildOptionSection('Playfield', 630, this.cache.json.get('playfields')?.playfields ?? [],
'selectedPlayfield', 'playfieldTiles', (pf) => this.selectPlayfield(pf));
if (this.gameDef.cardGame) {
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
const pfd = this.cache.json.get('playfields') ?? {};
this.applyDefault('playfields', pfd.default, 'selectedPlayfield', 'playfieldTiles');
if (this.gameDef.cardGame) {
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) {
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:${OPP_SCROLL_H}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 + OPP_SCROLL_H / 2, wrapper);
}
buildOpponentCardEl(opp, cardH) {
// Default each opponent to a random skill of 2 or 3 (Nerts uses this).
if (this.skillByOpp[opp.id] === undefined) {
this.skillByOpp[opp.id] = 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 (Nerts only): pips always show the level; the +/- buttons
// appear only when this opponent is selected.
if (this.gameDef.slug === 'nerts') {
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: tile placement toggle ───────────────────────────────────────────
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 });
});
}
// ── 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 === 0) 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,
});
}
}