feat(frontend): standalone theme-picker modal, offline anti-repeat, dimmer opponent green

- Solo theme choice moves out of the settings menu into its own
  post-Play modal; Play Again reopens it for chosen-theme rounds and
  instantly rerolls (never repeating the last theme) for random rounds.
- Last solo/bot theme played persists in localStorage so a fresh
  random pick or page reload doesn't immediately repeat it.
- Result modal swaps Change Theme for a Back to Menu button for as
  long as a solo/bot round is active.
- Darkened the opponent-progress fill color (renderer.ts).
This commit is contained in:
Gabriel Franco 2026-09-13 18:07:04 -03:00
parent 892235cdaf
commit 6c01d7e4e0
5 changed files with 139 additions and 58 deletions

View file

@ -105,11 +105,6 @@
</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">
@ -190,11 +185,22 @@
<div class="modal-actions">
<button id="close-result-btn" class="btn ghost">Keep Comparing</button>
<button id="change-theme-btn" class="btn ghost">🔄 Change Theme</button>
<button id="back-to-menu-btn" class="btn ghost hidden">🏠 Back to Menu</button>
<button id="play-again-btn" class="btn primary">Play Again</button>
</div>
</div>
</div>
<div id="theme-picker-modal" class="modal-overlay hidden">
<div class="modal">
<h2>🎨 CHOOSE A THEME</h2>
<div id="theme-picker-grid" class="theme-grid"></div>
<div class="modal-actions">
<button id="theme-picker-cancel-btn" class="btn ghost">Cancel</button>
</div>
</div>
</div>
<div id="preview-overlay" class="modal-overlay hidden">
<div class="preview-box">
<div class="preview-countdown" id="preview-countdown">5</div>

View file

@ -70,7 +70,7 @@ export function drawFrame(state: RenderState, now: number): void {
* actual color is never transmitted or known here (see PROTOCOL.md
* `round:progress`), so this is the only color this view can ever show
* for a picked category. */
export const OPPONENT_ASSIGNED_HEX = '#5fd75f';
export const OPPONENT_ASSIGNED_HEX = '#3e8c3e';
/** Flat grey used for categories the opponent hasn't picked yet. */
export const OPPONENT_UNASSIGNED_HEX = '#3a3a52';

View file

@ -1,6 +1,5 @@
import './style.css';
import { sound } from './engine/sound';
import { createThemeGrid } from './ui/themeGrid';
import { createSoloConfigFlow } from './ui/soloConfigFlow';
import { createMatchmakingFlow } from './ui/matchmakingFlow';
import { createPrivateRoomFlow } from './ui/privateRoomFlow';
@ -14,9 +13,7 @@ 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');
@ -28,6 +25,7 @@ const helpBtn = requireEl<HTMLButtonElement>('help-btn');
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
const backToMenuBtn = requireEl<HTMLButtonElement>('back-to-menu-btn');
const privateRoomMount = requireEl<HTMLElement>('private-room-mount');
const previewElements = {
@ -37,6 +35,12 @@ const previewElements = {
canvas: requireEl<HTMLCanvasElement>('preview-canvas'),
};
const themePickerElements = {
overlay: requireEl<HTMLElement>('theme-picker-modal'),
gridMount: requireEl<HTMLElement>('theme-picker-grid'),
cancelBtn: requireEl<HTMLButtonElement>('theme-picker-cancel-btn'),
};
/** Top-level overlay screens the composition root switches between. At most
* one is visible at a time; the game board underneath is always present and
* simply gets obscured while a screen is shown. */
@ -53,19 +57,18 @@ function showView(name: ScreenName | null): void {
}
}
const themeGrid = createThemeGrid(themeGridEl, 'gruvbox', () => sound.playOpen());
createSoloConfigFlow(
{
section: menuSoloSection,
themePickerWrap: soloThemePickerWrap,
botDifficultyWrap: soloBotDifficultyWrap,
playBtn: startBtn,
timerMount: soloTimerMount,
themeNameBadge,
preview: previewElements,
themePicker: themePickerElements,
changeThemeBtn,
backToMenuBtn,
},
themeGrid,
{ hideMenu: () => showView(null), showMenu: () => showView('menu') },
);

View file

@ -482,13 +482,6 @@ body {
}
.modal ol { padding-left: 20px; line-height: 1.7; color: var(--fg-dim); font-size: 13px; }
.modal p { color: var(--fg-dim); font-size: 13px; }
.pick-theme-label {
font-family: var(--pixel);
font-size: 9px;
color: var(--cyan);
text-align: center;
margin: 18px 0 10px;
}
.theme-grid {
display: grid;
@ -592,8 +585,6 @@ body {
animation: revealPulse 1.6s steps(2) infinite;
}
#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; }
@ -613,7 +604,7 @@ body {
.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; }
#solo-bot-difficulty.hidden { display: none; }
@media (max-width: 640px) {
.menu-sections { grid-template-columns: 1fr; }

View file

@ -8,7 +8,7 @@ import type { ThemeGuessGame } from '../game/ThemeGuessGame';
import type { ThemeId } from '../types';
import type { PreviewFlowElements } from './previewFlow';
import { runThemePreview } from './previewFlow';
import type { ThemeGrid } from './themeGrid';
import { createThemeGrid } from './themeGrid';
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
* handed straight to `computeBotSchedule` once the bot opponent path
@ -26,13 +26,16 @@ export interface SoloConfig {
botDifficulty: BotDifficulty;
}
/** Everything a round needs except the theme itself — resolved from the
* menu form (or carried over from `lastConfig` on "Play Again") before
* the theme-picker modal supplies the last piece. */
type PendingSoloConfig = Omit<SoloConfig, 'themeId' | 'snippetIndex'>;
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;
@ -43,6 +46,19 @@ export interface SoloConfigFlowElements {
themeNameBadge: HTMLElement;
/** Mounts for the pre-round "flash the real theme" overlay. */
preview: PreviewFlowElements;
/** Standalone "pick a theme" modal shown after Play or Play Again in
* chosen-theme mode — never inline in the settings menu. */
themePicker: {
overlay: HTMLElement;
gridMount: HTMLElement;
cancelBtn: HTMLButtonElement;
};
/** Result-modal button used by every non-solo match; hidden for as
* long as a solo/bot round is active, in favor of `backToMenuBtn`. */
changeThemeBtn: HTMLButtonElement;
/** Result-modal button shown in `changeThemeBtn`'s place once a
* solo/bot round has started. */
backToMenuBtn: HTMLButtonElement;
}
export interface SoloConfigFlowHooks {
@ -85,6 +101,43 @@ function checkedValue<T extends string>(inputs: HTMLInputElement[], fallback: T)
return (inputs.find((input) => input.checked)?.value as T | undefined) ?? fallback;
}
/** Offline (solo/bot) anti-repeat memory: the last theme actually
* played, persisted so a page reload doesn't immediately re-serve it.
* Multiplayer theme selection (server-assigned or voted) never touches
* this key. */
const LAST_THEME_STORAGE_KEY = 'theme-guess:last-solo-theme';
function isThemeId(value: string): value is ThemeId {
return value in THEMES;
}
function readLastTheme(): ThemeId | null {
try {
const stored = window.localStorage.getItem(LAST_THEME_STORAGE_KEY);
return stored && isThemeId(stored) ? stored : null;
} catch {
return null;
}
}
function writeLastTheme(themeId: ThemeId): void {
try {
window.localStorage.setItem(LAST_THEME_STORAGE_KEY, themeId);
} catch {
// Storage unavailable (private browsing, disabled) — anti-repeat
// just resets every page load instead of persisting across them.
}
}
/** Picks a random theme other than `exclude`, falling back to the full
* pool if excluding it would leave nothing to pick from. */
function pickRandomTheme(exclude: ThemeId | null): ThemeId {
const ids = Object.keys(THEMES) as ThemeId[];
const pool = exclude ? ids.filter((id) => id !== exclude) : ids;
const candidates = pool.length > 0 ? pool : ids;
return candidates[Math.floor(Math.random() * candidates.length)];
}
interface TimerHud {
/** Starts (or restarts) the countdown/elapsed-time display. `onExpire`,
* on the two clocked modes only, fires exactly once when the countdown
@ -154,13 +207,13 @@ function createTimerHud(mount: HTMLElement, onQuit: () => void): TimerHud {
}
/** Wires the Solo menu section's config form (time mode / theme mode /
* opponent, with the theme grid and bot difficulty picker revealed
* conditionally), its inline timer/quit HUD, and every shared
* result-modal button ("Keep Comparing" / "Play Again") for as long as a
* solo round is the thing driving the shared board — see `soloActive`. */
* opponent, with the bot difficulty picker revealed conditionally and
* theme choice deferred to a standalone modal), its inline timer/quit
* HUD, and every shared result-modal button ("Keep Comparing" / "Play
* Again" / the change-theme/back-to-menu swap) for as long as a solo
* round is the thing driving the shared board — see `soloActive`. */
export function createSoloConfigFlow(
elements: SoloConfigFlowElements,
themeGrid: ThemeGrid,
hooks: SoloConfigFlowHooks,
): void {
const themeModeInputs = radios(elements.section, 'solo-theme-mode');
@ -173,6 +226,10 @@ export function createSoloConfigFlow(
let lastConfig: SoloConfig | null = null;
let activeGame: ThemeGuessGame | null = null;
/** The non-theme settings a theme-picker-modal selection will complete
* into a full round — set whenever the modal opens, cleared once it's
* dismissed (by pick or Cancel). */
let pendingConfig: PendingSoloConfig | null = null;
/** True from the moment a solo round starts until the player quits —
* guards the shared, page-global result-modal buttons and the Quit
* button so a stale click from a leftover matchmaking/private-room
@ -181,19 +238,36 @@ export function createSoloConfigFlow(
const timerHud = createTimerHud(elements.timerMount, quit);
function syncThemePicker(): void {
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
elements.themePickerWrap.classList.toggle('hidden', mode !== 'choose');
const themeGrid = createThemeGrid(elements.themePicker.gridMount, readLastTheme() ?? 'gruvbox', (themeId) => {
sound.playOpen();
if (!pendingConfig) return;
const config = pendingConfig;
closeThemePicker();
beginRound({ ...config, themeId, snippetIndex: pickRandomSnippetIndex() });
});
void themeGrid;
function openThemePicker(config: PendingSoloConfig): void {
pendingConfig = config;
elements.themePicker.overlay.classList.remove('hidden');
}
function closeThemePicker(): void {
pendingConfig = null;
elements.themePicker.overlay.classList.add('hidden');
}
elements.themePicker.cancelBtn.addEventListener('click', () => {
closeThemePicker();
hooks.showMenu();
});
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();
/** Starts (or restarts) a live round from a fully-resolved config:
@ -202,6 +276,9 @@ export function createSoloConfigFlow(
function beginRound(config: SoloConfig): void {
lastConfig = config;
soloActive = true;
writeLastTheme(config.themeId);
elements.changeThemeBtn.classList.add('hidden');
elements.backToMenuBtn.classList.remove('hidden');
hooks.hideMenu();
runThemePreview(elements.preview, config.themeId, () => {
const game = getSharedGame(config.themeId, config.snippetIndex, (id, hex) => botMatch.handleLocalAssignment(id, hex));
@ -235,20 +312,15 @@ export function createSoloConfigFlow(
const themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy');
const base: PendingSoloConfig = { timeMode, themeMode, opponent, botDifficulty };
const themeIds = Object.keys(THEMES) as ThemeId[];
const themeId = themeMode === 'choose'
? themeGrid.selected
: themeIds[Math.floor(Math.random() * themeIds.length)];
if (themeMode === 'choose') {
hooks.hideMenu();
openThemePicker(base);
return;
}
beginRound({
themeId,
snippetIndex: pickRandomSnippetIndex(),
timeMode,
themeMode,
opponent,
botDifficulty,
});
beginRound({ ...base, themeId: pickRandomTheme(readLastTheme()), snippetIndex: pickRandomSnippetIndex() });
});
/** "Keep Comparing": lets the player keep tweaking colors after a
@ -264,32 +336,41 @@ export function createSoloConfigFlow(
}
/** "Play Again": a random-theme round immediately rerolls a new theme
* and snippet and starts over, with no extra input needed. A
* chosen-theme round instead reopens the menu (already sitting on
* "choose" with the theme grid visible) so the player picks their
* next theme, same as starting any other chosen-theme round. */
* (never repeating the last one played) and snippet, and starts over
* with no extra input needed. A chosen-theme round instead reopens the
* standalone theme-picker modal, carrying over every other setting, so
* the player picks their next theme the same way a fresh round does. */
function playAgain(): void {
if (!soloActive || !lastConfig) return;
const base: PendingSoloConfig = {
timeMode: lastConfig.timeMode,
themeMode: lastConfig.themeMode,
opponent: lastConfig.opponent,
botDifficulty: lastConfig.botDifficulty,
};
if (lastConfig.themeMode === 'choose') {
hooks.showMenu();
openThemePicker(base);
return;
}
const themeIds = Object.keys(THEMES) as ThemeId[];
const themeId = themeIds[Math.floor(Math.random() * themeIds.length)];
beginRound({ ...lastConfig, themeId, snippetIndex: pickRandomSnippetIndex() });
beginRound({ ...base, themeId: pickRandomTheme(readLastTheme()), snippetIndex: pickRandomSnippetIndex() });
}
/** Ends the active solo round and returns to the main menu — wired to
* the timer HUD's own Quit button, which is visible for as long as a
* solo round is live (menu, mid-round, and after a reveal alike). */
* the timer HUD's own Quit button (visible for as long as a solo round
* is live) and the result modal's "Back to Menu" button. */
function quit(): void {
if (!soloActive) return;
soloActive = false;
botMatch.stop();
timerHud.hide();
elements.changeThemeBtn.classList.remove('hidden');
elements.backToMenuBtn.classList.add('hidden');
hooks.showMenu();
}
elements.backToMenuBtn.addEventListener('click', quit);
closeResultBtn.addEventListener('click', keepComparing);
playAgainBtn.addEventListener('click', playAgain);
}