Commit c284b158 authored by Mahmoud Aglan's avatar Mahmoud Aglan

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

- Chess bots are court cards: the bot's initial printed large in red or black
  ink by strength, name plate, a one-line personality, the rating band as the
  corner index; the strongest carries a crown.
- Time controls are printed cards grouped by bullet, blitz, rapid, classical.
- Matchmaking shows a face-down deck riffling while the clock counts.
- Challenge-a-friend rows, a card-stock options sheet; the pre-game lobby shows
  you and your opponent as two player cards, the opponent face down until they
  accept. The lobby's clock chip now shows the real control; every classical
  control used to read 30 minutes.
- Ludo, domino and backgammon rooms: chip groups printed on card stock when
  chosen, ludo seats in each player's ink, one red start button. Domino bot
  setup is one screen (level and target, then start) instead of two steps.
- PRODUCT.md: El3ab is a game, not an app; binding for every screen.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 83ceec2a
...@@ -61,6 +61,12 @@ championship with a live broadcast. ...@@ -61,6 +61,12 @@ championship with a live broadcast.
## Brand Commitments ## Brand Commitments
- **El3ab is a mobile game, not an app and not a website** (owner, 2026-10-19,
binding). Everything must feel like a game: every touch answers with motion,
sound and haptics; screens are dealt, flipped and slid like cards; rewards,
wins, ranks and matches are celebrated; nothing sits dead. Judge every screen
by "would this feel at home in a polished mobile game?", never by app
conventions.
- Name: EL3AB / العب. - Name: EL3AB / العب.
- The crown mark (a king-like crown with diamond accents) is kept; source files in - The crown mark (a king-like crown with diamond accents) is kept; source files in
`Logo El3ab/`. Exists in gold and in a cyan-to-blue gradient. `Logo El3ab/`. Exists in gold and in a cyan-to-blue gradient.
...@@ -77,6 +83,7 @@ championship with a live broadcast. ...@@ -77,6 +83,7 @@ championship with a live broadcast.
## Product Principles ## Product Principles
0. It is a game. Feedback, juice and ceremony are features, not decoration.
1. Game in thirty seconds: anything that adds steps before the first move must earn it. 1. Game in thirty seconds: anything that adds steps before the first move must earn it.
2. The table is sacred: nothing competes with the board while a game is on. 2. The table is sacred: nothing competes with the board while a game is on.
3. Competition carries prestige: a rating, a rank and a result should feel earned. 3. Competition carries prestige: a rating, a rank and a result should feel earned.
......
...@@ -68,6 +68,8 @@ async function run() { ...@@ -68,6 +68,8 @@ async function run() {
const btns = [...document.querySelectorAll('[data-level], button')]; const btns = [...document.querySelectorAll('[data-level], button')];
const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط')); const mid = btns.find(b => b.dataset?.level === 'intermediate' || b.textContent?.includes('متوسط'));
if (mid) mid.click(); if (mid) mid.click();
// The bot setup is one screen since the 2026-10 redesign: pick, then start.
document.querySelector('#btn-start')?.click();
}); });
await wait(3000); await wait(3000);
} }
......
...@@ -742,7 +742,30 @@ const strings = { ...@@ -742,7 +742,30 @@ const strings = {
'home.streak': 'سلسلة {n} يوم', 'home.streak': 'سلسلة {n} يوم',
'home.daily_sub': 'عملات كل يوم', 'home.daily_sub': 'عملات كل يوم',
'home.daily_ready': 'جاهزة: {n} عملة', 'home.daily_ready': 'جاهزة: {n} عملة',
'home.achievements_sub': 'أوسمتك وتقدّمك' 'home.achievements_sub': 'أوسمتك وتقدّمك',
'bots.sub': 'اختر خصمًا يناسب مستواك',
'bots.rating_band': 'تقييم {min} إلى {max}',
'bots.strongest': 'الأقوى',
'bots.note_amina': 'ما زالت تتعلّم، وتخطئ كثيرًا',
'bots.note_tarek': 'يبيّت ملكه مبكرًا ويحمي قطعه',
'bots.note_nour': 'تضحّي بقطعها لتصل إلى ملكك',
'bots.note_omar': 'يمسك المركز ويكسب النهايات',
'bots.note_layla': 'تكتيكات وتضحيات لا تتوقّعها',
'bots.note_ziad': 'بلا نقاط ضعف، ونادرًا ما يخطئ',
'bots.note_grandmaster': 'قوة ستوكفيش كاملة، لا يغفر غلطة',
'play.tc_2min': 'دقيقتان',
'play.tc_90min': '90 دقيقة',
'play.tc_inc': '{n} ث',
'queue.hint': 'تبدأ المباراة فور العثور على خصم',
'queue.elapsed': 'مدة الانتظار',
'challenge.action': 'تحدّي',
'challenge.sheet_title': 'تحدّي {name}',
'challenge.game': 'اللعبة',
'challenge.time': 'الوقت',
'lobby.white': 'بالأبيض',
'lobby.black': 'بالأسود',
'ludo.seat_empty': 'فارغ',
'domino.target': 'هدف المباراة'
}, },
en: { en: {
'app.name': 'EL3AB', 'app.name': 'EL3AB',
...@@ -1485,7 +1508,30 @@ const strings = { ...@@ -1485,7 +1508,30 @@ const strings = {
'home.streak': '{n}-day streak', 'home.streak': '{n}-day streak',
'home.daily_sub': 'Coins every day', 'home.daily_sub': 'Coins every day',
'home.daily_ready': 'Ready: {n} coins', 'home.daily_ready': 'Ready: {n} coins',
'home.achievements_sub': 'Your badges and progress' 'home.achievements_sub': 'Your badges and progress',
'bots.sub': 'Pick an opponent at your level',
'bots.rating_band': 'Rated {min} to {max}',
'bots.strongest': 'Strongest',
'bots.note_amina': 'Still learning, and blunders often',
'bots.note_tarek': 'Castles early and guards every piece',
'bots.note_nour': 'Gives up pieces to reach your king',
'bots.note_omar': 'Holds the centre, wins endgames',
'bots.note_layla': 'Tactics and sacrifices you won\'t see coming',
'bots.note_ziad': 'No weak spots, rarely blunders',
'bots.note_grandmaster': 'Full Stockfish strength. Forgives nothing',
'play.tc_2min': '2 min',
'play.tc_90min': '90 min',
'play.tc_inc': '{n}s',
'queue.hint': 'The game starts as soon as someone is found',
'queue.elapsed': 'Time waiting',
'challenge.action': 'Challenge',
'challenge.sheet_title': 'Challenge {name}',
'challenge.game': 'Game',
'challenge.time': 'Time',
'lobby.white': 'White',
'lobby.black': 'Black',
'ludo.seat_empty': 'Empty',
'domino.target': 'Match target'
} }
}; };
......
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 scene from '../../../core/scene.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as net from '../../../core/net.js'; import * as net from '../../../core/net.js';
import { t } from '../../../core/i18n.js'; import { t, getLang } from '../../../core/i18n.js';
import { STOCKFISH_URL } from '../../../core/config.js'; import { icon } from '../../../core/icons.js';
import { screenHead, wireBack, errorState, esc } from '../../../core/ui.js';
const DIFFICULTY_COLORS = ['var(--success)', 'var(--success)', 'var(--amber)', 'var(--amber)', 'var(--orange)', 'var(--orange)', 'var(--error)'];
export async function mountBotSelect(el, params) { export async function mountBotSelect(el, params) {
el.innerHTML = ` el.innerHTML = `
<div style="padding:var(--s-4);display:flex;flex-direction:column;gap:var(--s-4);"> <div class="screen bs">
<div style="display:flex;align-items:center;gap:var(--s-3);"> ${screenHead(t('play.select_bot'), { sub: t('bots.sub') })}
<button class="btn btn-secondary" id="back-btn" style="width:var(--bot-back-btn-size);height:var(--bot-back-btn-size);padding:0;font-size:var(--bot-back-btn-font);">←</button> <div id="bots-grid" class="bs-grid" aria-busy="true" aria-label="${t('common.loading')}">
<h2 style="font-size:var(--bot-title-font);font-weight:700;">${t('play.select_bot')}</h2> ${Array.from({ length: 4 }, () => '<div class="deck-card face-down bs-card bs-placeholder"></div>').join('')}
</div>
<div id="bots-grid" style="display:grid;grid-template-columns:1fr 1fr;gap:var(--bot-grid-gap);">
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
<div class="skeleton" style="height:140px;border-radius:var(--r-xl);"></div>
</div> </div>
</div> </div>
<style>${STYLE}</style>
`; `;
el.querySelector('#back-btn').addEventListener('click', () => { wireBack(el);
audio.play('click'); await loadBots(el, params);
scene.pop(); }
});
async function loadBots(el, params) {
try { try {
const data = await net.post('bots.php', { action: 'list' }); const data = await net.post('bots.php', { action: 'list' });
const bots = data.bots || data || []; const bots = data.bots || data || [];
renderBots(el, bots, params); renderBots(el, bots, params);
} catch (e) { } catch (e) {
el.querySelector('#bots-grid').innerHTML = `<p style="color:var(--text-secondary);grid-column:1/-1;text-align:center;">${t('common.error')}</p>`; const grid = el.querySelector('#bots-grid');
grid.removeAttribute('aria-busy');
grid.innerHTML = `<div class="bs-error">${errorState(t('common.error_load'), 'bs-retry')}</div>`;
grid.querySelector('#bs-retry')?.addEventListener('click', () => {
audio.play('click');
loadBots(el, params);
});
} }
} }
/** The bot's own name without its epithet ("أمينة المبتدئة" -> "أمينة"). */
function botName(bot, ar) {
const full = ar ? (bot.name_ar || bot.name || '') : (bot.name || bot.name_ar || '');
if (!ar) return full;
const words = full.trim().split(/\s+/);
return words.length > 1 && !words[0].startsWith('ال') ? words[0] : full;
}
function botStyle(bot, ar) {
if (ar && bot.style_ar) return bot.style_ar;
return String(bot.style || bot.style_ar || '').replace(/_/g, ' ');
}
/** The letter printed on the face: the first letter of the name, past any article. */
function botInitial(name) {
const n = name.trim().replace(/^ال(?=\S{2})/, '');
return n.charAt(0) || '';
}
/** A note written for each bot; a bot the app has not met yet keeps its own bio. */
function botNote(bot, ar) {
const key = 'bots.note_' + bot.id;
const note = t(key);
if (note !== key) return note;
return (ar ? bot.bio_ar || bot.bio : bot.bio || bot.bio_ar) || '';
}
function renderBots(el, bots, params) { function renderBots(el, bots, params) {
const ar = getLang() === 'ar';
const grid = el.querySelector('#bots-grid'); const grid = el.querySelector('#bots-grid');
const strongest = bots.reduce((top, b) => (Number(b.elo_max) > Number(top?.elo_max ?? -1) ? b : top), null);
grid.removeAttribute('aria-busy');
grid.removeAttribute('aria-label');
grid.innerHTML = bots.map((bot, idx) => { grid.innerHTML = bots.map((bot, idx) => {
const diffColor = DIFFICULTY_COLORS[Math.min(idx, DIFFICULTY_COLORS.length - 1)]; const name = botName(bot, ar);
const initial = (bot.name_ar || bot.name || '?')[0]; const style = botStyle(bot, ar);
const note = botNote(bot, ar);
const crowned = bot === strongest && bots.length > 1;
const index = `
<span class="num">${esc(bot.elo_min)}</span>
<span class="bs-to" aria-hidden="true"></span>
<span class="num">${esc(bot.elo_max)}</span>
${crowned ? `<span class="bs-crown">${icon('crown', 14)}</span>` : ''}`;
const label = [
ar ? (bot.name_ar || bot.name) : (bot.name || bot.name_ar),
style,
t('bots.rating_band', { min: bot.elo_min, max: bot.elo_max }),
crowned ? t('bots.strongest') : '',
].filter(Boolean).join('، ');
return ` return `
<div class="card bot-card" data-id="${bot.id}" style="cursor:pointer;padding:var(--s-3);position:relative;overflow:hidden;"> <button class="deck-card bs-card bot-card dealt ${idx % 2 === 0 ? 'ink-red' : ''}" data-id="${esc(bot.id)}"
<div style="position:absolute;top:0;left:0;right:0;height:var(--bot-card-accent-height);background:${diffColor};"></div> style="--deal-rot:${idx % 2 === 0 ? -5 : 5}deg;animation-delay:${idx * 55}ms;" aria-label="${esc(label)}">
<div style="width:var(--bot-card-avatar-size);height:var(--bot-card-avatar-size);border-radius:var(--r-full);background:var(--bg-elevated);margin:var(--s-2) auto var(--s-2);display:flex;align-items:center;justify-content:center;font-size:20px;overflow:hidden;border:var(--bot-card-avatar-border) solid ${diffColor};"> <span class="index top" aria-hidden="true">${index}</span>
${bot.portrait_url ? `<img src="${STOCKFISH_URL}${bot.portrait_url}" style="width:100%;height:100%;object-fit:cover;" onerror="this.style.display='none';this.parentNode.querySelector('.bot-fallback').style.display='flex'"><span class="bot-fallback" style="display:none;font-size:22px;font-weight:800;color:${diffColor};">${initial}</span>` : `<span style="font-size:22px;font-weight:800;color:${diffColor};">${initial}</span>`} <span class="bs-face" aria-hidden="true">
</div> <span class="bs-initial">${esc(botInitial(name))}</span>
<div style="text-align:center;"> <span class="bs-name">${esc(name)}</span>
<div style="font-size:var(--bot-card-name-font);font-weight:700;">${bot.name_ar || bot.name}</div> <span class="bs-style">${esc(style)}</span>
<div style="font-size:var(--bot-card-sub-font);color:var(--text-secondary);margin-top:2px;">${bot.style_ar || bot.style}</div> ${note ? `<span class="bs-note">${esc(note)}</span>` : ''}
<div style="font-size:var(--bot-card-sub-font);color:${diffColor};font-weight:600;margin-top:4px;font-family:var(--font-lat);">${bot.elo_min}-${bot.elo_max}</div> </span>
</div> <span class="index bottom" aria-hidden="true">${index}</span>
</div> </button>
`;}).join(''); `;
}).join('');
grid.querySelectorAll('.bot-card').forEach(card => { grid.querySelectorAll('.bot-card').forEach(card => {
card.addEventListener('click', () => { card.addEventListener('click', () => {
...@@ -63,3 +114,72 @@ function renderBots(el, bots, params) { ...@@ -63,3 +114,72 @@ function renderBots(el, bots, params) {
}); });
}); });
} }
const STYLE = `
.bs-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--s-3);
}
/* The face is centred in the space above the bottom index: the initial is
narrow enough to rise between the top corners, the note is not. */
.bs-card {
aspect-ratio: 1 / 1.38;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px var(--s-3) 46px;
text-align: center;
}
/* An odd last card (the strongest, usually) sits centred under the pair. */
.bs-card:last-child:nth-child(odd) {
grid-column: 1 / -1;
justify-self: center;
width: calc(50% - var(--s-3) / 2);
}
.bs-placeholder { box-shadow: var(--shadow-sm); }
.bs-card .index { font-size: 12px; gap: 2px; }
.bs-card .index .num { font-size: 12px; letter-spacing: -0.01em; }
.bs-to { width: 10px; height: 1px; background: currentColor; opacity: 0.6; }
.bs-crown { display: flex; margin-top: 3px; color: var(--gold-ink); }
.bs-face {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
min-width: 0;
max-width: 100%;
}
.bs-initial {
font-size: clamp(40px, 12vw, 52px);
font-weight: 800;
line-height: 1.35;
color: var(--suit);
}
.bs-name {
font-size: var(--fs-md);
font-weight: 800;
line-height: 1.2;
color: var(--ink);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-style { font-size: var(--fs-xs); font-weight: 600; color: var(--suit); }
.bs-note {
margin-top: 6px;
font-size: var(--fs-xs);
line-height: 1.4;
color: var(--ink-3);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-wrap: balance;
}
.bs-error { grid-column: 1 / -1; }
`;
This diff is collapsed.
This diff is collapsed.
...@@ -4,6 +4,7 @@ import * as net from '../../../core/net.js'; ...@@ -4,6 +4,7 @@ import * as net from '../../../core/net.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import * as modal from '../../../core/modal.js'; import * as modal from '../../../core/modal.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon, GAME_MARK } from '../../../core/icons.js';
const QUEUE_TIMEOUT_S = 60; const QUEUE_TIMEOUT_S = 60;
let unsub = null; let unsub = null;
...@@ -23,20 +24,21 @@ export function mountQueue(el, params) { ...@@ -23,20 +24,21 @@ export function mountQueue(el, params) {
let seconds = 0; let seconds = 0;
el.innerHTML = ` el.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:var(--s-6);"> <div class="screen q-screen">
<div class="queue-radar" style="width:120px;height:120px;border-radius:50%;border:3px solid var(--gold);position:relative;display:flex;align-items:center;justify-content:center;"> <div class="q-table">
<div style="width:8px;height:8px;background:var(--gold);border-radius:50%;animation:pulse 1s ease-in-out infinite;"></div> <div class="q-deck" aria-hidden="true">
<div style="position:absolute;inset:0;border-radius:50%;border:2px solid var(--gold);opacity:0;animation:radarPing 2s ease-out infinite;"></div> ${[0, 1, 2, 3].map(i => `<span class="deck-back q-card" style="--q:${i};"></span>`).join('')}
</div> </div>
<div> <div class="q-text">
<div style="font-size:16px;font-weight:600;text-align:center;">${t('play.searching')}</div> <p class="q-title" role="status">${t('play.searching')}</p>
<div id="queue-timer" style="font-size:24px;font-weight:700;color:var(--gold);text-align:center;margin-top:var(--s-2);font-family:var(--font-lat);">0:00</div> <div id="queue-timer" class="num q-timer" role="timer" aria-label="${t('queue.elapsed')}">0:00</div>
<div class="q-chip-row">${matchChip(params)}</div>
<p class="q-hint">${t('queue.hint')}</p>
</div> </div>
<button class="btn btn-secondary" id="cancel-btn">${t('play.cancel')}</button>
</div> </div>
<style> <button class="btn btn-secondary btn-lg btn-block q-cancel" id="cancel-btn">${icon('x', 18)}${t('play.cancel')}</button>
@keyframes radarPing { 0% { transform:scale(1);opacity:0.6; } 100% { transform:scale(2);opacity:0; } } </div>
</style> <style>${STYLE}</style>
`; `;
const timerEl = el.querySelector('#queue-timer'); const timerEl = el.querySelector('#queue-timer');
...@@ -155,3 +157,87 @@ function cleanup() { ...@@ -155,3 +157,87 @@ function cleanup() {
if (unsub) { clearInterval(unsub); unsub = null; } if (unsub) { clearInterval(unsub); unsub = null; }
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; } if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
} }
/** "blitz_5_3" -> "5+3"; anything else (standard, undefined) -> ''. */
function clockOf(tc) {
const m = /^[a-z]+_(\d+)_(\d+)$/.exec(tc || '');
return m ? `${m[1]}+${m[2]}` : '';
}
/** The game being searched for, and its clock or table size, as one chip. */
function matchChip(params = {}) {
const game = GAME_MARK[params.game] ? params.game : 'chess';
const parts = [`${icon(GAME_MARK[game].icon, 16)}<span>${t('game.' + game)}</span>`];
const clock = clockOf(params.timeControl);
if (clock) parts.push(`<span class="num">${clock}</span>`);
else if (game === 'ludo' && params.playerCount) parts.push(`${icon('users', 15)}<span class="num">${Number(params.playerCount)}</span>`);
else if (game === 'backgammon' && params.variant) parts.push(`<span>${t('backgammon.' + params.variant)}</span>`);
return `<span class="chip q-chip">${parts.join('<span class="q-sep" aria-hidden="true"></span>')}</span>`;
}
// The wait is a deck being shuffled on the table: the packet splits in two,
// the halves riffle back together, and the deck squares up. Slow, and still
// when the player asks for less motion.
const STYLE = `
.q-screen {
min-height: 100%;
display: flex;
flex-direction: column;
padding-top: var(--s-8);
padding-bottom: var(--s-6);
}
.q-table {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--s-6);
text-align: center;
}
.q-deck {
display: grid;
place-items: center;
width: 220px;
height: 170px;
background: radial-gradient(closest-side, color-mix(in srgb, var(--sky) 12%, transparent), transparent);
}
.q-card {
grid-area: 1 / 1;
width: 72px;
height: 100px;
border-radius: 9px;
box-shadow: var(--shadow-md);
outline: 1px solid var(--back-line-strong);
outline-offset: -5px;
transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg));
animation: qRiffle 3.2s var(--ease-in-out) infinite;
animation-delay: calc(var(--q) * 70ms);
}
.q-card:nth-child(odd) { --dx: -46px; --tilt: -10deg; }
.q-card:nth-child(even) { --dx: 46px; --tilt: 10deg; }
@keyframes qRiffle {
0%, 12% { transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg)); }
34% { transform: translate(var(--dx), -10px) rotate(var(--tilt)); }
50% { transform: translate(var(--dx), -4px) rotate(calc(var(--tilt) * 0.6)); }
72%, 100% { transform: translateY(calc(var(--q) * -2px)) rotate(calc((var(--q) - 1.5) * 1.5deg)); }
}
@media (prefers-reduced-motion: reduce) {
.q-card { animation: none; transform: translateX(calc((var(--q) - 1.5) * 10px)) rotate(calc((var(--q) - 1.5) * 5deg)); }
}
.q-text { display: flex; flex-direction: column; align-items: center; gap: var(--s-2); }
.q-title { font-size: var(--fs-md); font-weight: 700; color: var(--text-primary); }
.q-timer {
font-size: var(--fs-3xl);
font-weight: 800;
line-height: 1.1;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.q-chip-row { display: flex; justify-content: center; margin-top: var(--s-1); }
.q-chip { cursor: default; color: var(--text-primary); }
.q-sep { width: 1px; height: 14px; background: var(--back-line-strong); }
.q-hint { font-size: var(--fs-sm); color: var(--text-muted); max-width: 30ch; margin-top: var(--s-1); }
.q-cancel { margin-top: var(--s-6); }
`;
// Choose a time control. Each control is a small printed card: the clock is
// its rank ("5+3"), and the minutes and increment are spelled out under it.
import * as scene from '../../../core/scene.js'; import * as scene from '../../../core/scene.js';
import * as bus from '../../../core/bus.js';
import * as audio from '../../../core/audio.js'; import * as audio from '../../../core/audio.js';
import { emoji } from '../../../core/theme.js';
import { t } from '../../../core/i18n.js'; import { t } from '../../../core/i18n.js';
import { icon } from '../../../core/icons.js';
import { screenHead, wireBack } from '../../../core/ui.js';
const categories = [ const categories = [
{ {
name: 'Bullet', nameKey: 'time.bullet', iconSlot: 'lightning', iconFallback: '⚡', color: 'var(--amber)', name: 'Bullet', nameKey: 'time.bullet', icon: 'zap',
controls: [ controls: [
{ key: 'bullet_1_0', labelKey: 'time.1min', sub: '1+0' }, { key: 'bullet_1_0', sub: '1+0' },
{ key: 'bullet_1_1', label: '1 | 1', sub: '1+1' }, { key: 'bullet_1_1', sub: '1+1' },
{ key: 'bullet_2_1', label: '2 | 1', sub: '2+1' }, { key: 'bullet_2_1', sub: '2+1' },
] ]
}, },
{ {
name: 'Blitz', nameKey: 'time.blitz', iconSlot: 'fire', iconFallback: '🔥', color: 'var(--orange)', name: 'Blitz', nameKey: 'time.blitz', icon: 'flame',
controls: [ controls: [
{ key: 'blitz_3_0', labelKey: 'time.3min', sub: '3+0' }, { key: 'blitz_3_0', sub: '3+0' },
{ key: 'blitz_3_2', label: '3 | 2', sub: '3+2' }, { key: 'blitz_3_2', sub: '3+2' },
{ key: 'blitz_5_0', labelKey: 'time.5min', sub: '5+0' }, { key: 'blitz_5_0', sub: '5+0' },
{ key: 'blitz_5_3', label: '5 | 3', sub: '5+3' }, { key: 'blitz_5_3', sub: '5+3' },
] ]
}, },
{ {
name: 'Rapid', nameKey: 'time.rapid', iconSlot: 'runner', iconFallback: '🏃', color: 'var(--green)', name: 'Rapid', nameKey: 'time.rapid', icon: 'timer',
controls: [ controls: [
{ key: 'rapid_10_0', labelKey: 'time.10min', sub: '10+0' }, { key: 'rapid_10_0', sub: '10+0' },
{ key: 'rapid_10_5', label: '10 | 5', sub: '10+5' }, { key: 'rapid_10_5', sub: '10+5' },
{ key: 'rapid_15_10', label: '15 | 10', sub: '15+10' }, { key: 'rapid_15_10', sub: '15+10' },
{ key: 'rapid_30_0', labelKey: 'time.30min', sub: '30+0' }, { key: 'rapid_30_0', sub: '30+0' },
] ]
}, },
{ {
name: 'Classical', nameKey: 'time.classical', iconSlot: 'crown', iconFallback: '♔', color: 'var(--purple)', name: 'Classical', nameKey: 'time.classical', icon: 'crown-line',
controls: [ controls: [
{ key: 'classical_60_0', labelKey: 'time.60min', sub: '60+0' }, { key: 'classical_60_0', sub: '60+0' },
{ key: 'classical_90_30', label: '90 | 30', sub: '90+30' }, { key: 'classical_90_30', sub: '90+30' },
] ]
} }
]; ];
// The minutes as words. Existing keys first; 2 and 90 have their own.
const MINUTE_KEYS = { 1: 'time.1min', 2: 'play.tc_2min', 3: 'time.3min', 5: 'time.5min', 10: 'time.10min', 15: 'time.15min', 30: 'time.30min', 60: 'time.60min', 90: 'play.tc_90min' };
function controlLabel(sub) {
const [min, inc] = sub.split('+').map(Number);
const minutes = MINUTE_KEYS[min] ? t(MINUTE_KEYS[min]) : String(min);
if (!inc) return `<span>${minutes}</span>`;
return `<span>${minutes}</span><span>${t('play.tc_inc', { n: `<span class="num">+${inc}</span>` })}</span>`;
}
// Only the values in the public.time_control enum are offered. blitz_5_5, // Only the values in the public.time_control enum are offered. blitz_5_5,
// rapid_20_0 and classical_45_45 used to appear here; the database rejects them, // rapid_20_0 and classical_45_45 used to appear here; the database rejects them,
// so choosing one created no match and dropped the player back to the menu. // so choosing one created no match and dropped the player back to the menu.
export function mountTimeSelect(el, params) { export function mountTimeSelect(el, params) {
const sub = params?.mode === 'bot' || params?.mode === 'local' ? t('play.vs_bot') : t('play.vs_human');
let dealt = 0;
el.innerHTML = ` el.innerHTML = `
<div class="tc-page" style="padding:16px;display:flex;flex-direction:column;gap:16px;height:100%;overflow-y:auto;"> <div class="screen tc-page">
<div style="display:flex;align-items:center;gap:12px;"> ${screenHead(t('play.select_time'), { sub })}
<button class="btn btn-secondary" id="back-btn" style="width:36px;height:36px;padding:0;border-radius:50%;">←</button>
<h2 style="font-size:18px;font-weight:700;color:var(--text-primary);">${t('play.select_time')}</h2>
</div>
${categories.map(cat => ` ${categories.map(cat => `
<div class="tc-category"> <section class="tc-category" aria-label="${t(cat.nameKey)}">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;"> <h2 class="section-title tc-title">
<span style="font-size:18px;">${emoji(cat.iconSlot, cat.iconFallback, 18)}</span> <span class="tc-title-text">${icon(cat.icon, 18, { cls: 'tc-title-icon' })}${t(cat.nameKey)}</span>
<span style="font-size:14px;font-weight:700;color:${cat.color};">${t(cat.nameKey)}</span> </h2>
<span style="font-size:11px;color:var(--text-muted);font-family:var(--font-lat);">${cat.name}</span> <div class="tc-grid">
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px;">
${cat.controls.map(tc => ` ${cat.controls.map(tc => `
<button class="time-btn" data-key="${tc.key}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;height:58px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;cursor:pointer;transition:transform 0.1s,background 0.15s,border-color 0.15s;"> <button class="deck-card time-btn tc-card dealt" data-key="${tc.key}"
<span style="font-size:14px;font-weight:700;color:var(--text-primary);font-family:var(--font-lat);">${tc.sub}</span> style="--deal-rot:${(dealt % 2 ? 3 : -3)}deg;animation-delay:${(dealt++) * 30}ms;">
<span style="font-size:var(--fs-xs);color:var(--text-secondary);">${tc.labelKey ? t(tc.labelKey) : tc.label}</span> <span class="num tc-rank">${tc.sub}</span>
<span class="tc-label">${controlLabel(tc.sub)}</span>
</button> </button>
`).join('')} `).join('')}
</div> </div>
</div> </section>
`).join('')} `).join('')}
</div> </div>
<style> <style>${STYLE}</style>
.time-btn:active { transform:scale(0.93);background:var(--bg-hover); }
.time-btn:hover { border-color:var(--border-hover); }
</style>
`; `;
el.querySelector('#back-btn').addEventListener('click', () => { wireBack(el);
audio.play('click');
scene.pop();
});
el.querySelectorAll('.time-btn').forEach(btn => { el.querySelectorAll('.time-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
...@@ -94,3 +99,46 @@ export function mountTimeSelect(el, params) { ...@@ -94,3 +99,46 @@ export function mountTimeSelect(el, params) {
}); });
}); });
} }
const STYLE = `
.tc-category + .tc-category { margin-top: var(--s-2); }
.tc-category:first-of-type .tc-title { margin-top: var(--s-2); }
.tc-title-text { display: inline-flex; align-items: center; gap: var(--s-2); }
.tc-title-icon { color: var(--text-muted); }
.tc-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--s-2);
}
.tc-card {
--r-card: 12px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 2px;
min-height: 84px;
padding: var(--s-4) 4px var(--s-3);
text-align: center;
}
.tc-card::before { inset: 5px; }
.tc-rank {
font-size: var(--fs-lg);
font-weight: 800;
line-height: 1.15;
color: var(--ink);
letter-spacing: -0.02em;
white-space: nowrap;
}
.tc-label {
display: flex;
flex-direction: column;
align-items: center;
font-size: var(--fs-xs);
font-weight: 500;
line-height: 1.35;
color: var(--ink-3);
white-space: nowrap;
}
`;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment