646 lines
24 KiB
JavaScript
646 lines
24 KiB
JavaScript
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,
|
||
});
|
||
}
|
||
}
|