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'
}
};
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
// 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; }
`;
This diff is collapsed.
This diff is collapsed.
......@@ -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