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, }); } }