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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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)'));
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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