5
diff --git a/frontend/src/game/renderer.ts b/frontend/src/game/renderer.ts
index ceb7557..157b270 100644
--- a/frontend/src/game/renderer.ts
+++ b/frontend/src/game/renderer.ts
@@ -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';
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index bb71089..cb126f0 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -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
(id: string): T {
const menuModal = requireEl('intro-modal');
const resultModal = requireEl('result-modal');
-const themeGridEl = requireEl('theme-grid');
const menuSoloSection = requireEl('menu-solo');
-const soloThemePickerWrap = requireEl('solo-theme-picker');
const soloBotDifficultyWrap = requireEl('solo-bot-difficulty');
const soloTimerMount = requireEl('solo-timer-mount');
const startBtn = requireEl('start-btn');
@@ -28,6 +25,7 @@ const helpBtn = requireEl('help-btn');
const muteBtn = requireEl('mute-btn');
const themeNameBadge = requireEl('theme-name-badge');
const changeThemeBtn = requireEl('change-theme-btn');
+const backToMenuBtn = requireEl('back-to-menu-btn');
const privateRoomMount = requireEl('private-room-mount');
const previewElements = {
@@ -37,6 +35,12 @@ const previewElements = {
canvas: requireEl('preview-canvas'),
};
+const themePickerElements = {
+ overlay: requireEl('theme-picker-modal'),
+ gridMount: requireEl('theme-picker-grid'),
+ cancelBtn: requireEl('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') },
);
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 302de4f..17bf340 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -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; }
diff --git a/frontend/src/ui/soloConfigFlow.ts b/frontend/src/ui/soloConfigFlow.ts
index 8ba81d6..09211de 100644
--- a/frontend/src/ui/soloConfigFlow.ts
+++ b/frontend/src/ui/soloConfigFlow.ts
@@ -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;
+
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(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(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(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(themeModeInputs, 'random');
const opponent = checkedValue(opponentInputs, 'alone');
const botDifficulty = checkedValue(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);
}