feat(frontend): add solo mode config flow (time/theme/opponent)
This commit is contained in:
parent
906fe59add
commit
5162f090cb
4 changed files with 277 additions and 18 deletions
|
|
@ -18,6 +18,8 @@
|
|||
</span>
|
||||
<h1>Theme<span>Guess</span></h1>
|
||||
</div>
|
||||
|
||||
<div id="solo-timer-mount"></div>
|
||||
<button id="help-btn" class="theme-badge" title="Change theme / how to play">
|
||||
🎮 <strong id="theme-name-badge">Gruvbox Dark</strong>
|
||||
</button>
|
||||
|
|
@ -68,8 +70,46 @@
|
|||
<section id="menu-solo" class="menu-section" aria-label="Solo">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<button id="start-btn" class="btn primary">▶ PLAY</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { THEMES } from './data/themes';
|
|||
import { sound } from './engine/sound';
|
||||
import { createThemeGrid } from './ui/themeGrid';
|
||||
import { runThemePreview } from './ui/previewFlow';
|
||||
import type { ThemeId } from './types';
|
||||
import { createSoloConfigFlow } from './ui/soloConfigFlow';
|
||||
|
||||
function requireEl<T extends HTMLElement>(id: string): T {
|
||||
const el = document.getElementById(id);
|
||||
|
|
@ -15,6 +15,10 @@ function requireEl<T extends HTMLElement>(id: string): T {
|
|||
const menuModal = requireEl<HTMLElement>('intro-modal');
|
||||
const resultModal = requireEl<HTMLElement>('result-modal');
|
||||
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 enterCodeBtn = requireEl<HTMLButtonElement>('enter-code-btn');
|
||||
const createRoomBtn = requireEl<HTMLButtonElement>('create-room-btn');
|
||||
|
|
@ -51,21 +55,30 @@ let game: ThemeGuessGame | null = null;
|
|||
|
||||
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
|
||||
|
||||
// Solo is the only flow this ticket wires end-to-end. Ticket #17 will
|
||||
// replace this direct "pick a theme, hit play" shortcut with a fuller solo
|
||||
// config flow inside #menu-solo; keep it working in the meantime.
|
||||
function playSolo(): void {
|
||||
const themeId: ThemeId = themeGrid.selected;
|
||||
const soloConfigFlow = createSoloConfigFlow(
|
||||
{
|
||||
section: menuSoloSection,
|
||||
themePickerWrap: soloThemePickerWrap,
|
||||
botDifficultyWrap: soloBotDifficultyWrap,
|
||||
playBtn: startBtn,
|
||||
timerMount: soloTimerMount,
|
||||
},
|
||||
themeGrid,
|
||||
(config) => {
|
||||
showView(null);
|
||||
runThemePreview(previewElements, themeId, () => {
|
||||
if (game) game.setTheme(themeId);
|
||||
else game = new ThemeGuessGame(themeId);
|
||||
themeNameBadge.textContent = THEMES[themeId].name;
|
||||
runThemePreview(previewElements, config.themeId, () => {
|
||||
if (game) {
|
||||
game.setTheme(config.themeId);
|
||||
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());
|
||||
}
|
||||
|
||||
startBtn.addEventListener('click', playSolo);
|
||||
},
|
||||
);
|
||||
|
||||
// TODO(#17/#15/#14): wire real flow
|
||||
enterCodeBtn.addEventListener('click', () => console.log('[menu] enter code clicked (not implemented yet)'));
|
||||
|
|
|
|||
|
|
@ -131,6 +131,21 @@ body {
|
|||
}
|
||||
.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-wrap {
|
||||
|
|
@ -419,6 +434,27 @@ body {
|
|||
|
||||
#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) {
|
||||
.menu-sections { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
|
|
|||
170
frontend/src/ui/soloConfigFlow.ts
Normal file
170
frontend/src/ui/soloConfigFlow.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue