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:
parent
892235cdaf
commit
6c01d7e4e0
5 changed files with 139 additions and 58 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue