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.
## 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 / العب.
- 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.
......@@ -77,6 +83,7 @@ championship with a live broadcast.
## 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.
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.
......
......@@ -68,6 +68,8 @@ async function run() {
const btns = [...document.querySelectorAll('[data-level], button')];
const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط'));
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);
}
......
......@@ -742,7 +742,30 @@ const strings = {
'home.streak': 'سلسلة {n} يوم',
'home.daily_sub': 'عملات كل يوم',
'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: {
'app.name': 'EL3AB',
......@@ -1485,7 +1508,30 @@ const strings = {
'home.streak': '{n}-day streak',
'home.daily_sub': 'Coins every day',
'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';
import * as audio from '../../../core/audio.js';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.js';
import { emoji } from '../../../core/theme.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) {
const { mode = 'menu' } = params || {};
......@@ -13,37 +14,59 @@ export function mountRoom(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) {
const mark = GAME_MARK.backgammon.icon;
el.innerHTML = `
<div class="bg-wrap">
<div class="bg-hero">
<div class="bg-icon">${emoji('game_die', '🎲', 56)}</div>
<h1 class="bg-title">${t('backgammon.title')}</h1>
<p class="bg-subtitle">${t('backgammon.subtitle')}</p>
<div class="screen bg-wrap">
${screenHead(t('backgammon.title'), { sub: t('backgammon.subtitle') })}
<div class="bg-hero" aria-hidden="true">
<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 class="bg-buttons">
<button class="bg-btn bg-btn-primary" id="btn-local">
<span class="bg-btn-icon">${emoji('gamepad', '🎮', 22)}</span>
<span class="bg-btn-label">${t('game.vs_bot')}</span>
<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>
${modeRow('btn-local', 'bot', t('game.vs_bot'), t('backgammon.bot_desc'))}
${modeRow('btn-online', 'globe', t('game.online'), t('backgammon.online_desc'), true)}
${modeRow('btn-friend', 'swords', t('game.vs_friend'), t('backgammon.friend_desc'))}
</div>
<button class="bg-back" id="btn-back">${t('common.back')}</button>
</div>
${getStyles()}
`;
headBack(el, 'btn-back');
el.querySelector('#btn-local').onclick = () => {
audio.play('click');
......@@ -76,91 +99,56 @@ function renderSetup(el, params) {
const isOnline = type === 'online';
el.innerHTML = `
<div class="bg-wrap">
<div class="bg-hero" style="margin-bottom:16px;">
<h2 class="bg-title" style="font-size:20px;">${isOnline ? t('backgammon.settings_online') : t('backgammon.settings_game')}</h2>
</div>
<div class="screen bg-wrap">
${screenHead(t('backgammon.title'), { sub: isOnline ? t('game.online') : t('game.vs_bot') })}
<!-- Variant -->
<div class="bg-section">
<div class="bg-section-title">${t('backgammon.game_type')}</div>
<div class="bg-grid" id="variant-grid">
<button class="bg-chip bg-chip-active" data-variant="sheshbesh">
<span class="bg-chip-label">${t('backgammon.sheshbesh')}</span>
</button>
<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>
<section class="bg-section">
<h2 class="section-title bg-section-title">${t('backgammon.game_type')}</h2>
<div class="chips bg-grid" id="variant-grid">
${chip('variant', 'sheshbesh', true, `<span class="bg-chip-label">${t('backgammon.sheshbesh')}</span>`)}
${chip('variant', 'mahbousa', false, `<span class="bg-chip-label">${t('backgammon.mahbousa')}</span>`)}
${chip('variant', 'thirtyone', false, `<span class="bg-chip-label">${t('backgammon.thirtyone')}</span>`)}
</div>
</section>
<!-- Match Length -->
<div class="bg-section">
<div class="bg-section-title">${t('backgammon.match_length')}</div>
<div class="bg-grid" id="length-grid">
<button class="bg-chip" data-len="1">
<span class="bg-chip-num">1</span>
<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>
<section class="bg-section">
<h2 class="section-title bg-section-title">${t('backgammon.match_length')}</h2>
<div class="chips bg-grid" id="length-grid">
${[[1, 'short_match'], [3, 'medium_match'], [5, 'normal_match'], [7, 'long_match']].map(([n, key]) =>
chip('len', n, n === 3, `<span class="num bg-chip-num">${n}</span><span class="bg-chip-label">${t('backgammon.' + key)}</span>`)).join('')}
</div>
</section>
${!isOnline ? `
<!-- Bot Difficulty -->
<div class="bg-section">
<div class="bg-section-title">${t('ludo.bot_level')}</div>
<div class="bg-grid" id="difficulty-grid">
<button class="bg-chip" data-diff="easy">
<span>${emoji('bot_easy', '😊', 18)}</span>
<span class="bg-chip-label">${t('ludo.easy')}</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>
<section class="bg-section">
<h2 class="section-title bg-section-title">${t('ludo.bot_level')}</h2>
<div class="chips bg-grid" id="difficulty-grid">
${chip('diff', 'easy', false, `${pips(1)}<span class="bg-chip-label">${t('ludo.easy')}</span>`)}
${chip('diff', 'medium', true, `${pips(2)}<span class="bg-chip-label">${t('ludo.medium')}</span>`)}
${chip('diff', 'hard', false, `${pips(3)}<span class="bg-chip-label">${t('ludo.hard')}</span>`)}
</div>
</section>
` : ''}
<!-- Doubling Cube Toggle -->
<div class="bg-section">
<div class="bg-section-title">${t('backgammon.doubling_cube')}</div>
<div class="bg-grid" id="cube-grid">
<button class="bg-chip bg-chip-active" data-cube="on">
<span class="bg-chip-label">${t('backgammon.enabled')}</span>
</button>
<button class="bg-chip" data-cube="off">
<span class="bg-chip-label">${t('backgammon.disabled')}</span>
</button>
</div>
<section class="bg-section">
<h2 class="section-title bg-section-title">${t('backgammon.doubling_cube')}</h2>
<div class="chips bg-grid" id="cube-grid">
${chip('cube', 'on', true, `${icon('check', 16)}<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>`)}
</div>
</section>
<button class="bg-btn bg-btn-start" id="btn-start">
${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
<button class="btn btn-primary btn-lg btn-block bgr-start" id="btn-start">
${icon(isOnline ? 'search' : 'play', 20)}${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
</button>
<button class="bg-back" id="btn-back-setup">${t('common.back')}</button>
</div>
${getStyles()}
`;
headBack(el, 'btn-back-setup');
let variant = 'sheshbesh';
let matchLength = 3;
......@@ -196,15 +184,15 @@ function renderSetup(el, params) {
function renderSearching(el) {
el.innerHTML = `
<div class="bg-wrap">
<div class="screen bg-wrap bgr-searching">
<div class="bg-hero">
<div class="bg-pulse-ring">
<div class="bg-pulse-inner">${emoji('game_die', '🎲', 32)}</div>
<div class="bg-pulse-ring" aria-hidden="true">
${[0, 1, 2].map(i => `<span class="deck-back bg-pulse-inner" style="--q:${i};"></span>`).join('')}
</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>
</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>
${getStyles()}
`;
......@@ -215,8 +203,9 @@ function setupChipGroup(el, selector, onChange, dataKey) {
el.querySelectorAll(`${selector} .bg-chip`).forEach(btn => {
btn.addEventListener('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.setAttribute('aria-pressed', 'true');
onChange(btn.dataset[dataKey]);
});
});
......@@ -224,59 +213,63 @@ function setupChipGroup(el, selector, onChange, dataKey) {
function getStyles() {
return `<style>
.bg-wrap {
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-wrap { display: flex; flex-direction: column; }
.bg-section { width:100%;max-width:320px;margin-bottom:16px; }
.bg-section-title { font-size:12px;font-weight:700;color:var(--text-secondary);margin-bottom:8px;text-align:center; }
.bg-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; }
.bg-chip {
display:flex;align-items:center;gap:6px;
padding:10px 16px;border-radius:12px;
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);
/* The game's card, lying on the table under the header. */
.bg-hero { display: flex; justify-content: center; margin: var(--s-2) 0 var(--s-6); }
.bgr-game-card {
width: 104px; height: 146px;
display: flex; align-items: center; justify-content: center;
color: var(--suit);
}
.bg-chip:active { transform:scale(0.95); }
.bg-chip-active { background:rgba(212,148,10,0.12);border-color:rgba(212,148,10,0.5);color:var(--gold-dark); }
.bg-chip-num { font-size:18px;font-weight:800; }
.bg-chip-label { font-size:12px; }
.bgr-game-card .index { font-size: 12px; }
.bgr-game-mark { display: flex; }
.bg-pulse-ring {
width:80px;height:80px;border-radius:50%;
border:3px solid rgba(212,148,10,0.4);
display:flex;align-items:center;justify-content:center;
animation:bgPulse 2s ease-in-out infinite;margin:0 auto;
/* Mode rows: printed card faces, icon on the reading side. */
.bg-buttons { display: flex; flex-direction: column; gap: var(--s-3); }
.bgr-mode {
justify-content: flex-start;
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)} }
@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)} }
/* Option groups. Chips wrap so every choice stays in view. */
.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>`;
}
......
......@@ -4,7 +4,8 @@ import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.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;
......@@ -20,60 +21,49 @@ export function mountRoom(el, params) {
}
}
function renderMenu(el) {
el.innerHTML = `
<div class="domino-menu">
<div class="dm-hero">
<div class="dm-icon">${emoji('domino_tile', '🁣', 56)}</div>
<h1 class="dm-title">${t('domino.title')}</h1>
<p class="dm-subtitle">${t('domino.subtitle')}</p>
/** 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;
}
/** 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>`;
}
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">
<button class="dm-btn dm-btn-primary" id="btn-bot">
<span class="dm-btn-icon">${emoji('robot', '🤖', 22)}</span>
<span class="dm-btn-label">${t('game.vs_bot')}</span>
</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>
${modeRow('btn-bot', 'bot', t('game.vs_bot'))}
${modeRow('btn-online', 'globe', t('game.online'), true)}
${modeRow('btn-friend', 'swords', t('game.vs_friend'))}
</div>
<button class="dm-back" id="btn-back">${t('common.back')}</button>
</div>
<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>
${STYLE}
`;
headBack(el, 'btn-back');
el.querySelector('#btn-bot').addEventListener('click', () => {
audio.play('click');
......@@ -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) {
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: '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: '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: 'beginner', label: t('domino.beginner'), desc: t('domino.beginner_desc'), pips: 1 },
{ key: 'intermediate', label: t('domino.intermediate'), desc: t('domino.intermediate_desc'), pips: 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 = `
<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_bot')}</h2>
<p style="font-size:12px;color:var(--text-secondary);margin:8px 0 0;">${t('domino.bot_strategy')}</p>
</div>
<div class="dm-buttons">
<div class="screen domino-menu">
${screenHead(t('domino.title'), { sub: t('game.vs_bot') })}
<section class="dm-section">
<h2 class="section-title dm-section-title">${t('domino.select_bot')}</h2>
<div class="chips dm-chips" id="dm-level-grid">
${levels.map(l => `
<button class="dm-level-btn" data-level="${l.key}" style="background:${l.bg};border:1px solid ${l.border};">
<span class="dm-level-icon">${l.icon}</span>
<div class="dm-level-info">
<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>
<button class="chip dm-level-btn ${l.key === botLevel ? 'active' : ''}" data-level="${l.key}" aria-pressed="${l.key === botLevel}">
${pips(l.pips)}<span>${l.label}</span>
</button>`).join('')}
</div>
<span style="font-size:16px;color:var(--text-dim);">←</span>
</button>
`).join('')}
<p class="dm-hint" id="dm-level-hint" aria-live="polite">${levels.find(l => l.key === botLevel).desc}</p>
</section>
<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>
<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>
<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>
${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 => {
btn.addEventListener('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');
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 => {
btn.addEventListener('click', () => {
el.querySelector('#btn-start').addEventListener('click', () => {
audio.play('click');
const targetScore = parseInt(btn.dataset.target);
scene.enterGameMode();
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');
renderBotPicker(el);
scene.replace('domino-room', { mode: 'menu' });
});
}
function renderLobby(el, { challengeId, friendId, friendName }) {
const isHost = !challengeId;
el.innerHTML = `
<div class="domino-menu">
<div class="dm-hero">
<div style="font-size:44px;margin-bottom:12px;">${emoji('domino_tile', '🁣', 44)}</div>
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);margin:0;">
<div class="screen domino-menu dm-lobby">
${screenHead(t('domino.title'), { back: false })}
${gameCard(true)}
<div class="dm-lobby-text">
<h2 class="dm-lobby-title">
${isHost ? t('room.waiting_friend') : t('room.challenge_from', { name: friendName || t('common.friend') })}
</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 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 style="display:flex;gap:10px;margin-top:20px;">
${!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>` : ''}
<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>
<div class="dm-lobby-actions">
${!isHost ? `<button class="btn btn-primary btn-lg btn-block" id="btn-accept">${icon('check', 20)}${t('social.accept')}</button>` : ''}
<button class="btn btn-secondary btn-block" id="btn-cancel-lobby">${icon('x', 18)}${t('common.cancel')}</button>
</div>
</div>
<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>
${STYLE}
`;
if (isHost) {
......@@ -285,3 +263,45 @@ function cleanupLobby() {
export function unmountRoom() {
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';
import * as bus from '../../../core/bus.js';
import * as store from '../../../core/store.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) {
const { mode = 'menu' } = 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) {
const mark = GAME_MARK.ludo.icon;
el.innerHTML = `
<div class="lr-wrap">
<div class="lr-hero">
<div class="lr-icon">${emoji('dice', '🎲', 56)}</div>
<h1 class="lr-title">${t('ludo.title')}</h1>
<p class="lr-subtitle">${t('ludo.subtitle')}</p>
<div class="screen lr-wrap">
${screenHead(t('ludo.title'), { sub: t('ludo.subtitle') })}
<div class="lr-hero" aria-hidden="true">
<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 class="lr-buttons">
<button class="lr-btn lr-btn-primary" id="btn-local">
<span class="lr-btn-icon">${emoji('gamepad', '🎮', 22)}</span>
<span class="lr-btn-label">${t('ludo.local_play')}</span>
<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>
${modeRow('btn-local', 'users', t('ludo.local_play'), t('ludo.local_desc'))}
${modeRow('btn-online', 'globe', t('game.online'), t('ludo.online_desc'), true)}
${modeRow('btn-friend', 'swords', t('game.vs_friend'), t('ludo.friend_desc'))}
</div>
<button class="lr-back" id="btn-back">${t('common.back')}</button>
</div>
${getStyles()}
`;
headBack(el, 'btn-back');
el.querySelector('#btn-local').addEventListener('click', () => {
audio.play('click');
......@@ -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) {
const { type = 'local' } = params;
const isOnline = type === 'online';
el.innerHTML = `
<div class="lr-wrap">
<div class="lr-hero" style="margin-bottom:16px;">
<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>
<div class="screen lr-wrap">
${screenHead(t('ludo.title'), { sub: isOnline ? t('ludo.settings_online_hint') : t('ludo.settings_play_hint') })}
<!-- Player Count -->
<div class="lr-section">
<div class="lr-section-title">${t('ludo.player_count')}</div>
<div class="lr-grid" id="player-count-grid">
<button class="lr-chip lr-chip-active" data-count="4">
<span class="lr-chip-num">4</span>
<section class="lr-section">
<h2 class="section-title lr-section-title">${t('ludo.player_count')}</h2>
<div class="chips lr-grid" id="player-count-grid">
<button class="chip lr-chip lr-chip-active" data-count="4" aria-pressed="true">
<span class="num lr-chip-num">4</span>
<span class="lr-chip-label">${t('ludo.classic')}</span>
</button>
<button class="lr-chip" data-count="3">
<span class="lr-chip-num">3</span>
<button class="chip lr-chip" data-count="3" aria-pressed="false">
<span class="num lr-chip-num">3</span>
<span class="lr-chip-label">${t('ludo.triple')}</span>
</button>
<button class="lr-chip" data-count="2">
<span class="lr-chip-num">2</span>
<button class="chip lr-chip" data-count="2" aria-pressed="false">
<span class="num lr-chip-num">2</span>
<span class="lr-chip-label">${t('ludo.duel')}</span>
</button>
</div>
</div>
</section>
<!-- Bot/Human Distribution (local only) -->
${!isOnline ? `
<div class="lr-section" id="distribution-section">
<div class="lr-section-title">${t('ludo.player_layout')}</div>
<section class="lr-section" id="distribution-section">
<h2 class="section-title lr-section-title">${t('ludo.player_layout')}</h2>
<div id="dist-options"></div>
</div>
</section>
` : ''}
${!isOnline ? `
<!-- Bot Difficulty -->
<div class="lr-section" id="difficulty-section">
<div class="lr-section-title">${t('ludo.bot_level')}</div>
<div class="lr-grid" id="difficulty-grid">
<button class="lr-chip" data-diff="easy">
<span>${emoji('bot_easy', '😊', 18)}</span>
<span class="lr-chip-label">${t('ludo.easy')}</span>
<section class="lr-section" id="difficulty-section">
<h2 class="section-title lr-section-title">${t('ludo.bot_level')}</h2>
<div class="chips lr-grid" id="difficulty-grid">
<button class="chip lr-chip" data-diff="easy" aria-pressed="false">
${pips(1)}<span class="lr-chip-label">${t('ludo.easy')}</span>
</button>
<button class="lr-chip lr-chip-active" data-diff="medium">
<span>${emoji('bot_medium', '🧐', 18)}</span>
<span class="lr-chip-label">${t('ludo.medium')}</span>
<button class="chip lr-chip lr-chip-active" data-diff="medium" aria-pressed="true">
${pips(2)}<span class="lr-chip-label">${t('ludo.medium')}</span>
</button>
<button class="lr-chip" data-diff="hard">
<span>${emoji('bot_hard', '🧠', 18)}</span>
<span class="lr-chip-label">${t('ludo.hard')}</span>
<button class="chip lr-chip" data-diff="hard" aria-pressed="false">
${pips(3)}<span class="lr-chip-label">${t('ludo.hard')}</span>
</button>
</div>
</div>
</section>
` : ''}
<!-- Seating Preview -->
<div class="lr-section">
<div class="lr-section-title">${t('ludo.seat_order')}</div>
<section class="lr-section">
<h2 class="section-title lr-section-title">${t('ludo.seat_order')}</h2>
<div id="seat-preview" class="lr-seat-preview"></div>
</div>
</section>
<!-- Start Button -->
<button class="lr-btn lr-btn-start" id="btn-start">
${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
<button class="btn btn-primary btn-lg btn-block lr-start" id="btn-start">
${icon(isOnline ? 'search' : 'play', 20)}${isOnline ? t('ludo.search_match') : t('ludo.start_play')}
</button>
<button class="lr-back" id="btn-back-setup">${t('common.back')}</button>
</div>
${getStyles()}
`;
headBack(el, 'btn-back-setup');
let playerCount = 4;
let humanCount = 1;
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
el.querySelectorAll('#player-count-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('#player-count-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
choose(el.querySelectorAll('#player-count-grid .lr-chip'), btn);
playerCount = parseInt(btn.dataset.count);
if (!isOnline) updateDistribution();
updateSeatPreview();
......@@ -174,8 +191,7 @@ function renderSetup(el, params) {
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
el.querySelectorAll('#difficulty-grid .lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
choose(el.querySelectorAll('#difficulty-grid .lr-chip'), btn);
difficulty = btn.dataset.diff;
});
});
......@@ -196,8 +212,8 @@ function renderSetup(el, params) {
options.push({ humans: h, bots, label });
}
container.innerHTML = `<div class="lr-grid">${options.map((opt, i) => `
<button class="lr-chip ${i === 0 ? 'lr-chip-active' : ''}" data-humans="${opt.humans}">
container.innerHTML = `<div class="chips lr-grid">${options.map((opt, i) => `
<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>
</button>
`).join('')}</div>`;
......@@ -206,8 +222,7 @@ function renderSetup(el, params) {
container.querySelectorAll('.lr-chip').forEach(btn => {
btn.addEventListener('click', () => {
audio.play('click');
container.querySelectorAll('.lr-chip').forEach(b => b.classList.remove('lr-chip-active'));
btn.classList.add('lr-chip-active');
choose(container.querySelectorAll('.lr-chip'), btn);
humanCount = parseInt(btn.dataset.humans);
updateSeatPreview();
// Show/hide difficulty based on whether bots exist
......@@ -224,7 +239,8 @@ function renderSetup(el, params) {
if (!preview) return;
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 positions = ['bottom-left', 'top-left', 'top-right', 'bottom-right'];
......@@ -234,14 +250,18 @@ function renderSetup(el, params) {
const active = seats.includes(i);
const isHuman = active && seats.indexOf(i) < humanCount;
const isBot = active && !isHuman;
const who = isHuman ? t('common.player') : isBot ? t('game.bot') : t('ludo.seat_empty');
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-dot">${isHuman ? emoji('person', '👤', 14) : isBot ? emoji('robot', '🤖', 14) : ''}</div>
<div class="lr-seat-label">${active ? labels[i] : '—'}</div>
<div class="lr-seat lr-seat-${positions[i]} ${active ? 'lr-seat-active' : 'lr-seat-empty'}" style="--seat-color:${inks[i]};">
${active ? `
<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>
`;
}).join('')}
<div class="lr-board-center">${emoji('dice', '🎲', 24)}</div>
<div class="lr-board-center" aria-hidden="true">${icon('dice', 22)}</div>
</div>
`;
}
......@@ -285,15 +305,15 @@ function getSeatPositions(playerCount) {
function renderSearching(el, params) {
el.innerHTML = `
<div class="lr-wrap">
<div class="screen lr-wrap lr-searching">
<div class="lr-hero">
<div class="lr-pulse-ring">
<div class="lr-pulse-inner">${emoji('dice', '🎲', 32)}</div>
<div class="lr-pulse-ring" aria-hidden="true">
${[0, 1, 2].map(i => `<span class="deck-back lr-pulse-inner" style="--q:${i};"></span>`).join('')}
</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>
</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>
${getStyles()}
`;
......@@ -306,87 +326,114 @@ function renderSearching(el, params) {
function getStyles() {
return `<style>
.lr-wrap {
display:flex;flex-direction:column;align-items:center;justify-content:center;
height:100%;padding:20px;
background:linear-gradient(180deg, #070d14 0%, var(--bg-inset) 40%, var(--bg-inset) 100%);
overflow-y:auto;
.lr-wrap { display: flex; flex-direction: column; }
/* The game's card, lying on the table under the header. */
.lr-hero { display: flex; justify-content: center; margin: var(--s-2) 0 var(--s-6); }
.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-icon { font-size:56px;margin-bottom:12px;animation:lrFloat 3s ease-in-out infinite; }
.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; }
.lr-buttons { display:flex;flex-direction:column;gap:12px;width:100%;max-width:320px; }
.lr-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);
.lr-game-card .index { font-size: 12px; }
.lr-game-mark { display: flex; }
/* Mode rows: printed card faces, icon on the reading side. */
.lr-buttons { display: flex; flex-direction: column; gap: var(--s-3); }
.lr-mode {
justify-content: flex-start;
gap: var(--s-3);
min-height: 72px;
padding: var(--s-3) var(--s-4);
text-align: start;
}
.lr-btn:active { transform:scale(0.95); }
.lr-btn-primary { background:linear-gradient(135deg,var(--gold) 0%,var(--gold-dark) 100%);color:#1a1a1a; }
.lr-btn-online { background:linear-gradient(135deg,var(--purple) 0%,var(--violet) 100%);color:#fff; }
.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-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-icon { display:flex;flex-shrink:0; }
.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; }
.lr-back { margin-top:16px;font-size:13px;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:8px 16px; }
.lr-section { width:100%;max-width:320px;margin-bottom:16px; }
.lr-section-title { font-size:12px;font-weight:700;color:var(--text-secondary);margin-bottom:8px;text-align:center; }
.lr-grid { display:flex;gap:8px;justify-content:center;flex-wrap:wrap; }
.lr-chip {
display:flex;align-items:center;gap:6px;
padding:10px 16px;border-radius:12px;
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);
}
.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-btn-icon { display: flex; flex-shrink: 0; color: var(--ink); }
.lr-btn-icon.lr-live { color: var(--red-ink); }
.lr-mode-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.lr-btn-label { font-size: var(--fs-md); font-weight: 800; color: var(--ink); }
.lr-btn-desc { font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.lr-chev { color: var(--ink-3); }
/* Option groups. Chips wrap so every choice stays in view. */
.lr-section:first-of-type .lr-section-title { margin-top: var(--s-2); }
.lr-grid { flex-wrap: wrap; overflow: visible; }
.lr-chip { min-height: 44px; }
.lr-chip.lr-chip-active { background: var(--card); color: var(--ink); border-color: var(--card); }
.lr-chip-num { font-size: var(--fs-md); font-weight: 800; }
.lr-chip-label { font-size: var(--fs-sm); }
.lr-pips { display: inline-flex; gap: 2px; color: currentColor; opacity: 0.8; }
.lr-chip-active .lr-pips { color: var(--red-ink); opacity: 1; }
/* Seats: four card-stock squares placed as on the board (which does not
mirror), each with its player's token printed in that player's ink. */
.lr-seat-preview { display: flex; justify-content: center; }
.lr-board-mini {
position:relative;width:140px;height:140px;
background:linear-gradient(135deg,#1a2844,#0f1a30);
border-radius:12px;border:1px solid var(--border);
position: relative;
direction: ltr;
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 {
position:absolute;display:flex;flex-direction:column;align-items:center;gap:2px;
transition:all 0.3s cubic-bezier(0.34,1.56,0.64,1);
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
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-top-left { top:8px;left:8px; }
.lr-seat-top-right { top:8px;right:8px; }
.lr-seat-bottom-right { bottom:8px;right:8px; }
.lr-seat-dot {
width:28px;height:28px;border-radius:50%;
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 {
background: var(--card);
color: var(--ink);
box-shadow: var(--shadow-sm);
outline: 1px solid var(--card-line);
outline-offset: -5px;
}
.lr-seat-active .lr-seat-dot { background:color-mix(in srgb, var(--seat-color) 20%, transparent); }
.lr-seat-empty .lr-seat-dot { opacity:0.2;border-style:dashed; }
.lr-seat-label { font-size:var(--fs-xs);color:var(--text-muted); }
.lr-seat-active .lr-seat-label { color:var(--seat-color); }
.lr-pulse-ring {
width:80px;height:80px;border-radius:50%;
border:3px solid rgba(228,172,56,0.4);
display:flex;align-items:center;justify-content:center;
animation:lrPulse 2s ease-in-out infinite;
margin:0 auto;
.lr-seat-empty {
background-color: var(--back-700);
background-image: var(--back-pattern-dense);
background-size: 14px 14px;
color: var(--text-muted);
}
.lr-seat-token { display: flex; color: var(--seat-color); }
.lr-seat-label { font-size: var(--fs-sm); font-weight: 800; color: inherit; }
.lr-seat-dot { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-xs); font-weight: 500; color: var(--ink-3); }
.lr-board-center {
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)} }
@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)} }
.lr-start { margin-top: var(--s-8); }
/* 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>`;
}
......
// 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 audio from '../../../core/audio.js';
import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js';
import { STOCKFISH_URL } from '../../../core/config.js';
const DIFFICULTY_COLORS = ['var(--success)', 'var(--success)', 'var(--amber)', 'var(--amber)', 'var(--orange)', 'var(--orange)', 'var(--error)'];
import { t, getLang } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, errorState, esc } from '../../../core/ui.js';
export async function mountBotSelect(el, params) {
el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);">
<div style="display:flex;align-items:center;gap:var(--s-3);">
<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>
<h2 style="font-size:var(--bot-title-font);font-weight:700;">${t('play.select_bot')}</h2>
</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 class="screen bs">
${screenHead(t('play.select_bot'), { sub: t('bots.sub') })}
<div id="bots-grid" class="bs-grid" aria-busy="true" aria-label="${t('common.loading')}">
${Array.from({ length: 4 }, () => '<div class="deck-card face-down bs-card bs-placeholder"></div>').join('')}
</div>
</div>
<style>${STYLE}</style>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
wireBack(el);
await loadBots(el, params);
}
async function loadBots(el, params) {
try {
const data = await net.post('bots.php', { action: 'list' });
const bots = data.bots || data || [];
renderBots(el, bots, params);
} 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) {
const ar = getLang() === 'ar';
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) => {
const diffColor = DIFFICULTY_COLORS[Math.min(idx, DIFFICULTY_COLORS.length - 1)];
const initial = (bot.name_ar || bot.name || '?')[0];
const name = botName(bot, ar);
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 `
<div class="card bot-card" data-id="${bot.id}" style="cursor:pointer;padding:var(--s-3);position:relative;overflow:hidden;">
<div style="position:absolute;top:0;left:0;right:0;height:var(--bot-card-accent-height);background:${diffColor};"></div>
<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};">
${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>`}
</div>
<div style="text-align:center;">
<div style="font-size:var(--bot-card-name-font);font-weight:700;">${bot.name_ar || bot.name}</div>
<div style="font-size:var(--bot-card-sub-font);color:var(--text-secondary);margin-top:2px;">${bot.style_ar || bot.style}</div>
<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>
</div>
</div>
`;}).join('');
<button class="deck-card bs-card bot-card dealt ${idx % 2 === 0 ? 'ink-red' : ''}" data-id="${esc(bot.id)}"
style="--deal-rot:${idx % 2 === 0 ? -5 : 5}deg;animation-delay:${idx * 55}ms;" aria-label="${esc(label)}">
<span class="index top" aria-hidden="true">${index}</span>
<span class="bs-face" aria-hidden="true">
<span class="bs-initial">${esc(botInitial(name))}</span>
<span class="bs-name">${esc(name)}</span>
<span class="bs-style">${esc(style)}</span>
${note ? `<span class="bs-note">${esc(note)}</span>` : ''}
</span>
<span class="index bottom" aria-hidden="true">${index}</span>
</button>
`;
}).join('');
grid.querySelectorAll('.bot-card').forEach(card => {
card.addEventListener('click', () => {
......@@ -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';
import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.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) {
el.innerHTML = `
<div class="cf-layout">
<div class="cf-header">
<button id="cf-back" class="cf-back-btn">→</button>
<div class="cf-title">${emoji('challenge_swords', '⚔️', 18)} ${t('challenge.title')}</div>
<div class="screen cf-layout">
${screenHead(t('challenge.title'))}
<div class="cf-body" id="cf-body">${loadingRows(5, 60)}</div>
</div>
<div class="cf-body" id="cf-body">
<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>
<style>${STYLE}</style>
`;
el.querySelector('#cf-back').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
wireBack(el);
loadFriendsForChallenge(el);
}
......@@ -53,13 +29,12 @@ async function loadFriendsForChallenge(el) {
const friends = data.friends || [];
if (friends.length === 0) {
body.innerHTML = `
<div style="text-align:center;padding:48px 24px;">
<div style="font-size:48px;margin-bottom:12px;opacity:0.5;">${emoji('people', '👥', 48)}</div>
<div style="font-size:15px;font-weight:700;color:var(--text-primary);margin-bottom:6px;">${t('challenge.no_friends')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:16px;">${t('challenge.no_friends_hint')}</div>
<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.innerHTML = emptyState({
mark: 'users',
title: t('challenge.no_friends'),
sub: t('challenge.no_friends_hint'),
action: `<button class="btn btn-primary" id="cf-go-social">${icon('search', 18)}${t('social.search_players')}</button>`,
});
body.querySelector('#cf-go-social')?.addEventListener('click', () => scene.push('friends'));
return;
}
......@@ -70,13 +45,13 @@ async function loadFriendsForChallenge(el) {
let html = '';
if (online.length > 0) {
html += `<div class="cf-section-title">${emoji('green_circle', '🟢', 11)} ${t('challenge.online_now', { n: online.length })}</div>`;
html += online.map(f => renderChallengeCard(f, true)).join('');
html += `<h2 class="section-title cf-section-title">${t('challenge.online_now', { n: `<span class="num">${online.length}</span>` })}</h2>`;
html += `<div class="rows">${online.map(f => renderChallengeCard(f, true)).join('')}</div>`;
}
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 += offline.map(f => renderChallengeCard(f, false)).join('');
html += `<h2 class="section-title cf-section-title">${t('challenge.offline')}</h2>`;
html += `<div class="rows">${offline.map(f => renderChallengeCard(f, false)).join('')}</div>`;
}
body.innerHTML = html;
......@@ -108,23 +83,26 @@ async function loadFriendsForChallenge(el) {
});
} 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));
}
}
function renderChallengeCard(f, isOnline) {
const name = f.display_name || f.username || t('common.player');
return `
<div class="cf-card" data-uid="${f.id}">
<div class="cf-card-avatar">
${f.avatar_url ? `<img src="${f.avatar_url}">` : emoji('person', '👤', 22)}
${isOnline ? '<div class="cf-card-online"></div>' : ''}
<div class="row cf-card" data-uid="${esc(f.id)}">
${avatar({ ...f, display_name: name }, 44)}
<div class="row-main">
<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 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>
<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>
`;
}
......@@ -135,38 +113,78 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
const dialog = document.createElement('div');
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 = `
<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 style="width:40px;height:4px;background:var(--border-hover);border-radius:2px;margin:0 auto 16px;"></div>
<div style="text-align:center;margin-bottom:16px;">
<div style="font-size:15px;font-weight:700;color:var(--text-primary);">${t('challenge.title')} ${targetName}</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px;">${t('challenge.select_game')}</div>
<div class="cfo-sheet" role="dialog" aria-modal="true" aria-labelledby="cfo-title">
<div class="cfo-grip" aria-hidden="true"></div>
<div class="cfo-head">
<div class="cfo-title" id="cfo-title">${t('challenge.sheet_title', { name: esc(targetName) })}</div>
<div class="cfo-sub">${t('challenge.select_game')}</div>
</div>
<!-- Game selection -->
<div style="display:flex;gap:8px;margin-bottom:14px;">
<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>
<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" 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>
<div class="cfo-label">${t('challenge.game')}</div>
<div class="cfo-games">
${['chess', 'ludo', 'domino'].map(g => `
<button class="cfo-game ${g === 'chess' ? 'active' : ''}" data-game="${g}" aria-pressed="${g === 'chess'}">
${icon(GAME_MARK[g].icon, 22, { cls: GAME_MARK[g].ink === 'red' ? 'cfo-red' : '' })}<span>${t('game.' + g)}</span>
</button>`).join('')}
</div>
<!-- Time control -->
<div id="cfo-time" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:20px;">
<button class="cfo-tc" data-tc="bullet_1_0">${emoji('lightning', '⚡', 12)} ${t('time.1min')}</button>
<button class="cfo-tc active" data-tc="blitz_3_0">${emoji('fire', '🔥', 12)} ${t('time.3min')}</button>
<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 id="cfo-time" class="cfo-times">
<div class="cfo-label cfo-label-row">${t('challenge.time')}</div>
${[['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 ${tc === 'blitz_3_0' ? 'active' : ''}" data-tc="${tc}" aria-pressed="${tc === 'blitz_3_0'}">${icon(ic, 16)}<span>${t(key)}</span></button>`).join('')}
</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 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-primary btn-lg btn-block" id="cfo-send">${icon('swords', 20)}${t('challenge.send')}</button>
<button class="btn btn-block cfo-ghost" id="cfo-cancel">${t('common.cancel')}</button>
</div>
<style>
@keyframes slideUp { from{transform:translateY(100%)}to{transform:none} }
.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; }
.cfo-tc.active { background:var(--gold);border-color:var(--gold);color:var(--bg-dark); }
.cfo-tc:active { transform:scale(0.93); }
.cfo-scrim {
position: fixed; inset: 0; z-index: 999;
display: flex; align-items: flex-end; justify-content: center;
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>
`;
......@@ -177,8 +195,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelectorAll('.cfo-game').forEach(btn => {
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'); });
btn.style.background = 'var(--chess-primary)'; btn.style.borderColor = 'var(--chess-primary)'; btn.style.color = '#fff'; btn.classList.add('active');
dialog.querySelectorAll('.cfo-game').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true');
selectedGame = btn.dataset.game;
dialog.querySelector('#cfo-time').style.display = (selectedGame === 'ludo' || selectedGame === 'domino') ? 'none' : 'grid';
});
......@@ -186,8 +204,8 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelectorAll('.cfo-tc').forEach(btn => {
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)'; });
btn.classList.add('active'); btn.style.background = 'var(--gold)'; btn.style.borderColor = 'var(--gold)'; btn.style.color = 'var(--bg-dark)';
dialog.querySelectorAll('.cfo-tc').forEach(b => { b.classList.remove('active'); b.setAttribute('aria-pressed', 'false'); });
btn.classList.add('active'); btn.setAttribute('aria-pressed', 'true');
selectedTc = btn.dataset.tc;
});
});
......@@ -240,3 +258,15 @@ function showChallengeOptions(el, targetId, targetName, friendProfile) {
dialog.querySelector('#cfo-cancel').addEventListener('click', () => 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';
import * as store from '../../../core/store.js';
import * as juice from '../../../core/juice.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 matchId = null;
......@@ -36,96 +37,61 @@ export function mountLobby(el, params = {}) {
const friendName = friendProfile?.display_name || friendProfile?.username || t('common.opponent');
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 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 = `
<div class="lobby-layout">
<!-- Header -->
<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>
<div class="screen gl">
${screenHead(t('lobby.title'))}
<!-- Match Info -->
<div class="lobby-match-info">
<div class="lobby-game-badge" style="background:${gameKey === 'chess' ? 'var(--chess-primary)' : gameKey === 'ludo' ? 'var(--purple)' : gameKey === 'backgammon' ? 'var(--amber)' : 'var(--emerald)'};">
<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 class="gl-tags">
<span class="chip active gl-tag">${icon(mark, 16)}${gameLabel}</span>
${gameKey === 'chess' ? `<span class="chip gl-tag">${icon('clock', 16)}${tcLabel}</span>` : ''}
</div>
<!-- Players -->
<div class="lobby-players">
<!-- Host / You -->
<div class="lobby-player-card">
<div class="lobby-avatar ${isHost ? 'host' : ''}">
${myAvatar ? `<img src="${myAvatar}" style="width:100%;height:100%;object-fit:cover;border-radius:50%;">` : emoji('person', '👤', 28)}
</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 class="gl-table">
<div class="deck-card gl-seat dealt" style="--deal-rot:-4deg;">
${avatar({ display_name: myName, avatar_url: myAvatar }, 64)}
<div class="gl-name">${esc(myName)}</div>
<div class="gl-state gl-ok">${icon('check', 14)}${t('lobby.ready')}</div>
${colorTag(color)}
</div>
<!-- VS divider -->
<div class="lobby-vs">
<div class="lobby-vs-circle">VS</div>
</div>
<div class="gl-vs" aria-hidden="true">${icon('swords', 22)}</div>
<!-- Opponent -->
<div class="lobby-player-card">
<div class="lobby-avatar opponent" id="lobby-opponent-avatar">
${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 class="deck-card gl-seat dealt ${isHost ? 'face-down' : ''}" id="lobby-opponent-card" style="--deal-rot:4deg;animation-delay:80ms;">
<div class="gl-opp-avatar" id="lobby-opponent-avatar">
${opponentKnown ? avatar({ display_name: friendName, avatar_url: friendAvatar }, 64) : `<span class="avatar gl-wait-avatar">${icon('hourglass', 26)}</span>`}
</div>
<div class="lobby-player-name" id="lobby-opponent-name">${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>
${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>` : ''}
<div class="gl-name" id="lobby-opponent-name">${esc(isHost ? (friendName || t('lobby.waiting')) : friendName)}</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>
${colorTag(color === 'w' ? 'b' : color === 'b' ? 'w' : '')}
</div>
</div>
<!-- Status -->
<div class="lobby-status" id="lobby-status">
${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 class="gl-status" id="lobby-status" role="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>`}
</div>
<!-- Actions -->
<div class="lobby-actions">
${!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 lobby-btn" id="lobby-cancel">${emoji('exit', '✕', 12)} ${t('common.cancel')}</button>
<div class="gl-actions">
${!isHost ? `<button class="btn btn-primary btn-lg btn-block" id="lobby-start">${icon('play', 20)}${t('lobby.start')}</button>` : ''}
<button class="btn btn-secondary btn-block" id="lobby-cancel">${icon('x', 18)}${t('common.cancel')}</button>
</div>
</div>
<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>
<style>${STYLE}</style>
`;
// 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));
const startBtn = el.querySelector('#lobby-start');
......@@ -140,7 +106,7 @@ export function mountLobby(el, params = {}) {
// Guest: wait briefly so host can detect acceptance, then start
const statusEl = el.querySelector('#lobby-status');
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);
}
......@@ -167,12 +133,19 @@ async function pollMatchStatus(el, params) {
const statusEl = el.querySelector('#lobby-status');
const oppStatus = el.querySelector('#lobby-opponent-status');
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) {
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');
}
// 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');
juice.hapticSuccess();
......@@ -269,3 +242,67 @@ export function unmountLobby() {
if (startTimeout) { clearTimeout(startTimeout); startTimeout = 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';
import * as audio from '../../../core/audio.js';
import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
const QUEUE_TIMEOUT_S = 60;
let unsub = null;
......@@ -23,20 +24,21 @@ export function mountQueue(el, params) {
let seconds = 0;
el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);">
<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 style="width:8px;height:8px;background:var(--gold);border-radius:50%;animation:pulse 1s ease-in-out infinite;"></div>
<div style="position:absolute;inset:0;border-radius:50%;border:2px solid var(--gold);opacity:0;animation:radarPing 2s ease-out infinite;"></div>
<div class="screen q-screen">
<div class="q-table">
<div class="q-deck" aria-hidden="true">
${[0, 1, 2, 3].map(i => `<span class="deck-back q-card" style="--q:${i};"></span>`).join('')}
</div>
<div>
<div style="font-size:16px;font-weight:600;text-align:center;">${t('play.searching')}</div>
<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 class="q-text">
<p class="q-title" role="status">${t('play.searching')}</p>
<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>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
</div>
<style>
@keyframes radarPing { 0% { transform:scale(1);opacity:0.6; } 100% { transform:scale(2);opacity:0; } }
</style>
<button class="btn btn-secondary btn-lg btn-block q-cancel" id="cancel-btn">${icon('x', 18)}${t('play.cancel')}</button>
</div>
<style>${STYLE}</style>
`;
const timerEl = el.querySelector('#queue-timer');
......@@ -155,3 +157,87 @@ function cleanup() {
if (unsub) { clearInterval(unsub); unsub = 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 bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js';
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { screenHead, wireBack } from '../../../core/ui.js';
const categories = [
{
name: 'Bullet', nameKey: 'time.bullet', iconSlot: 'lightning', iconFallback: '⚡', color: 'var(--amber)',
name: 'Bullet', nameKey: 'time.bullet', icon: 'zap',
controls: [
{ key: 'bullet_1_0', labelKey: 'time.1min', sub: '1+0' },
{ key: 'bullet_1_1', label: '1 | 1', sub: '1+1' },
{ key: 'bullet_2_1', label: '2 | 1', sub: '2+1' },
{ key: 'bullet_1_0', sub: '1+0' },
{ key: 'bullet_1_1', sub: '1+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: [
{ key: 'blitz_3_0', labelKey: 'time.3min', sub: '3+0' },
{ key: 'blitz_3_2', label: '3 | 2', sub: '3+2' },
{ key: 'blitz_5_0', labelKey: 'time.5min', sub: '5+0' },
{ key: 'blitz_5_3', label: '5 | 3', sub: '5+3' },
{ key: 'blitz_3_0', sub: '3+0' },
{ key: 'blitz_3_2', sub: '3+2' },
{ key: 'blitz_5_0', sub: '5+0' },
{ 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: [
{ key: 'rapid_10_0', labelKey: 'time.10min', sub: '10+0' },
{ key: 'rapid_10_5', label: '10 | 5', sub: '10+5' },
{ key: 'rapid_15_10', label: '15 | 10', sub: '15+10' },
{ key: 'rapid_30_0', labelKey: 'time.30min', sub: '30+0' },
{ key: 'rapid_10_0', sub: '10+0' },
{ key: 'rapid_10_5', sub: '10+5' },
{ key: 'rapid_15_10', sub: '15+10' },
{ 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: [
{ key: 'classical_60_0', labelKey: 'time.60min', sub: '60+0' },
{ key: 'classical_90_30', label: '90 | 30', sub: '90+30' },
{ key: 'classical_60_0', sub: '60+0' },
{ 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,
// 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.
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 = `
<div class="tc-page" style="padding:16px;display:flex;flex-direction:column;gap:16px;height:100%;overflow-y:auto;">
<div style="display:flex;align-items:center;gap:12px;">
<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>
<div class="screen tc-page">
${screenHead(t('play.select_time'), { sub })}
${categories.map(cat => `
<div class="tc-category">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
<span style="font-size:18px;">${emoji(cat.iconSlot, cat.iconFallback, 18)}</span>
<span style="font-size:14px;font-weight:700;color:${cat.color};">${t(cat.nameKey)}</span>
<span style="font-size:11px;color:var(--text-muted);font-family:var(--font-lat);">${cat.name}</span>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;">
<section class="tc-category" aria-label="${t(cat.nameKey)}">
<h2 class="section-title tc-title">
<span class="tc-title-text">${icon(cat.icon, 18, { cls: 'tc-title-icon' })}${t(cat.nameKey)}</span>
</h2>
<div class="tc-grid">
${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;">
<span style="font-size:14px;font-weight:700;color:var(--text-primary);font-family:var(--font-lat);">${tc.sub}</span>
<span style="font-size:var(--fs-xs);color:var(--text-secondary);">${tc.labelKey ? t(tc.labelKey) : tc.label}</span>
<button class="deck-card time-btn tc-card dealt" data-key="${tc.key}"
style="--deal-rot:${(dealt % 2 ? 3 : -3)}deg;animation-delay:${(dealt++) * 30}ms;">
<span class="num tc-rank">${tc.sub}</span>
<span class="tc-label">${controlLabel(tc.sub)}</span>
</button>
`).join('')}
</div>
</div>
</section>
`).join('')}
</div>
<style>
.time-btn:active { transform:scale(0.93);background:var(--bg-hover); }
.time-btn:hover { border-color:var(--border-hover); }
</style>
<style>${STYLE}</style>
`;
el.querySelector('#back-btn').addEventListener('click', () => {
audio.play('click');
scene.pop();
});
wireBack(el);
el.querySelectorAll('.time-btn').forEach(btn => {
btn.addEventListener('click', () => {
......@@ -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