feat(frontend): add solo mode config flow (time/theme/opponent)

This commit is contained in:
Gabriel Franco 2026-09-10 11:54:48 -03:00
parent 906fe59add
commit 5162f090cb
4 changed files with 277 additions and 18 deletions

View file

@ -18,6 +18,8 @@
</span> </span>
<h1>Theme<span>Guess</span></h1> <h1>Theme<span>Guess</span></h1>
</div> </div>
<div id="solo-timer-mount"></div>
<button id="help-btn" class="theme-badge" title="Change theme / how to play"> <button id="help-btn" class="theme-badge" title="Change theme / how to play">
🎮 <strong id="theme-name-badge">Gruvbox Dark</strong> 🎮 <strong id="theme-name-badge">Gruvbox Dark</strong>
</button> </button>
@ -68,8 +70,46 @@
<section id="menu-solo" class="menu-section" aria-label="Solo"> <section id="menu-solo" class="menu-section" aria-label="Solo">
<h3>🧑‍💻 SOLO</h3> <h3>🧑‍💻 SOLO</h3>
<p class="menu-section-desc">Click any word to color it, then reveal how close your palette lands to the real theme.</p> <p class="menu-section-desc">Click any word to color it, then reveal how close your palette lands to the real theme.</p>
<p class="pick-theme-label">▼ PICK A THEME ▼</p>
<div id="theme-grid" class="theme-grid"></div> <div class="solo-config-group">
<p class="solo-config-label">⏱ TIME</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="1min" checked />1 MIN</label>
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="2min" />2 MIN</label>
<label class="solo-radio"><input type="radio" name="solo-time-mode" value="none" />NO LIMIT</label>
</div>
</div>
<div class="solo-config-group">
<p class="solo-config-label">🎨 THEME</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="solo-theme-mode" value="random" checked />RANDOM</label>
<label class="solo-radio"><input type="radio" name="solo-theme-mode" value="choose" />CHOOSE</label>
</div>
</div>
<div id="solo-theme-picker" class="hidden">
<p class="pick-theme-label">▼ PICK A THEME ▼</p>
<div id="theme-grid" class="theme-grid"></div>
</div>
<div class="solo-config-group">
<p class="solo-config-label">🤖 OPPONENT</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="solo-opponent-mode" value="alone" checked />ALONE</label>
<label class="solo-radio"><input type="radio" name="solo-opponent-mode" value="bot" />VS BOT</label>
</div>
</div>
<div id="solo-bot-difficulty" class="hidden">
<p class="solo-config-label">DIFFICULTY</p>
<div class="solo-radio-row">
<label class="solo-radio"><input type="radio" name="solo-bot-difficulty" value="easy" checked />EASY</label>
<label class="solo-radio"><input type="radio" name="solo-bot-difficulty" value="medium" />MEDIUM</label>
<label class="solo-radio"><input type="radio" name="solo-bot-difficulty" value="hard" />HARD</label>
</div>
</div>
<div class="menu-section-actions"> <div class="menu-section-actions">
<button id="start-btn" class="btn primary">▶ PLAY</button> <button id="start-btn" class="btn primary">▶ PLAY</button>
</div> </div>

View file

@ -4,7 +4,7 @@ import { THEMES } from './data/themes';
import { sound } from './engine/sound'; import { sound } from './engine/sound';
import { createThemeGrid } from './ui/themeGrid'; import { createThemeGrid } from './ui/themeGrid';
import { runThemePreview } from './ui/previewFlow'; import { runThemePreview } from './ui/previewFlow';
import type { ThemeId } from './types'; import { createSoloConfigFlow } from './ui/soloConfigFlow';
function requireEl<T extends HTMLElement>(id: string): T { function requireEl<T extends HTMLElement>(id: string): T {
const el = document.getElementById(id); const el = document.getElementById(id);
@ -15,6 +15,10 @@ function requireEl<T extends HTMLElement>(id: string): T {
const menuModal = requireEl<HTMLElement>('intro-modal'); const menuModal = requireEl<HTMLElement>('intro-modal');
const resultModal = requireEl<HTMLElement>('result-modal'); const resultModal = requireEl<HTMLElement>('result-modal');
const themeGridEl = requireEl<HTMLElement>('theme-grid'); const themeGridEl = requireEl<HTMLElement>('theme-grid');
const menuSoloSection = requireEl<HTMLElement>('menu-solo');
const soloThemePickerWrap = requireEl<HTMLElement>('solo-theme-picker');
const soloBotDifficultyWrap = requireEl<HTMLElement>('solo-bot-difficulty');
const soloTimerMount = requireEl<HTMLElement>('solo-timer-mount');
const startBtn = requireEl<HTMLButtonElement>('start-btn'); const startBtn = requireEl<HTMLButtonElement>('start-btn');
const enterCodeBtn = requireEl<HTMLButtonElement>('enter-code-btn'); const enterCodeBtn = requireEl<HTMLButtonElement>('enter-code-btn');
const createRoomBtn = requireEl<HTMLButtonElement>('create-room-btn'); const createRoomBtn = requireEl<HTMLButtonElement>('create-room-btn');
@ -51,21 +55,30 @@ let game: ThemeGuessGame | null = null;
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen()); const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
// Solo is the only flow this ticket wires end-to-end. Ticket #17 will const soloConfigFlow = createSoloConfigFlow(
// replace this direct "pick a theme, hit play" shortcut with a fuller solo {
// config flow inside #menu-solo; keep it working in the meantime. section: menuSoloSection,
function playSolo(): void { themePickerWrap: soloThemePickerWrap,
const themeId: ThemeId = themeGrid.selected; botDifficultyWrap: soloBotDifficultyWrap,
showView(null); playBtn: startBtn,
runThemePreview(previewElements, themeId, () => { timerMount: soloTimerMount,
if (game) game.setTheme(themeId); },
else game = new ThemeGuessGame(themeId); themeGrid,
themeNameBadge.textContent = THEMES[themeId].name; (config) => {
sound.playApply(); showView(null);
}, () => sound.playOpen()); runThemePreview(previewElements, config.themeId, () => {
} if (game) {
game.setTheme(config.themeId);
startBtn.addEventListener('click', playSolo); game.setSnippet(config.snippetIndex);
} else {
game = new ThemeGuessGame(config.themeId, config.snippetIndex);
}
themeNameBadge.textContent = THEMES[config.themeId].name;
sound.playApply();
soloConfigFlow.startTimer(config.timeMode);
}, () => sound.playOpen());
},
);
// TODO(#17/#15/#14): wire real flow // TODO(#17/#15/#14): wire real flow
enterCodeBtn.addEventListener('click', () => console.log('[menu] enter code clicked (not implemented yet)')); enterCodeBtn.addEventListener('click', () => console.log('[menu] enter code clicked (not implemented yet)'));

View file

@ -131,6 +131,21 @@ body {
} }
.icon-btn:hover { transform: translate(-1px, -1px); } .icon-btn:hover { transform: translate(-1px, -1px); }
.solo-timer-hud {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--pixel);
font-size: 9px;
color: var(--fg);
background: var(--panel-2);
border: 3px solid var(--ink);
padding: 8px 12px;
box-shadow: 3px 3px 0 var(--ink);
}
.solo-timer-hud.hidden { display: none; }
.solo-timer-badge { color: var(--cyan); }
/* ---------- progress ---------- */ /* ---------- progress ---------- */
.progress-wrap { .progress-wrap {
@ -419,6 +434,27 @@ body {
#menu-solo .theme-grid { margin-bottom: 0; } #menu-solo .theme-grid { margin-bottom: 0; }
.solo-config-group { display: flex; flex-direction: column; gap: 6px; }
.solo-config-label { margin: 0; font-family: var(--pixel); font-size: 9px; color: var(--fg-dim); letter-spacing: .5px; }
.solo-radio-row { display: flex; gap: 6px; flex-wrap: wrap; }
.solo-radio {
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
flex: 1 1 auto;
background: var(--panel);
border: 2px solid var(--ink);
padding: 6px 8px;
font-size: 10px;
color: var(--fg-dim);
cursor: pointer;
}
.solo-radio:has(input:checked) { background: var(--panel-3); color: var(--fg); border-color: var(--yellow); }
.solo-radio input { accent-color: var(--yellow); margin: 0; }
#solo-theme-picker.hidden, #solo-bot-difficulty.hidden { display: none; }
@media (max-width: 640px) { @media (max-width: 640px) {
.menu-sections { grid-template-columns: 1fr; } .menu-sections { grid-template-columns: 1fr; }
} }

View file

@ -0,0 +1,170 @@
import { THEMES } from '../data/themes';
import { pickRandomSnippetIndex } from '../data/snippets';
import type { BotDifficulty, BotTimeMode } from '../engine/bot';
import type { ThemeId } from '../types';
import type { ThemeGrid } from './themeGrid';
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
* handed straight to `computeBotSchedule` once the bot opponent path
* (#19) is wired up. */
export type SoloTimeMode = BotTimeMode;
export type SoloOpponent = 'alone' | 'bot';
type SoloThemeMode = 'random' | 'choose';
export interface SoloConfig {
themeId: ThemeId;
snippetIndex: number;
timeMode: SoloTimeMode;
opponent: SoloOpponent;
botDifficulty: BotDifficulty;
}
export interface SoloConfigFlowElements {
/** Root of the #menu-solo section; radios are looked up inside it by
* `name` so this module owns its own markup instead of main.ts wiring
* every individual input. */
section: HTMLElement;
/** Wrapper around the reused theme grid, shown only in "choose" mode. */
themePickerWrap: HTMLElement;
/** Wrapper around the difficulty radios, shown only in "vs bot" mode. */
botDifficultyWrap: HTMLElement;
playBtn: HTMLButtonElement;
/** Empty mount point (outside the menu modal) the inline timer/mode
* badge HUD renders itself into. */
timerMount: HTMLElement;
}
export interface SoloConfigFlow {
/** Starts the inline countdown/elapsed-time HUD for a just-started
* round. Call once the round is actually live (e.g. after the theme
* preview finishes), not at Play-click time. */
startTimer(mode: SoloTimeMode): void;
}
const TIME_MODE_DEADLINE_MS: Record<Exclude<SoloTimeMode, 'none'>, number> = {
'1min': 60_000,
'2min': 120_000,
};
const TIME_MODE_LABEL: Record<SoloTimeMode, string> = {
'1min': '1 MIN',
'2min': '2 MIN',
none: 'NO LIMIT',
};
function formatClock(ms: number): string {
const totalSeconds = Math.max(0, Math.round(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function radios(section: HTMLElement, name: string): HTMLInputElement[] {
return Array.from(section.querySelectorAll<HTMLInputElement>(`input[name="${name}"]`));
}
function checkedValue<T extends string>(inputs: HTMLInputElement[], fallback: T): T {
return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback;
}
/** Minimal inline countdown (1/2 minute modes) or elapsed-time counter
* ("no limit") plus a mode badge, rendered into `mount`. Purely a
* display: it never forces a reveal, since `ThemeGuessGame.reveal()`
* stays manually triggered by the existing Reveal button in every time
* mode, "no limit" included.
* TODO: replace with shared timerHud module once available. */
function createTimerHud(mount: HTMLElement): SoloConfigFlow['startTimer'] {
mount.innerHTML = `
<div class="solo-timer-hud hidden">
<span class="solo-timer-badge"></span>
<span class="solo-timer-clock">00:00</span>
</div>`;
const root = mount.querySelector<HTMLElement>('.solo-timer-hud')!;
const badge = mount.querySelector<HTMLElement>('.solo-timer-badge')!;
const clock = mount.querySelector<HTMLElement>('.solo-timer-clock')!;
let intervalId: number | null = null;
function stop(): void {
if (intervalId !== null) {
window.clearInterval(intervalId);
intervalId = null;
}
}
return function startTimer(mode: SoloTimeMode): void {
stop();
root.classList.remove('hidden');
badge.textContent = TIME_MODE_LABEL[mode];
const startedAt = performance.now();
function tick(): void {
const elapsed = performance.now() - startedAt;
if (mode === 'none') {
clock.textContent = formatClock(elapsed);
return;
}
const remaining = TIME_MODE_DEADLINE_MS[mode] - elapsed;
clock.textContent = formatClock(remaining);
if (remaining <= 0) stop();
}
tick();
intervalId = window.setInterval(tick, 250);
};
}
/** Wires the Solo menu section's config form (time mode / theme mode /
* opponent, with the theme grid and bot difficulty picker revealed
* conditionally) and its inline timer HUD. Fires `onStart` with the
* resolved config once the player clicks Play. */
export function createSoloConfigFlow(
elements: SoloConfigFlowElements,
themeGrid: ThemeGrid,
onStart: (config: SoloConfig) => void,
): SoloConfigFlow {
const themeModeInputs = radios(elements.section, 'solo-theme-mode');
const opponentInputs = radios(elements.section, 'solo-opponent-mode');
const timeModeInputs = radios(elements.section, 'solo-time-mode');
const botDifficultyInputs = radios(elements.section, 'solo-bot-difficulty');
const startTimer = createTimerHud(elements.timerMount);
function syncThemePicker(): void {
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
elements.themePickerWrap.classList.toggle('hidden', mode !== 'choose');
}
function syncBotDifficulty(): void {
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
elements.botDifficultyWrap.classList.toggle('hidden', opponent !== 'bot');
}
for (const input of themeModeInputs) input.addEventListener('change', syncThemePicker);
for (const input of opponentInputs) input.addEventListener('change', syncBotDifficulty);
syncThemePicker();
syncBotDifficulty();
elements.playBtn.addEventListener('click', () => {
const timeMode = checkedValue<SoloTimeMode>(timeModeInputs, '1min');
const themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy');
const themeIds = Object.keys(THEMES) as ThemeId[];
const themeId = themeMode === 'choose'
? themeGrid.selected
: themeIds[Math.floor(Math.random() * themeIds.length)];
// TODO(#19): wire bot engine — opponent/botDifficulty are captured
// and handed off here, but nothing yet drives a live bot opponent
// from them; "vs Bot" currently plays identically to "Alone".
onStart({
themeId,
snippetIndex: pickRandomSnippetIndex(),
timeMode,
opponent,
botDifficulty,
});
});
return { startTimer };
}