Commit c284b158 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(design): play flow and game rooms in The Cafe Deck

- Chess bots are court cards: the bot's initial printed large in red or black
  ink by strength, name plate, a one-line personality, the rating band as the
  corner index; the strongest carries a crown.
- Time controls are printed cards grouped by bullet, blitz, rapid, classical.
- Matchmaking shows a face-down deck riffling while the clock counts.
- Challenge-a-friend rows, a card-stock options sheet; the pre-game lobby shows
  you and your opponent as two player cards, the opponent face down until they
  accept. The lobby's clock chip now shows the real control; every classical
  control used to read 30 minutes.
- Ludo, domino and backgammon rooms: chip groups printed on card stock when
  chosen, ludo seats in each player's ink, one red start button. Domino bot
  setup is one screen (level and target, then start) instead of two steps.
- PRODUCT.md: El3ab is a game, not an app; binding for every screen.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 83ceec2a
...@@ -61,6 +61,12 @@ championship with a live broadcast. ...@@ -61,6 +61,12 @@ championship with a live broadcast.
## Brand Commitments ## Brand Commitments
- **El3ab is a mobile game, not an app and not a website** (owner, 2026-10-19,
binding). Everything must feel like a game: every touch answers with motion,
sound and haptics; screens are dealt, flipped and slid like cards; rewards,
wins, ranks and matches are celebrated; nothing sits dead. Judge every screen
by "would this feel at home in a polished mobile game?", never by app
conventions.
- Name: EL3AB / العب. - Name: EL3AB / العب.
- The crown mark (a king-like crown with diamond accents) is kept; source files in - The crown mark (a king-like crown with diamond accents) is kept; source files in
`Logo El3ab/`. Exists in gold and in a cyan-to-blue gradient. `Logo El3ab/`. Exists in gold and in a cyan-to-blue gradient.
...@@ -77,6 +83,7 @@ championship with a live broadcast. ...@@ -77,6 +83,7 @@ championship with a live broadcast.
## Product Principles ## Product Principles
0. It is a game. Feedback, juice and ceremony are features, not decoration.
1. Game in thirty seconds: anything that adds steps before the first move must earn it. 1. Game in thirty seconds: anything that adds steps before the first move must earn it.
2. The table is sacred: nothing competes with the board while a game is on. 2. The table is sacred: nothing competes with the board while a game is on.
3. Competition carries prestige: a rating, a rank and a result should feel earned. 3. Competition carries prestige: a rating, a rank and a result should feel earned.
......
...@@ -68,6 +68,8 @@ async function run() { ...@@ -68,6 +68,8 @@ async function run() {
const btns = [...document.querySelectorAll('[data-level], button')]; const btns = [...document.querySelectorAll('[data-level], button')];
const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط')); const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط'));
if (mid) mid.click(); if (mid) mid.click();
// The bot setup is one screen since the 2026-10 redesign: pick, then start.
document.querySelector('#btn-start')?.click();
}); });
await wait(3000); await wait(3000);
} }
......
...@@ -742,7 +742,30 @@ const strings = { ...@@ -742,7 +742,30 @@ const strings = {
'home.streak': 'سلسلة {n} يوم', 'home.streak': 'سلسلة {n} يوم',
'home.daily_sub': 'عملات كل يوم', 'home.daily_sub': 'عملات كل يوم',
'home.daily_ready': 'جاهزة: {n} عملة', 'home.daily_ready': 'جاهزة: {n} عملة',
'home.achievements_sub': 'أوسمتك وتقدّمك' 'home.achievements_sub': 'أوسمتك وتقدّمك',
'bots.sub': 'اختر خصمًا يناسب مستواك',
'bots.rating_band': 'تقييم {min} إلى {max}',
'bots.strongest': 'الأقوى',
'bots.note_amina': 'ما زالت تتعلّم، وتخطئ كثيرًا',
'bots.note_tarek': 'يبيّت ملكه مبكرًا ويحمي قطعه',
'bots.note_nour': 'تضحّي بقطعها لتصل إلى ملكك',
'bots.note_omar': 'يمسك المركز ويكسب النهايات',
'bots.note_layla': 'تكتيكات وتضحيات لا تتوقّعها',
'bots.note_ziad': 'بلا نقاط ضعف، ونادرًا ما يخطئ',
'bots.note_grandmaster': 'قوة ستوكفيش كاملة، لا يغفر غلطة',
'play.tc_2min': 'دقيقتان',
'play.tc_90min': '90 دقيقة',
'play.tc_inc': '{n} ث',
'queue.hint': 'تبدأ المباراة فور العثور على خصم',
'queue.elapsed': 'مدة الانتظار',
'challenge.action': 'تحدّي',
'challenge.sheet_title': 'تحدّي {name}',
'challenge.game': 'اللعبة',
'challenge.time': 'الوقت',
'lobby.white': 'بالأبيض',
'lobby.black': 'بالأسود',
'ludo.seat_empty': 'فارغ',
'domino.target': 'هدف المباراة'
}, },
en: { en: {
'app.name': 'EL3AB', 'app.name': 'EL3AB',
...@@ -1485,7 +1508,30 @@ const strings = { ...@@ -1485,7 +1508,30 @@ const strings = {
'home.streak': '{n}-day streak', 'home.streak': '{n}-day streak',
'home.daily_sub': 'Coins every day', 'home.daily_sub': 'Coins every day',
'home.daily_ready': 'Ready: {n} coins', 'home.daily_ready': 'Ready: {n} coins',
'home.achievements_sub': 'Your badges and progress' 'home.achievements_sub': 'Your badges and progress',
'bots.sub': 'Pick an opponent at your level',
'bots.rating_band': 'Rated {min} to {max}',
'bots.strongest': 'Strongest',
'bots.note_amina': 'Still learning, and blunders often',
'bots.note_tarek': 'Castles early and guards every piece',
'bots.note_nour': 'Gives up pieces to reach your king',
'bots.note_omar': 'Holds the centre, wins endgames',
'bots.note_layla': 'Tactics and sacrifices you won\'t see coming',
'bots.note_ziad': 'No weak spots, rarely blunders',
'bots.note_grandmaster': 'Full Stockfish strength. Forgives nothing',
'play.tc_2min': '2 min',
'play.tc_90min': '90 min',
'play.tc_inc': '{n}s',
'queue.hint': 'The game starts as soon as someone is found',
'queue.elapsed': 'Time waiting',
'challenge.action': 'Challenge',
'challenge.sheet_title': 'Challenge {name}',
'challenge.game': 'Game',
'challenge.time': 'Time',
'lobby.white': 'White',
'lobby.black': 'Black',
'ludo.seat_empty': 'Empty',
'domino.target': 'Match target'
} }
}; };
......
...@@ -2,8 +2,9 @@ import * as scene from '../../../core/scene.js'; ...@@ -2,8 +2,9 @@ import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead } from '../../../core/ui.js';
export function mountRoom(el, params) { export function mountRoom(el, params) {
const { mode = 'menu' } = params || {}; const { mode = 'menu' } = params || {};
...@@ -13,37 +14,59 @@ export function mountRoom(el, params) { ...@@ -13,37 +14,59 @@ export function mountRoom(el, params) {
else if (mode === 'searching') renderSearching(el, params); else if (mode === 'searching') renderSearching(el, params);
} }
/** Give the header's back button the id the handlers below were written for. */
function headBack(el, id) {
const b = el.querySelector('[data-back]');
if (b) b.id = id;
return b;
}
/** A mode row: icon on the reading side, label and line, chevron at the end. */
function modeRow(id, ic, label, desc, live = false) {
return `
<button class="btn btn-card btn-block bg-btn bgr-mode" id="${id}">
<span class="bg-btn-icon ${live ? 'bgr-live' : ''}">${icon(ic, 24)}</span>
<span class="bgr-mode-text">
<span class="bg-btn-label">${label}</span>
<span class="bg-btn-desc">${desc}</span>
</span>
${icon('chevron-end', 20, { cls: 'bgr-chev' })}
</button>`;
}
/** Strength printed as pips: one for easy, three for hard. */
function pips(n) {
return `<span class="bgr-pips" aria-hidden="true">${icon('suit-diamond', 9).repeat(n)}</span>`;
}
/** A chip in an option group; the chosen one is printed on card stock. */
function chip(attr, value, active, inner) {
return `<button class="chip bg-chip ${active ? 'bg-chip-active' : ''}" data-${attr}="${value}" aria-pressed="${active}">${inner}</button>`;
}
function renderMenu(el) { function renderMenu(el) {
const mark = GAME_MARK.backgammon.icon;
el.innerHTML = ` el.innerHTML = `
<div class="bg-wrap"> <div class="screen bg-wrap">
<div class="bg-hero"> ${screenHead(t('backgammon.title'), { sub: t('backgammon.subtitle') })}
<div class="bg-icon">${emoji('game_die', '🎲', 56)}</div>
<h1 class="bg-title">${t('backgammon.title')}</h1> <div class="bg-hero" aria-hidden="true">
<p class="bg-subtitle">${t('backgammon.subtitle')}</p> <div class="deck-card ${GAME_MARK.backgammon.ink === 'red' ? 'ink-red' : ''} bgr-game-card dealt" style="--deal-rot:-5deg;">
<span class="index top">${icon(mark, 14)}</span>
<span class="bgr-game-mark">${icon(mark, 52)}</span>
<span class="index bottom">${icon(mark, 14)}</span>
</div>
</div> </div>
<div class="bg-buttons"> <div class="bg-buttons">
<button class="bg-btn bg-btn-primary" id="btn-local"> ${modeRow('btn-local', 'bot', t('game.vs_bot'), t('backgammon.bot_desc'))}
<span class="bg-btn-icon">${emoji('gamepad', '🎮', 22)}</span> ${modeRow('btn-online', 'globe', t('game.online'), t('backgammon.online_desc'), true)}
<span class="bg-btn-label">${t('game.vs_bot')}</span> ${modeRow('btn-friend', 'swords', t('game.vs_friend'), t('backgammon.friend_desc'))}
<span class="bg-btn-desc">${t('backgammon.bot_desc')}</span>
</button>
<button class="bg-btn bg-btn-online" id="btn-online">
<span class="bg-btn-icon">${emoji('globe', '🌍', 22)}</span>
<span class="bg-btn-label">${t('game.online')}</span>
<span class="bg-btn-desc">${t('backgammon.online_desc')}</span>
</button>
<button class="bg-btn bg-btn-friend" id="btn-friend">
<span class="bg-btn-icon">${emoji('handshake', '🤝', 20)}</span>
<span class="bg-btn-label">${t('game.vs_friend')}</span>
<span class="bg-btn-desc">${t('backgammon.friend_desc')}</span>
</button>
</div> </div>
<button class="bg-back" id="btn-back">${t('common.back')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
headBack(el, 'btn-back');
el.querySelector('#btn-local').onclick = () => { el.querySelector('#btn-local').onclick = () => {
audio.play('click'); audio.play('click');
...@@ -76,91 +99,56 @@ function renderSetup(el, params) { ...@@ -76,91 +99,56 @@ function renderSetup(el, params) {
const isOnline = type === 'online'; const isOnline = type === 'online';
el.innerHTML = ` el.innerHTML = `
<div class="bg-wrap"> <div class="screen bg-wrap">
<div class="bg-hero" style="margin-bottom:16px;"> ${screenHead(t('backgammon.title'), { sub: isOnline ? t('game.online') : t('game.vs_bot') })}
<h2 class="bg-title" style="font-size:20px;">${isOnline ? t('backgammon.settings_online') : t('backgammon.settings_game')}</h2>
</div>
<!-- Variant --> <!-- Variant -->
<div class="bg-section"> <section class="bg-section">
<div class="bg-section-title">${t('backgammon.game_type')}</div> <h2 class="section-title bg-section-title">${t('backgammon.game_type')}</h2>
<div class="bg-grid" id="variant-grid"> <div class="chips bg-grid" id="variant-grid">
<button class="bg-chip bg-chip-active" data-variant="sheshbesh"> ${chip('variant', 'sheshbesh', true, `<span class="bg-chip-label">${t('backgammon.sheshbesh')}</span>`)}
<span class="bg-chip-label">${t('backgammon.sheshbesh')}</span> ${chip('variant', 'mahbousa', false, `<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>`)}
</button> ${chip('variant', 'thirtyone', false, `<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>`)}
<button class="bg-chip" data-variant="mahbousa">
<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>
</button>
<button class="bg-chip" data-variant="thirtyone">
<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>
</button>
</div>
</div> </div>
</section>
<!-- Match Length --> <!-- Match Length -->
<div class="bg-section"> <section class="bg-section">
<div class="bg-section-title">${t('backgammon.match_length')}</div> <h2 class="section-title bg-section-title">${t('backgammon.match_length')}</h2>
<div class="bg-grid" id="length-grid"> <div class="chips bg-grid" id="length-grid">
<button class="bg-chip" data-len="1"> ${[[1, 'short_match'], [3, 'medium_match'], [5, 'normal_match'], [7, 'long_match']].map(([n, key]) =>
<span class="bg-chip-num">1</span> chip('len', n, n === 3, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + key)}</span>`)).join('')}
<span class="bg-chip-label">${t('backgammon.short_match')}</span>
</button>
<button class="bg-chip bg-chip-active" data-len="3">
<span class="bg-chip-num">3</span>
<span class="bg-chip-label">${t('backgammon.medium_match')}</span>
</button>
<button class="bg-chip" data-len="5">
<span class="bg-chip-num">5</span>
<span class="bg-chip-label">${t('backgammon.normal_match')}</span>
</button>
<button class="bg-chip" data-len="7">
<span class="bg-chip-num">7</span>
<span class="bg-chip-label">${t('backgammon.long_match')}</span>
</button>
</div>
</div> </div>
</section>
${!isOnline ? ` ${!isOnline ? `
<!-- Bot Difficulty --> <!-- Bot Difficulty -->
<div class="bg-section"> <section class="bg-section">
<div class="bg-section-title">${t('ludo.bot_level')}</div> <h2 class="section-title bg-section-title">${t('ludo.bot_level')}</h2>
<div class="bg-grid" id="difficulty-grid"> <div class="chips bg-grid" id="difficulty-grid">
<button class="bg-chip" data-diff="easy"> ${chip('diff', 'easy', false, `${pips(1)}<span class="bg-chip-label">${t('ludo.easy')}</span>`)}
<span>${emoji('bot_easy', '😊', 18)}</span> ${chip('diff', 'medium', true, `${pips(2)}<span class="bg-chip-label">${t('ludo.medium')}</span>`)}
<span class="bg-chip-label">${t('ludo.easy')}</span> ${chip('diff', 'hard', false, `${pips(3)}<span class="bg-chip-label">${t('ludo.hard')}</span>`)}
</button>
<button class="bg-chip bg-chip-active" data-diff="medium">
<span>${emoji('bot_medium', '🧐', 18)}</span>
<span class="bg-chip-label">${t('ludo.medium')}</span>
</button>
<button class="bg-chip" data-diff="hard">
<span>${emoji('bot_hard', '🧠', 18)}</span>
<span class="bg-chip-label">${t('ludo.hard')}</span>
</button>
</div>
</div> </div>
</section>
` : ''} ` : ''}
<!-- Doubling Cube Toggle --> <!-- Doubling Cube Toggle -->
<div class="bg-section"> <section class="bg-section">
<div class="bg-section-title">${t('backgammon.doubling_cube')}</div> <h2 class="section-title bg-section-title">${t('backgammon.doubling_cube')}</h2>
<div class="bg-grid" id="cube-grid"> <div class="chips bg-grid" id="cube-grid">
<button class="bg-chip bg-chip-active" data-cube="on"> ${chip('cube', 'on', true, `${icon('check', 16)}<span class="bg-chip-label">${t('backgammon.enabled')}</span>`)}
<span class="bg-chip-label">${t('backgammon.enabled')}</span> ${chip('cube', 'off', false, `${icon('x', 16)}<span class="bg-chip-label">${t('backgammon.disabled')}</span>`)}
</button>
<button class="bg-chip" data-cube="off">
<span class="bg-chip-label">${t('backgammon.disabled')}</span>
</button>
</div>
</div> </div>
</section>
<button class="bg-btn bg-btn-start" id="btn-start"> <button class="btn btn-primary btn-lg btn-block bgr-start" id="btn-start">
${isOnline ? t('ludo.search_match') : t('ludo.start_play')} ${icon(isOnline ? 'search' : 'play', 20)}${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
</button> </button>
<button class="bg-back" id="btn-back-setup">${t('common.back')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
headBack(el, 'btn-back-setup');
let variant = 'sheshbesh'; let variant = 'sheshbesh';
let matchLength = 3; let matchLength = 3;
...@@ -196,15 +184,15 @@ function renderSetup(el, params) { ...@@ -196,15 +184,15 @@ function renderSetup(el, params) {
function renderSearching(el) { function renderSearching(el) {
el.innerHTML = ` el.innerHTML = `
<div class="bg-wrap"> <div class="screen bg-wrap bgr-searching">
<div class="bg-hero"> <div class="bg-hero">
<div class="bg-pulse-ring"> <div class="bg-pulse-ring" aria-hidden="true">
<div class="bg-pulse-inner">${emoji('game_die', '🎲', 32)}</div> ${[0, 1, 2].map(i => `<span class="deck-back bg-pulse-inner" style="--q:${i};"></span>`).join('')}
</div> </div>
<h2 class="bg-title" style="font-size:18px;margin-top:20px;">${t('backgammon.searching')}</h2> <h2 class="bg-title" role="status">${t('backgammon.searching')}</h2>
<p class="bg-subtitle">${t('backgammon.searching_hint')}</p> <p class="bg-subtitle">${t('backgammon.searching_hint')}</p>
</div> </div>
<button class="bg-btn bg-btn-friend" id="btn-cancel" style="max-width:200px;">${t('common.cancel')}</button> <button class="btn btn-secondary btn-lg btn-block bg-btn-friend" id="btn-cancel">${icon('x', 18)}${t('common.cancel')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
...@@ -215,8 +203,9 @@ function setupChipGroup(el, selector, onChange, dataKey) { ...@@ -215,8 +203,9 @@ function setupChipGroup(el, selector, onChange, dataKey) {
el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => { el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
el.querySelectorAll(`${selector} .bg-chip`).forEach(b => b.classList.remove('bg-chip-active')); el.querySelectorAll(`${selector} .bg-chip`).forEach(b => { b.classList.remove('bg-chip-active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('bg-chip-active'); btn.classList.add('bg-chip-active');
btn.setAttribute('aria-pressed', 'true');
onChange(btn.dataset[dataKey]); onChange(btn.dataset[dataKey]);
}); });
}); });
...@@ -224,59 +213,63 @@ function setupChipGroup(el, selector, onChange, dataKey) { ...@@ -224,59 +213,63 @@ function setupChipGroup(el, selector, onChange, dataKey) {
function getStyles() { function getStyles() {
return `<style> return `<style>
.bg-wrap { .bg-wrap { display: flex; flex-direction: column; }
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:20px;
background:linear-gradient(180deg,#0a0f14 0%,#101820 40%,#141f2c 100%);
overflow-y:auto;
}
.bg-hero { text-align:center;margin-bottom:24px; }
.bg-icon { font-size:56px;margin-bottom:12px;animation:bgFloat 3s ease-in-out infinite; }
.bg-title { font-size:26px;font-weight:800;color:var(--text-primary);margin:0; }
.bg-subtitle { font-size:13px;color:var(--gold-dark);margin:8px 0 0;opacity:0.9; }
.bg-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:320px; }
.bg-btn {
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
min-height:56px;border-radius:16px;font-size:16px;font-weight:700;
border:none;cursor:pointer;padding:14px 18px;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s;
box-shadow:0 4px 16px rgba(0,0,0,0.3);
}
.bg-btn:active { transform:scale(0.95); }
.bg-btn-primary { background:linear-gradient(135deg,var(--gold-dark) 0%,#8B4513 100%);color:#fff; }
.bg-btn-online { background:linear-gradient(135deg,var(--purple) 0%,var(--violet) 100%);color:#fff; }
.bg-btn-friend { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:var(--text-light);min-height:50px;font-size:14px;box-shadow:none; }
.bg-btn-start { width:100%;max-width:320px;justify-content:center;background:linear-gradient(135deg,var(--gold-dark),#8B4513);color:#fff;font-size:17px;font-weight:800;margin-top:16px; }
.bg-btn-icon { display:flex;flex-shrink:0; }
.bg-btn-label { font-weight:700;font-size:16px; }
.bg-btn-desc { width:100%;font-size:11px;opacity:0.7;font-weight:400;margin-top:2px; }
.bg-back { margin-top:16px;font-size:13px;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:8px 16px; }
.bg-section { width:100%;max-width:320px;margin-bottom:16px; } /* The game's card, lying on the table under the header. */
.bg-section-title { font-size:12px;font-weight:700;color:var(--text-secondary);margin-bottom:8px;text-align:center; } .bg-hero { display: flex; justify-content: center; margin: var(--s-2) 0 var(--s-6); }
.bg-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; } .bgr-game-card {
.bg-chip { width: 104px; height: 146px;
display:flex;align-items:center;gap:6px; display: flex; align-items: center; justify-content: center;
padding:10px 16px;border-radius:12px; color: var(--suit);
background:rgba(255,255,255,0.04);border:1.5px solid rgba(255,255,255,0.08);
color:var(--text-secondary);font-size:13px;font-weight:600;cursor:pointer;
transition:all 0.2s cubic-bezier(0.34,1.56,0.64,1);
} }
.bg-chip:active { transform:scale(0.95); } .bgr-game-card .index { font-size: 12px; }
.bg-chip-active { background:rgba(212,148,10,0.12);border-color:rgba(212,148,10,0.5);color:var(--gold-dark); } .bgr-game-mark { display: flex; }
.bg-chip-num { font-size:18px;font-weight:800; }
.bg-chip-label { font-size:12px; }
.bg-pulse-ring { /* Mode rows: printed card faces, icon on the reading side. */
width:80px;height:80px;border-radius:50%; .bg-buttons { display: flex; flex-direction: column; gap: var(--s-3); }
border:3px solid rgba(212,148,10,0.4); .bgr-mode {
display:flex;align-items:center;justify-content:center; justify-content: flex-start;
animation:bgPulse 2s ease-in-out infinite;margin:0 auto; gap: var(--s-3);
min-height: 72px;
padding: var(--s-3) var(--s-4);
text-align: start;
} }
.bg-pulse-inner { font-size:32px; } .bg-btn-icon { display: flex; flex-shrink: 0; color: var(--ink); }
.bg-btn-icon.bgr-live { color: var(--red-ink); }
.bgr-mode-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.bg-btn-label { font-size: var(--fs-md); font-weight: 800; color: var(--ink); }
.bg-btn-desc { font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.bgr-chev { color: var(--ink-3); }
@keyframes bgFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } /* Option groups. Chips wrap so every choice stays in view. */
@keyframes bgPulse { 0%,100%{border-color:rgba(212,148,10,0.4);transform:scale(1)} 50%{border-color:rgba(212,148,10,0.7);transform:scale(1.05)} } .bg-section:first-of-type .bg-section-title { margin-top: var(--s-2); }
.bg-grid { flex-wrap: wrap; overflow: visible; }
.bg-chip { min-height: 44px; }
.bg-chip.bg-chip-active { background: var(--card); color: var(--ink); border-color: var(--card); }
.bg-chip-num { font-size: var(--fs-md); font-weight: 800; }
.bg-chip-label { font-size: var(--fs-sm); }
.bgr-pips { display: inline-flex; gap: 2px; opacity: 0.8; }
.bg-chip-active .bgr-pips { color: var(--red-ink); opacity: 1; }
.bgr-start { margin-top: var(--s-8); }
/* Searching: a short stack of cards shuffling. */
.bgr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); }
.bgr-searching .bg-hero { flex-direction: column; align-items: center; margin: 0; }
.bg-pulse-ring { display: grid; place-items: center; height: 120px; margin-bottom: var(--s-5); }
.bg-pulse-inner {
grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 8px; box-shadow: var(--shadow-md);
transform: rotate(calc((var(--q) - 1) * 6deg));
animation: bgrShuffle 2.8s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 90ms);
}
@keyframes bgrShuffle {
0%, 20%, 80%, 100% { transform: rotate(calc((var(--q) - 1) * 6deg)); }
45%, 55% { transform: translate(calc((var(--q) - 1) * 34px), -8px) rotate(calc((var(--q) - 1) * 10deg)); }
}
@media (prefers-reduced-motion: reduce) { .bg-pulse-inner { animation: none; } }
.bg-title { font-size: var(--fs-lg); font-weight: 800; color: var(--text-primary); }
.bg-subtitle { font-size: var(--fs-sm); color: var(--text-muted); margin-top: var(--s-1); }
</style>`; </style>`;
} }
......
...@@ -4,7 +4,8 @@ import * as audio from '../../../core/audio.js'; ...@@ -4,7 +4,8 @@ import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead } from '../../../core/ui.js';
let pollTimer = null; let pollTimer = null;
...@@ -20,60 +21,49 @@ export function mountRoom(el, params) { ...@@ -20,60 +21,49 @@ export function mountRoom(el, params) {
} }
} }
function renderMenu(el) { /** Give the header's back button the id the handlers below were written for. */
el.innerHTML = ` function headBack(el, id) {
<div class="domino-menu"> const b = el.querySelector('[data-back]');
<div class="dm-hero"> if (b) b.id = id;
<div class="dm-icon">${emoji('domino_tile', '🁣', 56)}</div> return b;
<h1 class="dm-title">${t('domino.title')}</h1> }
<p class="dm-subtitle">${t('domino.subtitle')}</p>
/** The domino card, lying on the table; face down while a friend decides. */
function gameCard(faceDown = false) {
const mark = GAME_MARK.domino.icon;
return `
<div class="dm-hero" aria-hidden="true">
<div class="deck-card dm-game-card dealt ${faceDown ? 'face-down' : GAME_MARK.domino.ink === 'red' ? 'ink-red' : ''}" style="--deal-rot:-5deg;">
${faceDown ? '' : `<span class="index top">${icon(mark, 14)}</span>`}
<span class="dm-game-mark">${icon(faceDown ? 'hourglass' : mark, faceDown ? 30 : 52)}</span>
${faceDown ? '' : `<span class="index bottom">${icon(mark, 14)}</span>`}
</div> </div>
</div>`;
}
function modeRow(id, ic, label, live = false) {
return `
<button class="btn btn-card btn-block dm-btn dm-mode" id="${id}">
<span class="dm-btn-icon ${live ? 'dm-live' : ''}">${icon(ic, 24)}</span>
<span class="dm-btn-label">${label}</span>
${icon('chevron-end', 20, { cls: 'dm-chev' })}
</button>`;
}
function renderMenu(el) {
el.innerHTML = `
<div class="screen domino-menu">
${screenHead(t('domino.title'), { sub: t('domino.subtitle') })}
${gameCard()}
<div class="dm-buttons"> <div class="dm-buttons">
<button class="dm-btn dm-btn-primary" id="btn-bot"> ${modeRow('btn-bot', 'bot', t('game.vs_bot'))}
<span class="dm-btn-icon">${emoji('robot', '🤖', 22)}</span> ${modeRow('btn-online', 'globe', t('game.online'), true)}
<span class="dm-btn-label">${t('game.vs_bot')}</span> ${modeRow('btn-friend', 'swords', t('game.vs_friend'))}
</button>
<button class="dm-btn dm-btn-online" id="btn-online">
<span class="dm-btn-icon">${emoji('globe', '🌍', 22)}</span>
<span class="dm-btn-label">${t('game.online')}</span>
</button>
<button class="dm-btn dm-btn-friend" id="btn-friend">
<span class="dm-btn-icon">${emoji('handshake', '🤝', 20)}</span>
<span class="dm-btn-label">${t('game.vs_friend')}</span>
</button>
</div> </div>
<button class="dm-back" id="btn-back">${t('common.back')}</button>
</div> </div>
<style> ${STYLE}
.domino-menu {
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:24px;
background:linear-gradient(180deg, #070d14 0%, var(--bg-inset) 40%, #0d1a2a 100%);
}
.dm-hero { text-align:center;margin-bottom:28px; }
.dm-icon { font-size:56px;margin-bottom:12px;animation:dmFloat 3s ease-in-out infinite; }
.dm-title { font-size:26px;font-weight:800;color:var(--text-primary);margin:0; }
.dm-subtitle { font-size:13px;color:var(--gold);margin:8px 0 0;opacity:0.9; }
.dm-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:300px; }
.dm-btn {
display:flex;align-items:center;justify-content:center;gap:10px;
min-height:56px;border-radius:16px;font-size:16px;font-weight:700;
border:none;cursor:pointer;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
box-shadow:0 4px 16px rgba(0,0,0,0.3);
}
.dm-btn:active { transform:scale(0.95); }
.dm-btn-primary { background:linear-gradient(135deg,var(--gold) 0%,var(--gold-dark) 100%);color:var(--bg-dark); }
.dm-btn-online { background:linear-gradient(135deg,var(--cyan) 0%,#0891b2 100%);color:#fff; }
.dm-btn-friend { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:var(--text-light);min-height:50px;font-size:14px;box-shadow:none; }
.dm-btn-icon { display:flex; }
.dm-btn-label { flex:none; }
.dm-back { margin-top:20px;font-size:13px;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:8px 16px; }
@keyframes dmFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} }
</style>
`; `;
headBack(el, 'btn-back');
el.querySelector('#btn-bot').addEventListener('click', () => { el.querySelector('#btn-bot').addEventListener('click', () => {
audio.play('click'); audio.play('click');
...@@ -96,128 +86,116 @@ function renderMenu(el) { ...@@ -96,128 +86,116 @@ function renderMenu(el) {
}); });
} }
/** Strength printed as pips: one for the beginner, three for the expert. */
function pips(n) {
return `<span class="dm-pips" aria-hidden="true">${icon('suit-diamond', 9).repeat(n)}</span>`;
}
// One setup screen: the bot's level and the score to play to, then start.
// The line under each group says what the chosen option means.
function renderBotPicker(el) { function renderBotPicker(el) {
const levels = [ const levels = [
{ key: 'beginner', label: t('domino.beginner'), desc: t('domino.beginner_desc'), icon: emoji('bot_easy', '😊', 30), color: 'var(--green-light)', bg: 'rgba(74,222,128,0.08)', border: 'rgba(74,222,128,0.2)' }, { key: 'beginner', label: t('domino.beginner'), desc: t('domino.beginner_desc'), pips: 1 },
{ key: 'intermediate', label: t('domino.intermediate'), desc: t('domino.intermediate_desc'), icon: emoji('bot_medium', '🧐', 30), color: 'var(--amber)', bg: 'rgba(251,191,36,0.08)', border: 'rgba(251,191,36,0.2)' }, { key: 'intermediate', label: t('domino.intermediate'), desc: t('domino.intermediate_desc'), pips: 2 },
{ key: 'expert', label: t('domino.expert'), desc: t('domino.expert_desc'), icon: emoji('bot_hard', '🧠', 30), color: 'var(--red-light)', bg: 'rgba(248,113,113,0.08)', border: 'rgba(248,113,113,0.2)' } { key: 'expert', label: t('domino.expert'), desc: t('domino.expert_desc'), pips: 3 }
]; ];
const targets = [
{ value: 50, label: t('domino.50_points'), desc: t('domino.50_desc') },
{ value: 100, label: t('domino.100_points'), desc: t('domino.100_desc') },
{ value: 150, label: t('domino.150_points'), desc: t('domino.150_desc') }
];
let botLevel = 'intermediate';
let targetScore = 100;
el.innerHTML = ` el.innerHTML = `
<div class="domino-menu"> <div class="screen domino-menu">
<div class="dm-hero"> ${screenHead(t('domino.title'), { sub: t('game.vs_bot') })}
<h2 style="font-size:20px;font-weight:700;color:var(--text-primary);margin:0;">${t('domino.select_bot')}</h2>
<p style="font-size:12px;color:var(--text-secondary);margin:8px 0 0;">${t('domino.bot_strategy')}</p> <section class="dm-section">
</div> <h2 class="section-title dm-section-title">${t('domino.select_bot')}</h2>
<div class="dm-buttons"> <div class="chips dm-chips" id="dm-level-grid">
${levels.map(l => ` ${levels.map(l => `
<button class="dm-level-btn" data-level="${l.key}" style="background:${l.bg};border:1px solid ${l.border};"> <button class="chip dm-level-btn ${l.key === botLevel ? 'active' : ''}" data-level="${l.key}" aria-pressed="${l.key === botLevel}">
<span class="dm-level-icon">${l.icon}</span> ${pips(l.pips)}<span>${l.label}</span>
<div class="dm-level-info"> </button>`).join('')}
<div style="font-size:15px;font-weight:700;color:${l.color};">${l.label}</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:2px;">${l.desc}</div>
</div> </div>
<span style="font-size:16px;color:var(--text-dim);">←</span> <p class="dm-hint" id="dm-level-hint" aria-live="polite">${levels.find(l => l.key === botLevel).desc}</p>
</button> </section>
`).join('')}
<section class="dm-section">
<h2 class="section-title dm-section-title">${t('domino.target')}</h2>
<div class="chips dm-chips" id="dm-target-grid">
${targets.map(x => `
<button class="chip dm-target-btn ${x.value === targetScore ? 'active' : ''}" data-target="${x.value}" aria-pressed="${x.value === targetScore}">
<span>${x.label}</span>
</button>`).join('')}
</div> </div>
<button class="dm-back" id="btn-back-bot">${t('common.back')}</button> <p class="dm-hint" id="dm-target-hint" aria-live="polite">${targets.find(x => x.value === targetScore).desc}</p>
</section>
<button class="btn btn-primary btn-lg btn-block dm-start" id="btn-start">${icon('play', 20)}${t('ludo.start_play')}</button>
</div> </div>
<style> ${STYLE}
.dm-level-btn {
display:flex;align-items:center;gap:14px;padding:16px 18px;
border-radius:14px;cursor:pointer;width:100%;text-align:right;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s;
}
.dm-level-btn:active { transform:scale(0.97); }
.dm-level-icon { font-size:30px;flex-shrink:0; }
.dm-level-info { flex:1; }
</style>
`; `;
headBack(el, 'btn-back-bot');
const choose = (selector, btn) => {
el.querySelectorAll(selector).forEach(b => { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
};
el.querySelectorAll('.dm-level-btn').forEach(btn => { el.querySelectorAll('.dm-level-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
renderTargetPicker(el, btn.dataset.level); choose('.dm-level-btn', btn);
botLevel = btn.dataset.level;
el.querySelector('#dm-level-hint').textContent = levels.find(l => l.key === botLevel).desc;
}); });
}); });
el.querySelector('#btn-back-bot').addEventListener('click', () => { el.querySelectorAll('.dm-target-btn').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
scene.replace('domino-room', { mode: 'menu' }); choose('.dm-target-btn', btn);
targetScore = parseInt(btn.dataset.target);
el.querySelector('#dm-target-hint').textContent = targets.find(x => x.value === targetScore).desc;
});
}); });
}
function renderTargetPicker(el, botLevel) {
const targets = [
{ value: 50, label: t('domino.50_points'), desc: t('domino.50_desc'), icon: emoji('lightning', '⚡', 30), color: 'var(--green-light)', bg: 'rgba(74,222,128,0.08)', border: 'rgba(74,222,128,0.2)' },
{ value: 100, label: t('domino.100_points'), desc: t('domino.100_desc'), icon: emoji('target', '🎯', 30), color: 'var(--amber)', bg: 'rgba(251,191,36,0.08)', border: 'rgba(251,191,36,0.2)' },
{ value: 150, label: t('domino.150_points'), desc: t('domino.150_desc'), icon: emoji('fire', '🔥', 30), color: 'var(--red-light)', bg: 'rgba(248,113,113,0.08)', border: 'rgba(248,113,113,0.2)' }
];
el.innerHTML = `
<div class="domino-menu">
<div class="dm-hero">
<h2 style="font-size:20px;font-weight:700;color:var(--text-primary);margin:0;">${t('domino.select_target')}</h2>
<p style="font-size:12px;color:var(--text-secondary);margin:8px 0 0;">${t('domino.target_hint')}</p>
</div>
<div class="dm-buttons">
${targets.map(t => `
<button class="dm-level-btn" data-target="${t.value}" style="background:${t.bg};border:1px solid ${t.border};">
<span class="dm-level-icon">${t.icon}</span>
<div class="dm-level-info">
<div style="font-size:15px;font-weight:700;color:${t.color};">${t.label}</div>
<div style="font-size:12px;color:var(--text-secondary);margin-top:2px;">${t.desc}</div>
</div>
<span style="font-size:16px;color:var(--text-dim);">←</span>
</button>
`).join('')}
</div>
<button class="dm-back" id="btn-back-target">${t('common.back')}</button>
</div>
`;
el.querySelectorAll('.dm-level-btn').forEach(btn => { el.querySelector('#btn-start').addEventListener('click', () => {
btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
const targetScore = parseInt(btn.dataset.target);
scene.enterGameMode(); scene.enterGameMode();
scene.replace('domino-game', { mode: 'bot', botLevel, targetScore }); scene.replace('domino-game', { mode: 'bot', botLevel, targetScore });
}); });
});
el.querySelector('#btn-back-target').addEventListener('click', () => { el.querySelector('#btn-back-bot').addEventListener('click', () => {
audio.play('click'); audio.play('click');
renderBotPicker(el); scene.replace('domino-room', { mode: 'menu' });
}); });
} }
function renderLobby(el, { challengeId, friendId, friendName }) { function renderLobby(el, { challengeId, friendId, friendName }) {
const isHost = !challengeId; const isHost = !challengeId;
el.innerHTML = ` el.innerHTML = `
<div class="domino-menu"> <div class="screen domino-menu dm-lobby">
<div class="dm-hero"> ${screenHead(t('domino.title'), { back: false })}
<div style="font-size:44px;margin-bottom:12px;">${emoji('domino_tile', '🁣', 44)}</div> ${gameCard(true)}
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0;"> <div class="dm-lobby-text">
<h2 class="dm-lobby-title">
${isHost ? t('room.waiting_friend') : t('room.challenge_from', { name: friendName || t('common.friend') })} ${isHost ? t('room.waiting_friend') : t('room.challenge_from', { name: friendName || t('common.friend') })}
</h2> </h2>
<div id="lobby-status" class="dm-lobby-status">
<div class="dm-lobby-msg" id="lobby-msg" role="status">${isHost ? t('room.send_invite') : t('room.tap_accept')}</div>
</div> </div>
<div id="lobby-status" style="display:flex;flex-direction:column;align-items:center;gap:12px;">
<div style="width:64px;height:64px;border-radius:50%;border:3px solid rgba(228,172,56,0.4);display:flex;align-items:center;justify-content:center;animation:dmPulseRing 2s ease-in-out infinite;">
${emoji('clock', '⏳', 26)}
</div>
<div style="font-size:13px;color:var(--gold);" id="lobby-msg">${isHost ? t('room.send_invite') : t('room.tap_accept')}</div>
</div> </div>
<div style="display:flex;gap:10px;margin-top:20px;"> <div class="dm-lobby-actions">
${!isHost ? `<button class="dm-btn dm-btn-online" id="btn-accept" style="min-height:48px;padding:0 28px;font-size:15px;">${t('social.accept')}</button>` : ''} ${!isHost ? `<button class="btn btn-primary btn-lg btn-block" id="btn-accept">${icon('check', 20)}${t('social.accept')}</button>` : ''}
<button class="dm-btn dm-btn-friend" id="btn-cancel-lobby" style="min-height:48px;padding:0 20px;font-size:14px;border-color:rgba(239,68,68,0.3);color:var(--red-soft);">${t('common.cancel')}</button> <button class="btn btn-secondary btn-block" id="btn-cancel-lobby">${icon('x', 18)}${t('common.cancel')}</button>
</div> </div>
</div> </div>
<style> ${STYLE}
@keyframes dmPulseRing { 0%,100%{border-color:rgba(228,172,56,0.4);transform:scale(1)} 50%{border-color:rgba(228,172,56,0.7);transform:scale(1.05)} }
</style>
`; `;
if (isHost) { if (isHost) {
...@@ -285,3 +263,45 @@ function cleanupLobby() { ...@@ -285,3 +263,45 @@ function cleanupLobby() {
export function unmountRoom() { export function unmountRoom() {
cleanupLobby(); cleanupLobby();
} }
const STYLE = `<style>
.domino-menu { display: flex; flex-direction: column; }
.dm-hero { display: flex; justify-content: center; margin: var(--s-2) 0 var(--s-6); }
.dm-game-card {
width: 104px; height: 146px;
display: flex; align-items: center; justify-content: center;
color: var(--suit);
}
.dm-game-card .index { font-size: 12px; }
.dm-game-mark { display: flex; }
.dm-game-card.face-down .dm-game-mark { color: var(--card); }
.dm-buttons { display: flex; flex-direction: column; gap: var(--s-3); }
.dm-mode {
justify-content: flex-start;
gap: var(--s-3);
min-height: 64px;
padding: var(--s-3) var(--s-4);
text-align: start;
}
.dm-btn-icon { display: flex; flex-shrink: 0; color: var(--ink); }
.dm-btn-icon.dm-live { color: var(--red-ink); }
.dm-btn-label { flex: 1; min-width: 0; font-size: var(--fs-md); font-weight: 800; color: var(--ink); }
.dm-chev { color: var(--ink-3); }
.dm-section:first-of-type .dm-section-title { margin-top: var(--s-2); }
.dm-chips { flex-wrap: wrap; overflow: visible; }
.dm-chips .chip { min-height: 44px; }
.dm-pips { display: inline-flex; gap: 2px; opacity: 0.8; }
.dm-chips .chip.active .dm-pips { color: var(--red-ink); opacity: 1; }
.dm-hint { margin-top: var(--s-2); font-size: var(--fs-sm); color: var(--text-muted); min-height: 1.5em; }
.dm-start { margin-top: var(--s-8); }
.dm-lobby { min-height: 100%; }
.dm-lobby-text { text-align: center; }
.dm-lobby-title { font-size: var(--fs-lg); font-weight: 800; color: var(--text-primary); text-wrap: balance; }
.dm-lobby-status { margin-top: var(--s-2); }
.dm-lobby-msg { font-size: var(--fs-sm); color: var(--text-secondary); }
.dm-lobby-actions { display: flex; flex-direction: column; gap: var(--s-3); margin-top: auto; padding-top: var(--s-8); }
</style>`;
...@@ -3,7 +3,8 @@ import * as audio from '../../../core/audio.js'; ...@@ -3,7 +3,8 @@ import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js'; import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead } from '../../../core/ui.js';
export function mountRoom(el, params) { export function mountRoom(el, params) {
const { mode = 'menu' } = params || {}; const { mode = 'menu' } = params || {};
...@@ -17,37 +18,49 @@ export function mountRoom(el, params) { ...@@ -17,37 +18,49 @@ export function mountRoom(el, params) {
} }
} }
/** Give the header's back button the id the handlers below were written for. */
function headBack(el, id) {
const b = el.querySelector('[data-back]');
if (b) b.id = id;
return b;
}
/** A mode row: icon on the reading side, label and line, chevron at the end. */
function modeRow(id, ic, label, desc, live = false) {
return `
<button class="btn btn-card btn-block lr-btn lr-mode" id="${id}">
<span class="lr-btn-icon ${live ? 'lr-live' : ''}">${icon(ic, 24)}</span>
<span class="lr-mode-text">
<span class="lr-btn-label">${label}</span>
<span class="lr-btn-desc">${desc}</span>
</span>
${icon('chevron-end', 20, { cls: 'lr-chev' })}
</button>`;
}
function renderMenu(el) { function renderMenu(el) {
const mark = GAME_MARK.ludo.icon;
el.innerHTML = ` el.innerHTML = `
<div class="lr-wrap"> <div class="screen lr-wrap">
<div class="lr-hero"> ${screenHead(t('ludo.title'), { sub: t('ludo.subtitle') })}
<div class="lr-icon">${emoji('dice', '🎲', 56)}</div>
<h1 class="lr-title">${t('ludo.title')}</h1> <div class="lr-hero" aria-hidden="true">
<p class="lr-subtitle">${t('ludo.subtitle')}</p> <div class="deck-card ${GAME_MARK.ludo.ink === 'red' ? 'ink-red' : ''} lr-game-card dealt" style="--deal-rot:-5deg;">
<span class="index top">${icon(mark, 14)}</span>
<span class="lr-game-mark">${icon(mark, 52)}</span>
<span class="index bottom">${icon(mark, 14)}</span>
</div>
</div> </div>
<div class="lr-buttons"> <div class="lr-buttons">
<button class="lr-btn lr-btn-primary" id="btn-local"> ${modeRow('btn-local', 'users', t('ludo.local_play'), t('ludo.local_desc'))}
<span class="lr-btn-icon">${emoji('gamepad', '🎮', 22)}</span> ${modeRow('btn-online', 'globe', t('game.online'), t('ludo.online_desc'), true)}
<span class="lr-btn-label">${t('ludo.local_play')}</span> ${modeRow('btn-friend', 'swords', t('game.vs_friend'), t('ludo.friend_desc'))}
<span class="lr-btn-desc">${t('ludo.local_desc')}</span>
</button>
<button class="lr-btn lr-btn-online" id="btn-online">
<span class="lr-btn-icon">${emoji('globe', '🌍', 22)}</span>
<span class="lr-btn-label">${t('game.online')}</span>
<span class="lr-btn-desc">${t('ludo.online_desc')}</span>
</button>
<button class="lr-btn lr-btn-friend" id="btn-friend">
<span class="lr-btn-icon">${emoji('handshake', '🤝', 20)}</span>
<span class="lr-btn-label">${t('game.vs_friend')}</span>
<span class="lr-btn-desc">${t('ludo.friend_desc')}</span>
</button>
</div> </div>
<button class="lr-back" id="btn-back">${t('common.back')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
headBack(el, 'btn-back');
el.querySelector('#btn-local').addEventListener('click', () => { el.querySelector('#btn-local').addEventListener('click', () => {
audio.play('click'); audio.play('click');
...@@ -78,91 +91,95 @@ function renderMenu(el) { ...@@ -78,91 +91,95 @@ function renderMenu(el) {
}); });
} }
/** Strength printed as pips: one for easy, three for hard. */
function pips(n) {
return `<span class="lr-pips" aria-hidden="true">${icon('suit-diamond', 9).repeat(n)}</span>`;
}
function renderSetup(el, params) { function renderSetup(el, params) {
const { type = 'local' } = params; const { type = 'local' } = params;
const isOnline = type === 'online'; const isOnline = type === 'online';
el.innerHTML = ` el.innerHTML = `
<div class="lr-wrap"> <div class="screen lr-wrap">
<div class="lr-hero" style="margin-bottom:16px;"> ${screenHead(t('ludo.title'), { sub: isOnline ? t('ludo.settings_online_hint') : t('ludo.settings_play_hint') })}
<h2 class="lr-title" style="font-size:20px;">${isOnline ? t('ludo.settings_online') : t('ludo.settings_play')}</h2>
<p class="lr-subtitle">${isOnline ? t('ludo.settings_online_hint') : t('ludo.settings_play_hint')}</p>
</div>
<!-- Player Count --> <!-- Player Count -->
<div class="lr-section"> <section class="lr-section">
<div class="lr-section-title">${t('ludo.player_count')}</div> <h2 class="section-title lr-section-title">${t('ludo.player_count')}</h2>
<div class="lr-grid" id="player-count-grid"> <div class="chips lr-grid" id="player-count-grid">
<button class="lr-chip lr-chip-active" data-count="4"> <button class="chip lr-chip lr-chip-active" data-count="4" aria-pressed="true">
<span class="lr-chip-num">4</span> <span class="num lr-chip-num">4</span>
<span class="lr-chip-label">${t('ludo.classic')}</span> <span class="lr-chip-label">${t('ludo.classic')}</span>
</button> </button>
<button class="lr-chip" data-count="3"> <button class="chip lr-chip" data-count="3" aria-pressed="false">
<span class="lr-chip-num">3</span> <span class="num lr-chip-num">3</span>
<span class="lr-chip-label">${t('ludo.triple')}</span> <span class="lr-chip-label">${t('ludo.triple')}</span>
</button> </button>
<button class="lr-chip" data-count="2"> <button class="chip lr-chip" data-count="2" aria-pressed="false">
<span class="lr-chip-num">2</span> <span class="num lr-chip-num">2</span>
<span class="lr-chip-label">${t('ludo.duel')}</span> <span class="lr-chip-label">${t('ludo.duel')}</span>
</button> </button>
</div> </div>
</div> </section>
<!-- Bot/Human Distribution (local only) --> <!-- Bot/Human Distribution (local only) -->
${!isOnline ? ` ${!isOnline ? `
<div class="lr-section" id="distribution-section"> <section class="lr-section" id="distribution-section">
<div class="lr-section-title">${t('ludo.player_layout')}</div> <h2 class="section-title lr-section-title">${t('ludo.player_layout')}</h2>
<div id="dist-options"></div> <div id="dist-options"></div>
</div> </section>
` : ''} ` : ''}
${!isOnline ? ` ${!isOnline ? `
<!-- Bot Difficulty --> <!-- Bot Difficulty -->
<div class="lr-section" id="difficulty-section"> <section class="lr-section" id="difficulty-section">
<div class="lr-section-title">${t('ludo.bot_level')}</div> <h2 class="section-title lr-section-title">${t('ludo.bot_level')}</h2>
<div class="lr-grid" id="difficulty-grid"> <div class="chips lr-grid" id="difficulty-grid">
<button class="lr-chip" data-diff="easy"> <button class="chip lr-chip" data-diff="easy" aria-pressed="false">
<span>${emoji('bot_easy', '😊', 18)}</span> ${pips(1)}<span class="lr-chip-label">${t('ludo.easy')}</span>
<span class="lr-chip-label">${t('ludo.easy')}</span>
</button> </button>
<button class="lr-chip lr-chip-active" data-diff="medium"> <button class="chip lr-chip lr-chip-active" data-diff="medium" aria-pressed="true">
<span>${emoji('bot_medium', '🧐', 18)}</span> ${pips(2)}<span class="lr-chip-label">${t('ludo.medium')}</span>
<span class="lr-chip-label">${t('ludo.medium')}</span>
</button> </button>
<button class="lr-chip" data-diff="hard"> <button class="chip lr-chip" data-diff="hard" aria-pressed="false">
<span>${emoji('bot_hard', '🧠', 18)}</span> ${pips(3)}<span class="lr-chip-label">${t('ludo.hard')}</span>
<span class="lr-chip-label">${t('ludo.hard')}</span>
</button> </button>
</div> </div>
</div> </section>
` : ''} ` : ''}
<!-- Seating Preview --> <!-- Seating Preview -->
<div class="lr-section"> <section class="lr-section">
<div class="lr-section-title">${t('ludo.seat_order')}</div> <h2 class="section-title lr-section-title">${t('ludo.seat_order')}</h2>
<div id="seat-preview" class="lr-seat-preview"></div> <div id="seat-preview" class="lr-seat-preview"></div>
</div> </section>
<!-- Start Button --> <!-- Start Button -->
<button class="lr-btn lr-btn-start" id="btn-start"> <button class="btn btn-primary btn-lg btn-block lr-start" id="btn-start">
${isOnline ? t('ludo.search_match') : t('ludo.start_play')} ${icon(isOnline ? 'search' : 'play', 20)}${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
</button> </button>
<button class="lr-back" id="btn-back-setup">${t('common.back')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
headBack(el, 'btn-back-setup');
let playerCount = 4; let playerCount = 4;
let humanCount = 1; let humanCount = 1;
let difficulty = 'medium'; let difficulty = 'medium';
/** Move the printed (chosen) chip within one group. */
const choose = (group, btn) => {
group.forEach(b => { b.classList.remove('lr-chip-active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('lr-chip-active');
btn.setAttribute('aria-pressed', 'true');
};
// Player count selection // Player count selection
el.querySelectorAll('#player-count-grid .lr-chip').forEach(btn => { el.querySelectorAll('#player-count-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
el.querySelectorAll('#player-count-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active')); choose(el.querySelectorAll('#player-count-grid .lr-chip'), btn);
btn.classList.add('lr-chip-active');
playerCount = parseInt(btn.dataset.count); playerCount = parseInt(btn.dataset.count);
if (!isOnline) updateDistribution(); if (!isOnline) updateDistribution();
updateSeatPreview(); updateSeatPreview();
...@@ -174,8 +191,7 @@ function renderSetup(el, params) { ...@@ -174,8 +191,7 @@ function renderSetup(el, params) {
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(btn => { el.querySelectorAll('#difficulty-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active')); choose(el.querySelectorAll('#difficulty-grid .lr-chip'), btn);
btn.classList.add('lr-chip-active');
difficulty = btn.dataset.diff; difficulty = btn.dataset.diff;
}); });
}); });
...@@ -196,8 +212,8 @@ function renderSetup(el, params) { ...@@ -196,8 +212,8 @@ function renderSetup(el, params) {
options.push({ humans: h, bots, label }); options.push({ humans: h, bots, label });
} }
container.innerHTML = `<div class="lr-grid">${options.map((opt, i) => ` container.innerHTML = `<div class="chips lr-grid">${options.map((opt, i) => `
<button class="lr-chip ${i === 0 ? 'lr-chip-active' : ''}" data-humans="${opt.humans}"> <button class="chip lr-chip ${i === 0 ? 'lr-chip-active' : ''}" data-humans="${opt.humans}" aria-pressed="${i === 0}">
<span class="lr-chip-label">${opt.label}</span> <span class="lr-chip-label">${opt.label}</span>
</button> </button>
`).join('')}</div>`; `).join('')}</div>`;
...@@ -206,8 +222,7 @@ function renderSetup(el, params) { ...@@ -206,8 +222,7 @@ function renderSetup(el, params) {
container.querySelectorAll('.lr-chip').forEach(btn => { container.querySelectorAll('.lr-chip').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
audio.play('click'); audio.play('click');
container.querySelectorAll('.lr-chip').forEach(b => b.classList.remove('lr-chip-active')); choose(container.querySelectorAll('.lr-chip'), btn);
btn.classList.add('lr-chip-active');
humanCount = parseInt(btn.dataset.humans); humanCount = parseInt(btn.dataset.humans);
updateSeatPreview(); updateSeatPreview();
// Show/hide difficulty based on whether bots exist // Show/hide difficulty based on whether bots exist
...@@ -224,7 +239,8 @@ function renderSetup(el, params) { ...@@ -224,7 +239,8 @@ function renderSetup(el, params) {
if (!preview) return; if (!preview) return;
const seats = getSeatPositions(playerCount); const seats = getSeatPositions(playerCount);
const colors = ['#E53935', '#43A047', '#FDD835', '#1E88E5']; // The four player inks, in seat order: red, green, yellow, blue.
const inks = ['var(--ludo-red)', 'var(--ludo-green)', 'var(--ludo-yellow)', 'var(--ludo-blue)'];
const labels = [t('ludo.red'), t('ludo.green'), t('ludo.yellow'), t('ludo.blue')]; const labels = [t('ludo.red'), t('ludo.green'), t('ludo.yellow'), t('ludo.blue')];
const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right']; const positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right'];
...@@ -234,14 +250,18 @@ function renderSetup(el, params) { ...@@ -234,14 +250,18 @@ function renderSetup(el, params) {
const active = seats.includes(i); const active = seats.includes(i);
const isHuman = active && seats.indexOf(i) < humanCount; const isHuman = active && seats.indexOf(i) < humanCount;
const isBot = active && !isHuman; const isBot = active && !isHuman;
const who = isHuman ? t('common.player') : isBot ? t('game.bot') : t('ludo.seat_empty');
return ` return `
<div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${colors[i]};"> <div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${inks[i]};">
<div class="lr-seat-dot">${isHuman ? emoji('person', '👤', 14) : isBot ? emoji('robot', '🤖', 14) : ''}</div> ${active ? `
<div class="lr-seat-label">${active ? labels[i] : '—'}</div> <span class="lr-seat-token">${icon('game-ludo', 26)}</span>
<span class="lr-seat-label">${labels[i]}</span>
<span class="lr-seat-dot">${icon(isHuman ? 'user' : 'bot', 14)}<span>${who}</span></span>
` : `<span class="lr-seat-label">${who}</span>`}
</div> </div>
`; `;
}).join('')} }).join('')}
<div class="lr-board-center">${emoji('dice', '🎲', 24)}</div> <div class="lr-board-center" aria-hidden="true">${icon('dice', 22)}</div>
</div> </div>
`; `;
} }
...@@ -285,15 +305,15 @@ function getSeatPositions(playerCount) { ...@@ -285,15 +305,15 @@ function getSeatPositions(playerCount) {
function renderSearching(el, params) { function renderSearching(el, params) {
el.innerHTML = ` el.innerHTML = `
<div class="lr-wrap"> <div class="screen lr-wrap lr-searching">
<div class="lr-hero"> <div class="lr-hero">
<div class="lr-pulse-ring"> <div class="lr-pulse-ring" aria-hidden="true">
<div class="lr-pulse-inner">${emoji('dice', '🎲', 32)}</div> ${[0, 1, 2].map(i => `<span class="deck-back lr-pulse-inner" style="--q:${i};"></span>`).join('')}
</div> </div>
<h2 class="lr-title" style="font-size:18px;margin-top:20px;">${t('ludo.searching')}</h2> <h2 class="lr-title" role="status">${t('ludo.searching')}</h2>
<p class="lr-subtitle">${t('ludo.searching_hint')}</p> <p class="lr-subtitle">${t('ludo.searching_hint')}</p>
</div> </div>
<button class="lr-btn lr-btn-friend" id="btn-cancel" style="max-width:200px;">${t('common.cancel')}</button> <button class="btn btn-secondary btn-lg btn-block lr-btn-friend" id="btn-cancel">${icon('x', 18)}${t('common.cancel')}</button>
</div> </div>
${getStyles()} ${getStyles()}
`; `;
...@@ -306,87 +326,114 @@ function renderSearching(el, params) { ...@@ -306,87 +326,114 @@ function renderSearching(el, params) {
function getStyles() { function getStyles() {
return `<style> return `<style>
.lr-wrap { .lr-wrap { display: flex; flex-direction: column; }
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:20px; /* The game's card, lying on the table under the header. */
background:linear-gradient(180deg, #070d14 0%, var(--bg-inset) 40%, var(--bg-inset) 100%); .lr-hero { display: flex; justify-content: center; margin: var(--s-2) 0 var(--s-6); }
overflow-y:auto; .lr-game-card {
width: 104px; height: 146px;
display: flex; align-items: center; justify-content: center;
color: var(--suit);
} }
.lr-hero { text-align:center;margin-bottom:24px; } .lr-game-card .index { font-size: 12px; }
.lr-icon { font-size:56px;margin-bottom:12px;animation:lrFloat 3s ease-in-out infinite; } .lr-game-mark { display: flex; }
.lr-title { font-size:26px;font-weight:800;color:var(--text-primary);margin:0; }
.lr-subtitle { font-size:13px;color:var(--gold);margin:8px 0 0;opacity:0.9; } /* Mode rows: printed card faces, icon on the reading side. */
.lr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:320px; } .lr-buttons { display: flex; flex-direction: column; gap: var(--s-3); }
.lr-btn { .lr-mode {
display:flex;align-items:center;gap:10px;flex-wrap:wrap; justify-content: flex-start;
min-height:56px;border-radius:16px;font-size:16px;font-weight:700; gap: var(--s-3);
border:none;cursor:pointer;padding:14px 18px; min-height: 72px;
transition:transform 0.15s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s; padding: var(--s-3) var(--s-4);
box-shadow:0 4px 16px rgba(0,0,0,0.3); text-align: start;
} }
.lr-btn:active { transform:scale(0.95); } .lr-btn-icon { display: flex; flex-shrink: 0; color: var(--ink); }
.lr-btn-primary { background:linear-gradient(135deg,var(--gold) 0%,var(--gold-dark) 100%);color:#1a1a1a; } .lr-btn-icon.lr-live { color: var(--red-ink); }
.lr-btn-online { background:linear-gradient(135deg,var(--purple) 0%,var(--violet) 100%);color:#fff; } .lr-mode-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.lr-btn-friend { background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.1);color:var(--text-light);min-height:50px;font-size:14px;box-shadow:none; } .lr-btn-label { font-size: var(--fs-md); font-weight: 800; color: var(--ink); }
.lr-btn-start { width:100%;max-width:320px;justify-content:center;background:linear-gradient(135deg,var(--emerald),var(--cyan));color:#fff;font-size:17px;font-weight:800;margin-top:16px; } .lr-btn-desc { font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.lr-btn-icon { display:flex;flex-shrink:0; } .lr-chev { color: var(--ink-3); }
.lr-btn-label { font-weight:700;font-size:16px; }
.lr-btn-desc { width:100%;font-size:11px;opacity:0.7;font-weight:400;margin-top:2px; } /* Option groups. Chips wrap so every choice stays in view. */
.lr-back { margin-top:16px;font-size:13px;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:8px 16px; } .lr-section:first-of-type .lr-section-title { margin-top: var(--s-2); }
.lr-grid { flex-wrap: wrap; overflow: visible; }
.lr-section { width:100%;max-width:320px;margin-bottom:16px; } .lr-chip { min-height: 44px; }
.lr-section-title { font-size:12px;font-weight:700;color:var(--text-secondary);margin-bottom:8px;text-align:center; } .lr-chip.lr-chip-active { background: var(--card); color: var(--ink); border-color: var(--card); }
.lr-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; } .lr-chip-num { font-size: var(--fs-md); font-weight: 800; }
.lr-chip { .lr-chip-label { font-size: var(--fs-sm); }
display:flex;align-items:center;gap:6px; .lr-pips { display: inline-flex; gap: 2px; color: currentColor; opacity: 0.8; }
padding:10px 16px;border-radius:12px; .lr-chip-active .lr-pips { color: var(--red-ink); opacity: 1; }
background:rgba(255,255,255,0.04);border:1.5px solid rgba(255,255,255,0.08);
color:var(--text-secondary);font-size:13px;font-weight:600;cursor:pointer; /* Seats: four card-stock squares placed as on the board (which does not
transition:all 0.2s cubic-bezier(0.34,1.56,0.64,1); mirror), each with its player's token printed in that player's ink. */
} .lr-seat-preview { display: flex; justify-content: center; }
.lr-chip:active { transform:scale(0.95); }
.lr-chip-active { background:rgba(228,172,56,0.1);border-color:rgba(228,172,56,0.4);color:var(--gold); }
.lr-chip-num { font-size:18px;font-weight:800; }
.lr-chip-label { font-size:12px; }
.lr-seat-preview { display:flex;justify-content:center;margin-top:8px; }
.lr-board-mini { .lr-board-mini {
position:relative;width:140px;height:140px; position: relative;
background:linear-gradient(135deg,#1a2844,#0f1a30); direction: ltr;
border-radius:12px;border:1px solid var(--border); display: grid;
grid-template-columns: 1fr 1fr;
grid-template-areas: "tl tr" "bl br";
gap: var(--s-2);
width: 100%;
max-width: 340px;
} }
.lr-board-center { position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);font-size:24px; } .lr-seat-top-left { grid-area: tl; }
.lr-seat-top-right { grid-area: tr; }
.lr-seat-bottom-left { grid-area: bl; }
.lr-seat-bottom-right { grid-area: br; }
.lr-seat { .lr-seat {
position:absolute;display:flex;flex-direction:column;align-items:center;gap:2px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1); min-height: 92px;
padding: var(--s-2);
border-radius: var(--r-lg);
text-align: center;
transition: background var(--dur-normal) var(--ease-out);
} }
.lr-seat-bottom-left { bottom:8px;left:8px; } .lr-seat-active {
.lr-seat-top-left { top:8px;left:8px; } background: var(--card);
.lr-seat-top-right { top:8px;right:8px; } color: var(--ink);
.lr-seat-bottom-right { bottom:8px;right:8px; } box-shadow: var(--shadow-sm);
.lr-seat-dot { outline: 1px solid var(--card-line);
width:28px;height:28px;border-radius:50%; outline-offset: -5px;
display:flex;align-items:center;justify-content:center;font-size:14px;
border:2px solid var(--seat-color);
background:rgba(0,0,0,0.3);
transition:all 0.3s;
} }
.lr-seat-active .lr-seat-dot { background:color-mix(in srgb, var(--seat-color) 20%, transparent); } .lr-seat-empty {
.lr-seat-empty .lr-seat-dot { opacity:0.2;border-style:dashed; } background-color: var(--back-700);
.lr-seat-label { font-size:var(--fs-xs);color:var(--text-muted); } background-image: var(--back-pattern-dense);
.lr-seat-active .lr-seat-label { color:var(--seat-color); } background-size: 14px 14px;
color: var(--text-muted);
.lr-pulse-ring { }
width:80px;height:80px;border-radius:50%; .lr-seat-token { display: flex; color: var(--seat-color); }
border:3px solid rgba(228,172,56,0.4); .lr-seat-label { font-size: var(--fs-sm); font-weight: 800; color: inherit; }
display:flex;align-items:center;justify-content:center; .lr-seat-dot { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
animation:lrPulse 2s ease-in-out infinite; .lr-board-center {
margin:0 auto; position: absolute; inset: 0; margin: auto;
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
border-radius: 9px;
background: var(--back-950);
color: var(--text-primary);
box-shadow: 0 0 0 4px var(--back-900);
} }
.lr-pulse-inner { font-size:32px; }
@keyframes lrFloat { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-6px)} } .lr-start { margin-top: var(--s-8); }
@keyframes lrPulse { 0%,100%{border-color:rgba(228,172,56,0.4);transform:scale(1)} 50%{border-color:rgba(228,172,56,0.7);transform:scale(1.05)} }
/* Searching: a short stack of cards shuffling. */
.lr-searching { min-height: 100%; justify-content: center; text-align: center; gap: var(--s-6); }
.lr-searching .lr-hero { flex-direction: column; align-items: center; margin: 0; }
.lr-pulse-ring { display: grid; place-items: center; height: 120px; margin-bottom: var(--s-5); }
.lr-pulse-inner {
grid-area: 1 / 1; width: 64px; height: 90px; border-radius: 8px; box-shadow: var(--shadow-md);
transform: rotate(calc((var(--q) - 1) * 6deg));
animation: lrShuffle 2.8s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 90ms);
}
@keyframes lrShuffle {
0%, 20%, 80%, 100% { transform: rotate(calc((var(--q) - 1) * 6deg)); }
45%, 55% { transform: translate(calc((var(--q) - 1) * 34px), -8px) rotate(calc((var(--q) - 1) * 10deg)); }
}
@media (prefers-reduced-motion: reduce) { .lr-pulse-inner { animation: none; } }
.lr-title { font-size: var(--fs-lg); font-weight: 800; color: var(--text-primary); }
.lr-subtitle { font-size: var(--fs-sm); color: var(--text-muted); margin-top: var(--s-1); }
</style>`; </style>`;
} }
......
// Choose a chess bot: the bots are the court cards of the deck.
//
// Each bot is a tall card printed in one of the two inks, alternating from the
// weakest to the strongest. Its rating band is the corner index, its initial is
// the face, and a one-line note says how it plays.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js'; import { t, getLang } from '../../../core/i18n.js';
import { STOCKFISH_URL } from '../../../core/config.js'; import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, errorState, esc } from '../../../core/ui.js';
const DIFFICULTY_COLORS = ['var(--success)', 'var(--success)', 'var(--amber)', 'var(--amber)', 'var(--orange)', 'var(--orange)', 'var(--error)'];
export async function mountBotSelect(el, params) { export async function mountBotSelect(el, params) {
el.innerHTML = ` el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);"> <div class="screen bs">
<div style="display:flex;align-items:center;gap:var(--s-3);"> ${screenHead(t('play.select_bot'), { sub: t('bots.sub') })}
<button class="btn btn-secondary" id="back-btn" style="width:var(--bot-back-btn-size);height:var(--bot-back-btn-size);padding:0;font-size:var(--bot-back-btn-font);">←</button> <div id="bots-grid" class="bs-grid" aria-busy="true" aria-label="${t('common.loading')}">
<h2 style="font-size:var(--bot-title-font);font-weight:700;">${t('play.select_bot')}</h2> ${Array.from({ length: 4 }, () => '<div class="deck-card face-down bs-card bs-placeholder"></div>').join('')}
</div>
<div id="bots-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:var(--bot-grid-gap);">
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
</div> </div>
</div> </div>
<style>${STYLE}</style>
`; `;
el.querySelector('#back-btn').addEventListener('click', () => { wireBack(el);
audio.play('click'); await loadBots(el, params);
scene.pop(); }
});
async function loadBots(el, params) {
try { try {
const data = await net.post('bots.php', { action: 'list' }); const data = await net.post('bots.php', { action: 'list' });
const bots = data.bots || data || []; const bots = data.bots || data || [];
renderBots(el, bots, params); renderBots(el, bots, params);
} catch (e) { } catch (e) {
el.querySelector('#bots-grid').innerHTML = `<p style="color:var(--text-secondary);grid-column:1/-1;text-align:center;">${t('common.error')}</p>`; const grid = el.querySelector('#bots-grid');
grid.removeAttribute('aria-busy');
grid.innerHTML = `<div class="bs-error">${errorState(t('common.error_load'), 'bs-retry')}</div>`;
grid.querySelector('#bs-retry')?.addEventListener('click', () => {
audio.play('click');
loadBots(el, params);
});
} }
} }
/** The bot's own name without its epithet ("أمينة المبتدئة" -> "أمينة"). */
function botName(bot, ar) {
const full = ar ? (bot.name_ar || bot.name || '') : (bot.name || bot.name_ar || '');
if (!ar) return full;
const words = full.trim().split(/\s+/);
return words.length > 1 && !words[0].startsWith('ال') ? words[0] : full;
}
function botStyle(bot, ar) {
if (ar && bot.style_ar) return bot.style_ar;
return String(bot.style || bot.style_ar || '').replace(/_/g, ' ');
}
/** The letter printed on the face: the first letter of the name, past any article. */
function botInitial(name) {
const n = name.trim().replace(/^ال(?=\S{2})/, '');
return n.charAt(0) || '';
}
/** A note written for each bot; a bot the app has not met yet keeps its own bio. */
function botNote(bot, ar) {
const key = 'bots.note_' + bot.id;
const note = t(key);
if (note !== key) return note;
return (ar ? bot.bio_ar || bot.bio : bot.bio || bot.bio_ar) || '';
}
function renderBots(el, bots, params) { function renderBots(el, bots, params) {
const ar = getLang() === 'ar';
const grid = el.querySelector('#bots-grid'); const grid = el.querySelector('#bots-grid');
const strongest = bots.reduce((top, b) => (Number(b.elo_max) > Number(top?.elo_max ?? -1) ? b : top), null);
grid.removeAttribute('aria-busy');
grid.removeAttribute('aria-label');
grid.innerHTML = bots.map((bot, idx) => { grid.innerHTML = bots.map((bot, idx) => {
const diffColor = DIFFICULTY_COLORS[Math.min(idx, DIFFICULTY_COLORS.length - 1)]; const name = botName(bot, ar);
const initial = (bot.name_ar || bot.name || '?')[0]; const style = botStyle(bot, ar);
const note = botNote(bot, ar);
const crowned = bot === strongest && bots.length > 1;
const index = `
<span class="num">${esc(bot.elo_min)}</span>
<span class="bs-to" aria-hidden="true"></span>
<span class="num">${esc(bot.elo_max)}</span>
${crowned ? `<span class="bs-crown">${icon('crown', 14)}</span>` : ''}`;
const label = [
ar ? (bot.name_ar || bot.name) : (bot.name || bot.name_ar),
style,
t('bots.rating_band', { min: bot.elo_min, max: bot.elo_max }),
crowned ? t('bots.strongest') : '',
].filter(Boolean).join('، ');
return ` return `
<div class="card bot-card" data-id="${bot.id}" style="cursor:pointer;padding:var(--s-3);position:relative;overflow:hidden;"> <button class="deck-card bs-card bot-card dealt ${idx % 2 === 0 ? 'ink-red' : ''}" data-id="${esc(bot.id)}"
<div style="position:absolute;top:0;left:0;right:0;height:var(--bot-card-accent-height);background:${diffColor};"></div> style="--deal-rot:${idx % 2 === 0 ? -5 : 5}deg;animation-delay:${idx * 55}ms;" aria-label="${esc(label)}">
<div style="width:var(--bot-card-avatar-size);height:var(--bot-card-avatar-size);border-radius:var(--r-full);background:var(--bg-elevated);margin:var(--s-2) auto var(--s-2);display:flex;align-items:center;justify-content:center;font-size:20px;overflow:hidden;border:var(--bot-card-avatar-border) solid ${diffColor};"> <span class="index top" aria-hidden="true">${index}</span>
${bot.portrait_url ? `<img src="${STOCKFISH_URL}${bot.portrait_url}" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.parentNode.querySelector('.bot-fallback').style.display='flex'"><span class="bot-fallback" style="display:none;font-size:22px;font-weight:800;color:${diffColor};">${initial}</span>` : `<span style="font-size:22px;font-weight:800;color:${diffColor};">${initial}</span>`} <span class="bs-face" aria-hidden="true">
</div> <span class="bs-initial">${esc(botInitial(name))}</span>
<div style="text-align:center;"> <span class="bs-name">${esc(name)}</span>
<div style="font-size:var(--bot-card-name-font);font-weight:700;">${bot.name_ar || bot.name}</div> <span class="bs-style">${esc(style)}</span>
<div style="font-size:var(--bot-card-sub-font);color:var(--text-secondary);margin-top:2px;">${bot.style_ar || bot.style}</div> ${note ? `<span class="bs-note">${esc(note)}</span>` : ''}
<div style="font-size:var(--bot-card-sub-font);color:${diffColor};font-weight:600;margin-top:4px;font-family:var(--font-lat);">${bot.elo_min}-${bot.elo_max}</div> </span>
</div> <span class="index bottom" aria-hidden="true">${index}</span>
</div> </button>
`;}).join(''); `;
}).join('');
grid.querySelectorAll('.bot-card').forEach(card => { grid.querySelectorAll('.bot-card').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
...@@ -63,3 +114,72 @@ function renderBots(el, bots, params) { ...@@ -63,3 +114,72 @@ function renderBots(el, bots, params) {
}); });
}); });
} }
const STYLE = `
.bs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-3);
}
/* The face is centred in the space above the bottom index: the initial is
narrow enough to rise between the top corners, the note is not. */
.bs-card {
aspect-ratio: 1 / 1.38;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px var(--s-3) 46px;
text-align: center;
}
/* An odd last card (the strongest, usually) sits centred under the pair. */
.bs-card:last-child:nth-child(odd) {
grid-column: 1 / -1;
justify-self: center;
width: calc(50% - var(--s-3) / 2);
}
.bs-placeholder { box-shadow: var(--shadow-sm); }
.bs-card .index { font-size: 12px; gap: 2px; }
.bs-card .index .num { font-size: 12px; letter-spacing: -0.01em; }
.bs-to { width: 10px; height: 1px; background: currentColor; opacity: 0.6; }
.bs-crown { display: flex; margin-top: 3px; color: var(--gold-ink); }
.bs-face {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.bs-initial {
font-size: clamp(40px, 12vw, 52px);
font-weight: 800;
line-height: 1.35;
color: var(--suit);
}
.bs-name {
font-size: var(--fs-md);
font-weight: 800;
line-height: 1.2;
color: var(--ink);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-style { font-size: var(--fs-xs); font-weight: 600; color: var(--suit); }
.bs-note {
margin-top: 6px;
font-size: var(--fs-xs);
line-height: 1.4;
color: var(--ink-3);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-wrap: balance;
}
.bs-error { grid-column: 1 / -1; }
`;
...@@ -4,43 +4,19 @@ import * as audio from '../../../core/audio.js'; ...@@ -4,43 +4,19 @@ import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, wireBack, emptyState, loadingRows, errorState, avatar, esc } from '../../../core/ui.js';
export function mountChallenge(el) { export function mountChallenge(el) {
el.innerHTML = ` el.innerHTML = `
<div class="cf-layout"> <div class="screen cf-layout">
<div class="cf-header"> ${screenHead(t('challenge.title'))}
<button id="cf-back" class="cf-back-btn">→</button> <div class="cf-body" id="cf-body">${loadingRows(5, 60)}</div>
<div class="cf-title">${emoji('challenge_swords', '⚔️', 18)} ${t('challenge.title')}</div>
</div> </div>
<div class="cf-body" id="cf-body"> <style>${STYLE}</style>
<div style="text-align:center;padding:32px;color:var(--text-muted);font-size:13px;">${emoji('loading', '⏳', 14)} ${t('challenge.loading_friends')}</div>
</div>
</div>
<style>
.cf-layout { display:flex;flex-direction:column;height:100%;background:var(--bg-deep); }
.cf-header { display:flex;align-items:center;gap:12px;padding:12px 16px;background:var(--bg-panel);border-bottom:1px solid var(--border); }
.cf-back-btn { background:none;border:none;color:var(--text-secondary);font-size:20px;cursor:pointer;padding:4px 8px;font-family:inherit; }
.cf-title { font-size:16px;font-weight:700;color:var(--text-primary); }
.cf-body { flex:1;overflow-y:auto;padding:16px; }
.cf-card { display:flex;align-items:center;gap:12px;padding:14px;background:var(--bg-card);border-radius:14px;margin-bottom:10px;cursor:pointer;transition:transform 0.1s,background 0.15s;border:1px solid var(--border); }
.cf-card:active { transform:scale(0.98);background:var(--bg-hover); }
.cf-card-avatar { width:48px;height:48px;border-radius:50%;background:var(--bg-hover);display:flex;align-items:center;justify-content:center;overflow:hidden;flex-shrink:0;position:relative; }
.cf-card-avatar img { width:100%;height:100%;object-fit:cover;border-radius:50%; }
.cf-card-online { position:absolute;bottom:1px;right:1px;width:12px;height:12px;border-radius:50%;background:var(--success);border:2px solid var(--bg-card); }
.cf-card-info { flex:1;min-width:0; }
.cf-card-name { font-size:14px;font-weight:600;color:var(--text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.cf-card-sub { font-size:11px;color:var(--text-muted);margin-top:2px; }
.cf-card-action { padding:8px 16px;border-radius:10px;background:linear-gradient(135deg,var(--violet),var(--purple-light));border:none;color:#fff;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:transform 0.1s; }
.cf-card-action:active { transform:scale(0.9); }
.cf-section-title { font-size:12px;font-weight:600;color:var(--text-muted);margin-bottom:8px;padding-right:4px; }
</style>
`; `;
el.querySelector('#cf-back').addEventListener('click', () => { wireBack(el);
audio.play('click');
scene.pop();
});
loadFriendsForChallenge(el); loadFriendsForChallenge(el);
} }
...@@ -53,13 +29,12 @@ async function loadFriendsForChallenge(el) { ...@@ -53,13 +29,12 @@ async function loadFriendsForChallenge(el) {
const friends = data.friends || []; const friends = data.friends || [];
if (friends.length === 0) { if (friends.length === 0) {
body.innerHTML = ` body.innerHTML = emptyState({
<div style="text-align:center;padding:48px 24px;"> mark: 'users',
<div style="font-size:48px;margin-bottom:12px;opacity:0.5;">${emoji('people', '👥', 48)}</div> title: t('challenge.no_friends'),
<div style="font-size:15px;font-weight:700;color:var(--text-primary);margin-bottom:6px;">${t('challenge.no_friends')}</div> sub: t('challenge.no_friends_hint'),
<div style="font-size:12px;color:var(--text-muted);margin-bottom:16px;">${t('challenge.no_friends_hint')}</div> action: `<button class="btn btn-primary" id="cf-go-social">${icon('search', 18)}${t('social.search_players')}</button>`,
<button class="btn btn-primary" id="cf-go-social" style="font-size:13px;padding:10px 24px;">${emoji('search_icon', '🔍', 13)} ${t('social.search_players')}</button> });
</div>`;
body.querySelector('#cf-go-social')?.addEventListener('click', () => scene.push('friends')); body.querySelector('#cf-go-social')?.addEventListener('click', () => scene.push('friends'));
return; return;
} }
...@@ -70,13 +45,13 @@ async function loadFriendsForChallenge(el) { ...@@ -70,13 +45,13 @@ async function loadFriendsForChallenge(el) {
let html = ''; let html = '';
if (online.length > 0) { if (online.length > 0) {
html += `<div class="cf-section-title">${emoji('green_circle', '🟢', 11)} ${t('challenge.online_now', { n: online.length })}</div>`; html += `<h2 class="section-title cf-section-title">${t('challenge.online_now', { n: `<span class="num">${online.length}</span>` })}</h2>`;
html += online.map(f => renderChallengeCard(f, true)).join(''); html += `<div class="rows">${online.map(f => renderChallengeCard(f, true)).join('')}</div>`;
} }
if (offline.length > 0) { if (offline.length > 0) {
html += `<div class="cf-section-title" style="margin-top:${online.length > 0 ? '16px' : '0'};">${emoji('gray_circle', '⚪', 11)} ${t('challenge.offline')}</div>`; html += `<h2 class="section-title cf-section-title">${t('challenge.offline')}</h2>`;
html += offline.map(f => renderChallengeCard(f, false)).join(''); html += `<div class="rows">${offline.map(f => renderChallengeCard(f, false)).join('')}</div>`;
} }
body.innerHTML = html; body.innerHTML = html;
...@@ -108,23 +83,26 @@ async function loadFriendsForChallenge(el) { ...@@ -108,23 +83,26 @@ async function loadFriendsForChallenge(el) {
}); });
} catch (e) { } catch (e) {
body.innerHTML = `<div style="text-align:center;color:var(--error);padding:24px;">${t('common.error_load')} — <button id="cf-retry" style="color:var(--blue);background:none;border:none;text-decoration:underline;cursor:pointer;font-family:inherit;">${t('social.retry_load')}</button></div>`; body.innerHTML = errorState(t('common.error_load'), 'cf-retry');
body.querySelector('#cf-retry')?.addEventListener('click', () => loadFriendsForChallenge(el)); body.querySelector('#cf-retry')?.addEventListener('click', () => loadFriendsForChallenge(el));
} }
} }
function renderChallengeCard(f, isOnline) { function renderChallengeCard(f, isOnline) {
const name = f.display_name || f.username || t('common.player');
return ` return `
<div class="cf-card" data-uid="${f.id}"> <div class="row cf-card" data-uid="${esc(f.id)}">
<div class="cf-card-avatar"> ${avatar({ ...f, display_name: name }, 44)}
${f.avatar_url ? `<img src="${f.avatar_url}">` : emoji('person', '👤', 22)} <div class="row-main">
${isOnline ? '<div class="cf-card-online"></div>' : ''} <div class="row-title cf-card-name">${esc(name)}</div>
<div class="row-sub cf-card-sub">
${icon('dot', 10, { cls: isOnline ? 'cf-dot-on' : 'cf-dot-off' })}
<span>${isOnline ? t('common.online') : t('common.offline')}</span>
${f.level ? `<span class="cf-sep" aria-hidden="true"></span><span>${t('common.level', { n: `<span class="num">${esc(f.level)}</span>` })}</span>` : ''}
</div> </div>
<div class="cf-card-info">
<div class="cf-card-name">${f.display_name || f.username || t('common.player')}</div>
<div class="cf-card-sub">${isOnline ? t('common.online') : t('common.offline')}${f.level ? ` • ${t('common.level', { n: f.level })}` : ''}</div>
</div> </div>
<button class="cf-card-action" data-challenge="${f.id}">${emoji('challenge_swords', '⚔️', 12)} ${t('challenge.title')}</button> <button class="btn ${isOnline ? 'btn-card' : 'btn-secondary'} cf-card-action" data-challenge="${esc(f.id)}"
aria-label="${esc(t('challenge.sheet_title', { name }))}">${icon('swords', 16)}${t('challenge.action')}</button>
</div> </div>
`; `;
} }
...@@ -135,38 +113,78 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -135,38 +113,78 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
const dialog = document.createElement('div'); const dialog = document.createElement('div');
dialog.id = 'cf-options-dialog'; dialog.id = 'cf-options-dialog';
dialog.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:999;display:flex;align-items:flex-end;justify-content:center;padding:0;'; dialog.className = 'cfo-scrim';
dialog.innerHTML = ` dialog.innerHTML = `
<div style="background:var(--bg-card);border-radius:20px 20px 0 0;padding:24px 20px;width:100%;max-width:400px;animation:slideUp 0.25s ease;"> <div class="cfo-sheet" role="dialog" aria-modal="true" aria-labelledby="cfo-title">
<div style="width:40px;height:4px;background:var(--border-hover);border-radius:2px;margin:0 auto 16px;"></div> <div class="cfo-grip" aria-hidden="true"></div>
<div style="text-align:center;margin-bottom:16px;"> <div class="cfo-head">
<div style="font-size:15px;font-weight:700;color:var(--text-primary);">${t('challenge.title')} ${targetName}</div> <div class="cfo-title" id="cfo-title">${t('challenge.sheet_title', { name: esc(targetName) })}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;">${t('challenge.select_game')}</div> <div class="cfo-sub">${t('challenge.select_game')}</div>
</div> </div>
<!-- Game selection --> <div class="cfo-label">${t('challenge.game')}</div>
<div style="display:flex;gap:8px;margin-bottom:14px;"> <div class="cfo-games">
<button class="cfo-game active" data-game="chess" style="flex:1;padding:12px;border-radius:12px;background:var(--chess-primary);border:2px solid var(--chess-primary);color:#fff;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">${emoji('chess_pawn', '♟', 14)} ${t('game.chess')}</button> ${['chess', 'ludo', 'domino'].map(g => `
<button class="cfo-game" data-game="ludo" style="flex:1;padding:12px;border-radius:12px;background:var(--bg-card);border:2px solid var(--border);color:var(--text-secondary);font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">${emoji('dice', '🎲', 14)} ${t('game.ludo')}</button> <button class="cfo-game ${g === 'chess' ? 'active' : ''}" data-game="${g}" aria-pressed="${g === 'chess'}">
<button class="cfo-game" data-game="domino" style="flex:1;padding:12px;border-radius:12px;background:var(--bg-card);border:2px solid var(--border);color:var(--text-secondary);font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s;">${emoji('domino_tile', '🁣', 14)} ${t('game.domino')}</button> ${icon(GAME_MARK[g].icon, 22, { cls: GAME_MARK[g].ink === 'red' ? 'cfo-red' : '' })}<span>${t('game.' + g)}</span>
</button>`).join('')}
</div> </div>
<!-- Time control --> <div id="cfo-time" class="cfo-times">
<div id="cfo-time" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:20px;"> <div class="cfo-label cfo-label-row">${t('challenge.time')}</div>
<button class="cfo-tc" data-tc="bullet_1_0">${emoji('lightning', '⚡', 12)} ${t('time.1min')}</button> ${[['bullet_1_0', 'zap', 'time.1min'], ['blitz_3_0', 'flame', 'time.3min'], ['blitz_5_0', 'flame', 'time.5min'], ['rapid_10_0', 'timer', 'time.10min']].map(([tc, ic, key]) => `
<button class="cfo-tc active" data-tc="blitz_3_0">${emoji('fire', '🔥', 12)} ${t('time.3min')}</button> <button class="cfo-tc ${tc === 'blitz_3_0' ? 'active' : ''}" data-tc="${tc}" aria-pressed="${tc === 'blitz_3_0'}">${icon(ic, 16)}<span>${t(key)}</span></button>`).join('')}
<button class="cfo-tc" data-tc="blitz_5_0">${emoji('wind', '💨', 12)} ${t('time.5min')}</button>
<button class="cfo-tc" data-tc="rapid_10_0">${emoji('clock', '🕐', 12)} ${t('time.10min')}</button>
</div> </div>
<button class="btn btn-primary" id="cfo-send" style="width:100%;min-height:50px;font-size:15px;font-weight:700;border-radius:14px;">${emoji('challenge_swords', '⚔️', 15)} ${t('challenge.send')}</button> <button class="btn btn-primary btn-lg btn-block" id="cfo-send">${icon('swords', 20)}${t('challenge.send')}</button>
<button id="cfo-cancel" style="width:100%;margin-top:10px;background:none;border:none;color:var(--text-muted);font-size:13px;cursor:pointer;font-family:inherit;padding:10px;">${t('common.cancel')}</button> <button class="btn btn-block cfo-ghost" id="cfo-cancel">${t('common.cancel')}</button>
</div> </div>
<style> <style>
@keyframes slideUp { from{transform:translateY(100%)}to{transform:none} } .cfo-scrim {
.cfo-tc { padding:10px 8px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid var(--border);color:var(--text-secondary);font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;transition:all 0.15s; } position: fixed; inset: 0; z-index: 999;
.cfo-tc.active { background:var(--gold);border-color:var(--gold);color:var(--bg-dark); } display: flex; align-items: flex-end; justify-content: center;
.cfo-tc:active { transform:scale(0.93); } background: rgba(5, 8, 30, 0.72);
animation: fadeIn var(--dur-normal) var(--ease-out);
}
/* The sheet is a card slid out from under the deck: card stock, ink text. */
.cfo-sheet {
width: 100%; max-width: 560px;
background: var(--card);
color: var(--ink);
border-radius: var(--sheet-radius, 18px) var(--sheet-radius, 18px) 0 0;
padding: var(--s-3) var(--s-5) calc(var(--s-5) + var(--safe-bottom));
box-shadow: var(--shadow-lg);
animation: slideUp var(--dur-slow) var(--ease-deal);
}
.cfo-grip { width: 40px; height: 4px; border-radius: 2px; background: var(--card-line); margin: 0 auto var(--s-4); }
.cfo-head { text-align: center; margin-bottom: var(--s-4); }
.cfo-title { font-size: var(--fs-lg); font-weight: 800; color: var(--ink); }
.cfo-sub { font-size: var(--fs-sm); color: var(--ink-3); margin-top: 2px; }
.cfo-label { font-size: var(--fs-sm); font-weight: 700; color: var(--ink-2); margin-bottom: var(--s-2); }
.cfo-label-row { grid-column: 1 / -1; margin-bottom: 0; }
.cfo-games { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--s-2); margin-bottom: var(--s-4); }
.cfo-times { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--s-2); margin-bottom: var(--s-5); }
.cfo-game, .cfo-tc {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
min-height: 64px; padding: var(--s-2);
border-radius: var(--r-md);
background: transparent;
border: 1px solid var(--card-line);
color: var(--ink-2);
font-family: inherit; font-size: var(--fs-sm); font-weight: 700;
cursor: pointer;
transition: background var(--dur-fast), color var(--dur-fast), border-color var(--dur-fast);
}
.cfo-tc { min-height: 56px; font-size: var(--fs-xs); }
.cfo-game .cfo-red { color: var(--red-ink); }
/* The chosen one is printed solid in black ink. */
.cfo-game.active, .cfo-tc.active { background: var(--ink); border-color: var(--ink); color: var(--card); }
.cfo-game.active .cfo-red { color: var(--card); }
.cfo-game:active, .cfo-tc:active { background: var(--card-2); }
.cfo-game.active:active, .cfo-tc.active:active { background: var(--ink-2); }
.cfo-ghost { margin-top: var(--s-2); background: transparent; color: var(--ink-2); }
.cfo-ghost:active { background: var(--card-2); }
</style> </style>
`; `;
...@@ -177,8 +195,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -177,8 +195,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelectorAll('.cfo-game').forEach(btn => { dialog.querySelectorAll('.cfo-game').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
dialog.querySelectorAll('.cfo-game').forEach(b => { b.style.background = 'var(--bg-card)'; b.style.borderColor = 'var(--border)'; b.style.color = 'var(--text-secondary)'; b.classList.remove('active'); }); dialog.querySelectorAll('.cfo-game').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.style.background = 'var(--chess-primary)'; btn.style.borderColor = 'var(--chess-primary)'; btn.style.color = '#fff'; btn.classList.add('active'); btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true');
selectedGame = btn.dataset.game; selectedGame = btn.dataset.game;
dialog.querySelector('#cfo-time').style.display = (selectedGame === 'ludo' || selectedGame === 'domino') ? 'none' : 'grid'; dialog.querySelector('#cfo-time').style.display = (selectedGame === 'ludo' || selectedGame === 'domino') ? 'none' : 'grid';
}); });
...@@ -186,8 +204,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -186,8 +204,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelectorAll('.cfo-tc').forEach(btn => { dialog.querySelectorAll('.cfo-tc').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
dialog.querySelectorAll('.cfo-tc').forEach(b => { b.classList.remove('active'); b.style.background = 'rgba(255,255,255,0.05)'; b.style.borderColor = 'var(--border)'; b.style.color = 'var(--text-secondary)'; }); dialog.querySelectorAll('.cfo-tc').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('active'); btn.style.background = 'var(--gold)'; btn.style.borderColor = 'var(--gold)'; btn.style.color = 'var(--bg-dark)'; btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true');
selectedTc = btn.dataset.tc; selectedTc = btn.dataset.tc;
}); });
}); });
...@@ -240,3 +258,15 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) { ...@@ -240,3 +258,15 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelector('#cfo-cancel').addEventListener('click', () => dialog.remove()); dialog.querySelector('#cfo-cancel').addEventListener('click', () => dialog.remove());
dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); }); dialog.addEventListener('click', (e) => { if (e.target === dialog) dialog.remove(); });
} }
const STYLE = `
.cf-body { min-height: 120px; }
.cf-body > .section-title:first-child { margin-top: var(--s-1); }
.cf-card { cursor: pointer; transition: background var(--dur-fast); }
.cf-card:active { background: var(--back-800); }
.cf-card-sub { display: flex; align-items: center; gap: 6px; min-width: 0; }
.cf-dot-on { color: var(--win); }
.cf-dot-off { color: var(--text-dim); }
.cf-sep { width: 1px; height: 11px; background: var(--back-line-strong); }
.cf-card-action { min-height: 44px; padding: 0 var(--s-4); font-size: var(--fs-sm); flex-shrink: 0; }
`;
...@@ -4,7 +4,8 @@ import * as audio from '../../../core/audio.js'; ...@@ -4,7 +4,8 @@ import * as audio from '../../../core/audio.js';
import * as store from '../../../core/store.js'; import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.js'; import * as juice from '../../../core/juice.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { emoji } from '../../../core/theme.js'; import { icon, GAME_MARK } from '../../../core/icons.js';
import { screenHead, avatar, esc } from '../../../core/ui.js';
let pollTimer = null; let pollTimer = null;
let matchId = null; let matchId = null;
...@@ -36,96 +37,61 @@ export function mountLobby(el, params = {}) { ...@@ -36,96 +37,61 @@ export function mountLobby(el, params = {}) {
const friendName = friendProfile?.display_name || friendProfile?.username || t('common.opponent'); const friendName = friendProfile?.display_name || friendProfile?.username || t('common.opponent');
const friendAvatar = friendProfile?.avatar_url; const friendAvatar = friendProfile?.avatar_url;
const tcLabel = formatTimeControl(timeControl); const clock = /^[a-z]+_(\d+)_(\d+)$/.exec(timeControl);
const tcLabel = clock ? `<span class="num">${clock[1]}+${clock[2]}</span>` : formatTimeControl(timeControl);
const gameLabel = gameKey === 'ludo' ? t('game.ludo') : gameKey === 'domino' ? t('game.domino') : gameKey === 'backgammon' ? t('game.backgammon') : t('game.chess'); const gameLabel = gameKey === 'ludo' ? t('game.ludo') : gameKey === 'domino' ? t('game.domino') : gameKey === 'backgammon' ? t('game.backgammon') : t('game.chess');
const gameIcon = gameKey === 'ludo' ? emoji('dice', '🎲', 20) : gameKey === 'domino' ? emoji('domino_tile', '🁣', 20) : gameKey === 'backgammon' ? emoji('backgammon', '🎯', 20) : emoji('chess_pawn', '♟', 20); const mark = (GAME_MARK[gameKey] || GAME_MARK.chess).icon;
const opponentKnown = !!(friendAvatar || friendProfile?.display_name || friendProfile?.username);
// Each player is a card on the table. The opponent's lies face down until
// they accept, then it turns over.
const colorTag = (c) => c
? `<span class="gl-color"><span class="gl-swatch ${c === 'w' ? 'gl-swatch-w' : 'gl-swatch-b'}"></span>${c === 'w' ? t('lobby.white') : t('lobby.black')}</span>`
: '';
el.innerHTML = ` el.innerHTML = `
<div class="lobby-layout"> <div class="screen gl">
<!-- Header --> ${screenHead(t('lobby.title'))}
<div class="lobby-header">
<button id="lobby-back" class="lobby-back-btn">←</button>
<div class="lobby-title">${emoji('challenge_swords', '⚔️', 18)} ${t('lobby.title')}</div>
</div>
<!-- Match Info --> <div class="gl-tags">
<div class="lobby-match-info"> <span class="chip active gl-tag">${icon(mark, 16)}${gameLabel}</span>
<div class="lobby-game-badge" style="background:${gameKey === 'chess' ? 'var(--chess-primary)' : gameKey === 'ludo' ? 'var(--purple)' : gameKey === 'backgammon' ? 'var(--amber)' : 'var(--emerald)'};"> ${gameKey === 'chess' ? `<span class="chip gl-tag">${icon('clock', 16)}${tcLabel}</span>` : ''}
<span style="font-size:20px;">${gameIcon}</span>
<span style="font-size:13px;font-weight:600;">${gameLabel}</span>
</div>
${gameKey === 'chess' ? `<div class="lobby-time-badge">${emoji('clock', '⏱️', 13)} ${tcLabel}</div>` : ''}
</div> </div>
<!-- Players --> <div class="gl-table">
<div class="lobby-players"> <div class="deck-card gl-seat dealt" style="--deal-rot:-4deg;">
<!-- Host / You --> ${avatar({ display_name: myName, avatar_url: myAvatar }, 64)}
<div class="lobby-player-card"> <div class="gl-name">${esc(myName)}</div>
<div class="lobby-avatar ${isHost ? 'host' : ''}"> <div class="gl-state gl-ok">${icon('check', 14)}${t('lobby.ready')}</div>
${myAvatar ? `<img src="${myAvatar}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 28)} ${colorTag(color)}
</div>
<div class="lobby-player-name">${myName}</div>
<div class="lobby-player-status ready">${emoji('check', '✓', 11)} ${t('lobby.ready')}</div>
${color ? `<div class="lobby-color" style="background:${color === 'w' ? '#fff' : 'var(--bg-dark)'};border:2px solid ${color === 'w' ? 'var(--text-light)' : 'var(--text-dim)'};width:20px;height:20px;border-radius:50%;margin-top:6px;"></div>` : ''}
</div> </div>
<!-- VS divider --> <div class="gl-vs" aria-hidden="true">${icon('swords', 22)}</div>
<div class="lobby-vs">
<div class="lobby-vs-circle">VS</div>
</div>
<!-- Opponent --> <div class="deck-card gl-seat dealt ${isHost ? 'face-down' : ''}" id="lobby-opponent-card" style="--deal-rot:4deg;animation-delay:80ms;">
<div class="lobby-player-card"> <div class="gl-opp-avatar" id="lobby-opponent-avatar">
<div class="lobby-avatar opponent" id="lobby-opponent-avatar"> ${opponentKnown ? avatar({ display_name: friendName, avatar_url: friendAvatar }, 64) : `<span class="avatar gl-wait-avatar">${icon('hourglass', 26)}</span>`}
${friendAvatar ? `<img src="${friendAvatar}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` : `<div class="lobby-waiting-pulse">${emoji('hourglass', '⏳', 28)}</div>`}
</div> </div>
<div class="lobby-player-name" id="lobby-opponent-name">${isHost ? (friendName || t('lobby.waiting')) : friendName}</div> <div class="gl-name" id="lobby-opponent-name">${esc(isHost ? (friendName || t('lobby.waiting')) : friendName)}</div>
<div class="lobby-player-status" id="lobby-opponent-status">${isHost ? t('lobby.waiting_accept') : `${emoji('check', '✓', 11)} ${t('lobby.ready')}`}</div> <div class="gl-state" id="lobby-opponent-status">${isHost ? t('lobby.waiting_accept') : `<span class="gl-ok">${icon('check', 14)}${t('lobby.ready')}</span>`}</div>
${color ? `<div class="lobby-color" style="background:${color === 'w' ? 'var(--bg-dark)' : '#fff'};border:2px solid ${color === 'w' ? 'var(--text-dim)' : 'var(--text-light)'};width:20px;height:20px;border-radius:50%;margin-top:6px;"></div>` : ''} ${colorTag(color === 'w' ? 'b' : color === 'b' ? 'w' : '')}
</div> </div>
</div> </div>
<!-- Status --> <div class="gl-status" id="lobby-status" role="status">
<div class="lobby-status" id="lobby-status"> ${isHost ? `<div class="gl-status-text">${icon('hourglass', 18)}${t('lobby.waiting_opponent')}</div><div class="gl-status-sub">${t('lobby.auto_start')}</div>` : `<div class="gl-status-text gl-ok">${icon('check', 18)}${t('lobby.ready_start')}</div>`}
${isHost ? `<div class="lobby-status-text">${emoji('hourglass', '⏳', 14)} ${t('lobby.waiting_opponent')}</div><div class="lobby-status-sub">${t('lobby.auto_start')}</div>` : `<div class="lobby-status-text" style="color:var(--success);">${emoji('check', '✓', 14)} ${t('lobby.ready_start')}</div>`}
</div> </div>
<!-- Actions --> <div class="gl-actions">
<div class="lobby-actions"> ${!isHost ? `<button class="btn btn-primary btn-lg btn-block" id="lobby-start">${icon('play', 20)}${t('lobby.start')}</button>` : ''}
${!isHost ? `<button class="btn btn-primary lobby-btn" id="lobby-start" style="background:var(--success);">${emoji('play', '▶', 14)} ${t('lobby.start')}</button>` : ''} <button class="btn btn-secondary btn-block" id="lobby-cancel">${icon('x', 18)}${t('common.cancel')}</button>
<button class="btn btn-secondary lobby-btn" id="lobby-cancel">${emoji('exit', '✕', 12)} ${t('common.cancel')}</button>
</div> </div>
</div> </div>
<style> <style>${STYLE}</style>
.lobby-layout { display:flex;flex-direction:column;align-items:center;height:100%;background:var(--bg-deep);padding:0; }
.lobby-header { display:flex;align-items:center;gap:12px;padding:12px 16px;width:100%;background:var(--bg-panel);border-bottom:1px solid var(--border); }
.lobby-back-btn { background:none;border:none;color:var(--text-secondary);font-size:20px;cursor:pointer;padding:4px 8px; }
.lobby-title { font-size:16px;font-weight:700;color:var(--text-primary); }
.lobby-match-info { display:flex;gap:10px;align-items:center;justify-content:center;padding:16px;width:100%; }
.lobby-game-badge { display:flex;align-items:center;gap:6px;padding:8px 16px;border-radius:12px;color:#fff; }
.lobby-time-badge { display:flex;align-items:center;gap:4px;padding:8px 14px;border-radius:10px;background:var(--bg-card);border:1px solid var(--border);color:var(--text-light);font-size:13px;font-weight:600; }
.lobby-players { display:flex;align-items:center;justify-content:center;gap:16px;padding:20px 16px;width:100%; }
.lobby-player-card { display:flex;flex-direction:column;align-items:center;gap:8px;flex:1;max-width:140px; }
.lobby-avatar { width:72px;height:72px;border-radius:50%;background:var(--bg-hover);display:flex;align-items:center;justify-content:center;overflow:hidden;border:3px solid rgba(255,255,255,0.1); }
.lobby-avatar.host { border-color:var(--gold); }
.lobby-avatar.opponent { border-color:var(--blue); }
.lobby-player-name { font-size:14px;font-weight:600;color:var(--text-primary);text-align:center;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.lobby-player-status { font-size:11px;color:var(--text-muted);text-align:center; }
.lobby-player-status.ready { color:var(--success); }
.lobby-vs { display:flex;align-items:center; }
.lobby-vs-circle { width:40px;height:40px;border-radius:50%;background:linear-gradient(135deg,var(--gold),var(--warning));display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:800;color:var(--bg-dark); }
.lobby-status { text-align:center;padding:16px 24px;width:100%; }
.lobby-status-text { font-size:14px;font-weight:600;color:var(--gold);margin-bottom:4px; }
.lobby-status-sub { font-size:12px;color:var(--text-muted); }
.lobby-actions { display:flex;flex-direction:column;gap:10px;padding:16px 24px;width:100%;max-width:320px;margin-top:auto;padding-bottom:max(16px, env(safe-area-inset-bottom, 0px)); }
.lobby-btn { width:100%;min-height:48px;font-size:14px;font-weight:600;border-radius:12px; }
.lobby-waiting-pulse { animation:lobbyPulse 2s infinite; }
@keyframes lobbyPulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.5;transform:scale(0.9)} }
</style>
`; `;
// Bind events // Bind events
el.querySelector('#lobby-back').addEventListener('click', () => cancelAndLeave(el)); el.querySelector('[data-back]').addEventListener('click', () => cancelAndLeave(el));
el.querySelector('#lobby-cancel').addEventListener('click', () => cancelAndLeave(el)); el.querySelector('#lobby-cancel').addEventListener('click', () => cancelAndLeave(el));
const startBtn = el.querySelector('#lobby-start'); const startBtn = el.querySelector('#lobby-start');
...@@ -140,7 +106,7 @@ export function mountLobby(el, params = {}) { ...@@ -140,7 +106,7 @@ export function mountLobby(el, params = {}) {
// Guest: wait briefly so host can detect acceptance, then start // Guest: wait briefly so host can detect acceptance, then start
const statusEl = el.querySelector('#lobby-status'); const statusEl = el.querySelector('#lobby-status');
if (statusEl) { if (statusEl) {
statusEl.innerHTML = `<div class="lobby-status-text" style="color:var(--success);">${emoji('check', '✓', 14)} ${t('lobby.accepted_preparing')}</div>`; statusEl.innerHTML = `<div class="gl-status-text gl-ok">${icon('check', 18)}${t('lobby.accepted_preparing')}</div>`;
} }
startTimeout = setTimeout(() => startGame(el, params), 2500); startTimeout = setTimeout(() => startGame(el, params), 2500);
} }
...@@ -167,12 +133,19 @@ async function pollMatchStatus(el, params) { ...@@ -167,12 +133,19 @@ async function pollMatchStatus(el, params) {
const statusEl = el.querySelector('#lobby-status'); const statusEl = el.querySelector('#lobby-status');
const oppStatus = el.querySelector('#lobby-opponent-status'); const oppStatus = el.querySelector('#lobby-opponent-status');
if (statusEl) { if (statusEl) {
statusEl.innerHTML = `<div class="lobby-status-text" style="color:var(--success);">${emoji('check', '✓', 14)} ${t('lobby.opponent_accepted')}</div>`; statusEl.innerHTML = `<div class="gl-status-text gl-ok">${icon('check', 18)}${t('lobby.opponent_accepted')}</div>`;
} }
if (oppStatus) { if (oppStatus) {
oppStatus.innerHTML = `<span style="color:var(--success);">${emoji('check', '✓', 11)} ${t('lobby.ready')}</span>`; oppStatus.innerHTML = `<span class="gl-ok">${icon('check', 14)}${t('lobby.ready')}</span>`;
oppStatus.classList.add('ready'); oppStatus.classList.add('ready');
} }
// The opponent's card turns face up.
const oppCard = el.querySelector('#lobby-opponent-card');
if (oppCard?.classList.contains('face-down')) {
oppCard.classList.remove('face-down', 'dealt');
oppCard.style.animationDelay = '';
oppCard.classList.add('flip-in');
}
audio.play('reward'); audio.play('reward');
juice.hapticSuccess(); juice.hapticSuccess();
...@@ -269,3 +242,67 @@ export function unmountLobby() { ...@@ -269,3 +242,67 @@ export function unmountLobby() {
if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; } if (startTimeout) { clearTimeout(startTimeout); startTimeout = null; }
matchId = null; matchId = null;
} }
const STYLE = `
.gl { min-height: 100%; display: flex; flex-direction: column; }
.gl-tags { display: flex; justify-content: center; flex-wrap: wrap; gap: var(--s-2); }
.gl-tag { cursor: default; }
.gl-table {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--s-3);
margin-top: var(--s-6);
}
.gl-seat {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
min-height: 196px;
padding: var(--s-5) var(--s-3) var(--s-4);
text-align: center;
}
.gl-seat .avatar { box-shadow: 0 0 0 3px var(--card), 0 0 0 4px var(--card-line); }
.gl-name {
margin-top: var(--s-2);
max-width: 100%;
font-size: var(--fs-base);
font-weight: 800;
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gl-state { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 600; color: var(--ink-3); }
.gl-state .gl-ok, .gl-state.gl-ok { display: inline-flex; align-items: center; gap: 4px; color: var(--win-ink); }
.gl-color { display: inline-flex; align-items: center; gap: 6px; margin-top: 4px; font-size: var(--fs-xs); font-weight: 600; color: var(--ink-2); }
.gl-swatch { width: 14px; height: 14px; border-radius: 4px; border: 1.5px solid var(--ink); }
.gl-swatch-w { background: var(--card); }
.gl-swatch-b { background: var(--ink); }
/* Face down: the opponent has not picked up their card yet. */
.gl-seat.face-down .gl-name { color: var(--text-primary); }
.gl-seat.face-down .gl-state { color: var(--text-secondary); }
.gl-seat.face-down .gl-color { color: var(--text-secondary); }
.gl-seat.face-down .gl-swatch { border-color: var(--text-secondary); }
.gl-seat.face-down .avatar { box-shadow: 0 0 0 3px var(--back-600), 0 0 0 4px var(--back-line-strong); }
.gl-wait-avatar { width: 64px; height: 64px; color: var(--text-secondary); }
.gl-vs { color: var(--text-muted); display: flex; }
.gl-status { text-align: center; margin-top: var(--s-6); }
.gl-status-text {
display: inline-flex; align-items: center; justify-content: center; gap: var(--s-2);
font-size: var(--fs-md); font-weight: 700; color: var(--text-primary);
}
.gl-status .gl-ok { color: var(--win); }
.gl-status-sub { font-size: var(--fs-sm); color: var(--text-muted); margin-top: var(--s-1); max-width: 34ch; margin-inline: auto; }
.gl-actions {
display: flex; flex-direction: column; gap: var(--s-3);
margin-top: auto; padding-top: var(--s-8);
}
`;
...@@ -4,6 +4,7 @@ import * as net from '../../../core/net.js'; ...@@ -4,6 +4,7 @@ import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as modal from '../../../core/modal.js'; import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
const QUEUE_TIMEOUT_S = 60; const QUEUE_TIMEOUT_S = 60;
let unsub = null; let unsub = null;
...@@ -23,20 +24,21 @@ export function mountQueue(el, params) { ...@@ -23,20 +24,21 @@ export function mountQueue(el, params) {
let seconds = 0; let seconds = 0;
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);"> <div class="screen q-screen">
<div class="queue-radar" style="width:120px;height:120px;border-radius:50%;border:3px solid var(--gold);position:relative;display:flex;align-items:center;justify-content:center;"> <div class="q-table">
<div style="width:8px;height:8px;background:var(--gold);border-radius:50%;animation:pulse 1s ease-in-out infinite;"></div> <div class="q-deck" aria-hidden="true">
<div style="position:absolute;inset:0;border-radius:50%;border:2px solid var(--gold);opacity:0;animation:radarPing 2s ease-out infinite;"></div> ${[0, 1, 2, 3].map(i => `<span class="deck-back q-card" style="--q:${i};"></span>`).join('')}
</div> </div>
<div> <div class="q-text">
<div style="font-size:16px;font-weight:600;text-align:center;">${t('play.searching')}</div> <p class="q-title" role="status">${t('play.searching')}</p>
<div id="queue-timer" style="font-size:24px;font-weight:700;color:var(--gold);text-align:center;margin-top:var(--s-2);font-family:var(--font-lat);">0:00</div> <div id="queue-timer" class="num q-timer" role="timer" aria-label="${t('queue.elapsed')}">0:00</div>
<div class="q-chip-row">${matchChip(params)}</div>
<p class="q-hint">${t('queue.hint')}</p>
</div> </div>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
</div> </div>
<style> <button class="btn btn-secondary btn-lg btn-block q-cancel" id="cancel-btn">${icon('x', 18)}${t('play.cancel')}</button>
@keyframes radarPing { 0% { transform:scale(1);opacity:0.6; } 100% { transform:scale(2);opacity:0; } } </div>
</style> <style>${STYLE}</style>
`; `;
const timerEl = el.querySelector('#queue-timer'); const timerEl = el.querySelector('#queue-timer');
...@@ -155,3 +157,87 @@ function cleanup() { ...@@ -155,3 +157,87 @@ function cleanup() {
if (unsub) { clearInterval(unsub); unsub = null; } if (unsub) { clearInterval(unsub); unsub = null; }
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
} }
/** "blitz_5_3" -> "5+3"; anything else (standard, undefined) -> ''. */
function clockOf(tc) {
const m = /^[a-z]+_(\d+)_(\d+)$/.exec(tc || '');
return m ? `${m[1]}+${m[2]}` : '';
}
/** The game being searched for, and its clock or table size, as one chip. */
function matchChip(params = {}) {
const game = GAME_MARK[params.game] ? params.game : 'chess';
const parts = [`${icon(GAME_MARK[game].icon, 16)}<span>${t('game.' + game)}</span>`];
const clock = clockOf(params.timeControl);
if (clock) parts.push(`<span class="num">${clock}</span>`);
else if (game === 'ludo' && params.playerCount) parts.push(`${icon('users', 15)}<span class="num">${Number(params.playerCount)}</span>`);
else if (game === 'backgammon' && params.variant) parts.push(`<span>${t('backgammon.' + params.variant)}</span>`);
return `<span class="chip q-chip">${parts.join('<span class="q-sep" aria-hidden="true"></span>')}</span>`;
}
// The wait is a deck being shuffled on the table: the packet splits in two,
// the halves riffle back together, and the deck squares up. Slow, and still
// when the player asks for less motion.
const STYLE = `
.q-screen {
min-height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--s-8);
padding-bottom: var(--s-6);
}
.q-table {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-6);
text-align: center;
}
.q-deck {
display: grid;
place-items: center;
width: 220px;
height: 170px;
background: radial-gradient(closest-side, color-mix(in srgb, var(--sky) 12%, transparent), transparent);
}
.q-card {
grid-area: 1 / 1;
width: 72px;
height: 100px;
border-radius: 9px;
box-shadow: var(--shadow-md);
outline: 1px solid var(--back-line-strong);
outline-offset: -5px;
transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg));
animation: qRiffle 3.2s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 70ms);
}
.q-card:nth-child(odd) { --dx: -46px; --tilt: -10deg; }
.q-card:nth-child(even) { --dx: 46px; --tilt: 10deg; }
@keyframes qRiffle {
0%, 12% { transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg)); }
34% { transform: translate(var(--dx), -10px) rotate(var(--tilt)); }
50% { transform: translate(var(--dx), -4px) rotate(calc(var(--tilt) * 0.6)); }
72%, 100% { transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg)); }
}
@media (prefers-reduced-motion: reduce) {
.q-card { animation: none; transform: translateX(calc((var(--q) - 1.5) * 10px)) rotate(calc((var(--q) - 1.5) * 5deg)); }
}
.q-text { display: flex; flex-direction: column; align-items: center; gap: var(--s-2); }
.q-title { font-size: var(--fs-md); font-weight: 700; color: var(--text-primary); }
.q-timer {
font-size: var(--fs-3xl);
font-weight: 800;
line-height: 1.1;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.q-chip-row { display: flex; justify-content: center; margin-top: var(--s-1); }
.q-chip { cursor: default; color: var(--text-primary); }
.q-sep { width: 1px; height: 14px; background: var(--back-line-strong); }
.q-hint { font-size: var(--fs-sm); color: var(--text-muted); max-width: 30ch; margin-top: var(--s-1); }
.q-cancel { margin-top: var(--s-6); }
`;
// Choose a time control. Each control is a small printed card: the clock is
// its rank ("5+3"), and the minutes and increment are spelled out under it.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { screenHead, wireBack } from '../../../core/ui.js';
const categories = [ const categories = [
{ {
name: 'Bullet', nameKey: 'time.bullet', iconSlot: 'lightning', iconFallback: '⚡', color: 'var(--amber)', name: 'Bullet', nameKey: 'time.bullet', icon: 'zap',
controls: [ controls: [
{ key: 'bullet_1_0', labelKey: 'time.1min', sub: '1+0' }, { key: 'bullet_1_0', sub: '1+0' },
{ key: 'bullet_1_1', label: '1 | 1', sub: '1+1' }, { key: 'bullet_1_1', sub: '1+1' },
{ key: 'bullet_2_1', label: '2 | 1', sub: '2+1' }, { key: 'bullet_2_1', sub: '2+1' },
] ]
}, },
{ {
name: 'Blitz', nameKey: 'time.blitz', iconSlot: 'fire', iconFallback: '🔥', color: 'var(--orange)', name: 'Blitz', nameKey: 'time.blitz', icon: 'flame',
controls: [ controls: [
{ key: 'blitz_3_0', labelKey: 'time.3min', sub: '3+0' }, { key: 'blitz_3_0', sub: '3+0' },
{ key: 'blitz_3_2', label: '3 | 2', sub: '3+2' }, { key: 'blitz_3_2', sub: '3+2' },
{ key: 'blitz_5_0', labelKey: 'time.5min', sub: '5+0' }, { key: 'blitz_5_0', sub: '5+0' },
{ key: 'blitz_5_3', label: '5 | 3', sub: '5+3' }, { key: 'blitz_5_3', sub: '5+3' },
] ]
}, },
{ {
name: 'Rapid', nameKey: 'time.rapid', iconSlot: 'runner', iconFallback: '🏃', color: 'var(--green)', name: 'Rapid', nameKey: 'time.rapid', icon: 'timer',
controls: [ controls: [
{ key: 'rapid_10_0', labelKey: 'time.10min', sub: '10+0' }, { key: 'rapid_10_0', sub: '10+0' },
{ key: 'rapid_10_5', label: '10 | 5', sub: '10+5' }, { key: 'rapid_10_5', sub: '10+5' },
{ key: 'rapid_15_10', label: '15 | 10', sub: '15+10' }, { key: 'rapid_15_10', sub: '15+10' },
{ key: 'rapid_30_0', labelKey: 'time.30min', sub: '30+0' }, { key: 'rapid_30_0', sub: '30+0' },
] ]
}, },
{ {
name: 'Classical', nameKey: 'time.classical', iconSlot: 'crown', iconFallback: '♔', color: 'var(--purple)', name: 'Classical', nameKey: 'time.classical', icon: 'crown-line',
controls: [ controls: [
{ key: 'classical_60_0', labelKey: 'time.60min', sub: '60+0' }, { key: 'classical_60_0', sub: '60+0' },
{ key: 'classical_90_30', label: '90 | 30', sub: '90+30' }, { key: 'classical_90_30', sub: '90+30' },
] ]
} }
]; ];
// The minutes as words. Existing keys first; 2 and 90 have their own.
const MINUTE_KEYS = { 1: 'time.1min', 2: 'play.tc_2min', 3: 'time.3min', 5: 'time.5min', 10: 'time.10min', 15: 'time.15min', 30: 'time.30min', 60: 'time.60min', 90: 'play.tc_90min' };
function controlLabel(sub) {
const [min, inc] = sub.split('+').map(Number);
const minutes = MINUTE_KEYS[min] ? t(MINUTE_KEYS[min]) : String(min);
if (!inc) return `<span>${minutes}</span>`;
return `<span>${minutes}</span><span>${t('play.tc_inc', { n: `<span class="num">+${inc}</span>` })}</span>`;
}
// Only the values in the public.time_control enum are offered. blitz_5_5, // Only the values in the public.time_control enum are offered. blitz_5_5,
// rapid_20_0 and classical_45_45 used to appear here; the database rejects them, // rapid_20_0 and classical_45_45 used to appear here; the database rejects them,
// so choosing one created no match and dropped the player back to the menu. // so choosing one created no match and dropped the player back to the menu.
export function mountTimeSelect(el, params) { export function mountTimeSelect(el, params) {
const sub = params?.mode === 'bot' || params?.mode === 'local' ? t('play.vs_bot') : t('play.vs_human');
let dealt = 0;
el.innerHTML = ` el.innerHTML = `
<div class="tc-page" style="padding:16px;display:flex;flex-direction:column;gap:16px;height:100%;overflow-y:auto;"> <div class="screen tc-page">
<div style="display:flex;align-items:center;gap:12px;"> ${screenHead(t('play.select_time'), { sub })}
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;border-radius:50%;">←</button>
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);">${t('play.select_time')}</h2>
</div>
${categories.map(cat => ` ${categories.map(cat => `
<div class="tc-category"> <section class="tc-category" aria-label="${t(cat.nameKey)}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;"> <h2 class="section-title tc-title">
<span style="font-size:18px;">${emoji(cat.iconSlot, cat.iconFallback, 18)}</span> <span class="tc-title-text">${icon(cat.icon, 18, { cls: 'tc-title-icon' })}${t(cat.nameKey)}</span>
<span style="font-size:14px;font-weight:700;color:${cat.color};">${t(cat.nameKey)}</span> </h2>
<span style="font-size:11px;color:var(--text-muted);font-family:var(--font-lat);">${cat.name}</span> <div class="tc-grid">
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;">
${cat.controls.map(tc => ` ${cat.controls.map(tc => `
<button class="time-btn" data-key="${tc.key}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;height:58px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:transform 0.1s,background 0.15s,border-color 0.15s;"> <button class="deck-card time-btn tc-card dealt" data-key="${tc.key}"
<span style="font-size:14px;font-weight:700;color:var(--text-primary);font-family:var(--font-lat);">${tc.sub}</span> style="--deal-rot:${(dealt % 2 ? 3 : -3)}deg;animation-delay:${(dealt++) * 30}ms;">
<span style="font-size:var(--fs-xs);color:var(--text-secondary);">${tc.labelKey ? t(tc.labelKey) : tc.label}</span> <span class="num tc-rank">${tc.sub}</span>
<span class="tc-label">${controlLabel(tc.sub)}</span>
</button> </button>
`).join('')} `).join('')}
</div> </div>
</div> </section>
`).join('')} `).join('')}
</div> </div>
<style> <style>${STYLE}</style>
.time-btn:active { transform:scale(0.93);background:var(--bg-hover); }
.time-btn:hover { border-color:var(--border-hover); }
</style>
`; `;
el.querySelector('#back-btn').addEventListener('click', () => { wireBack(el);
audio.play('click');
scene.pop();
});
el.querySelectorAll('.time-btn').forEach(btn => { el.querySelectorAll('.time-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
...@@ -94,3 +99,46 @@ export function mountTimeSelect(el, params) { ...@@ -94,3 +99,46 @@ export function mountTimeSelect(el, params) {
}); });
}); });
} }
const STYLE = `
.tc-category + .tc-category { margin-top: var(--s-2); }
.tc-category:first-of-type .tc-title { margin-top: var(--s-2); }
.tc-title-text { display: inline-flex; align-items: center; gap: var(--s-2); }
.tc-title-icon { color: var(--text-muted); }
.tc-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-2);
}
.tc-card {
--r-card: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
min-height: 84px;
padding: var(--s-4) 4px var(--s-3);
text-align: center;
}
.tc-card::before { inset: 5px; }
.tc-rank {
font-size: var(--fs-lg);
font-weight: 800;
line-height: 1.15;
color: var(--ink);
letter-spacing: -0.02em;
white-space: nowrap;
}
.tc-label {
display: flex;
flex-direction: column;
align-items: center;
font-size: var(--fs-xs);
font-weight: 500;
line-height: 1.35;
color: var(--ink-3);
white-space: nowrap;
}
`;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment