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>
|
</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">
|
<div class="solo-config-group">
|
||||||
<p class="solo-config-label">🤖 OPPONENT</p>
|
<p class="solo-config-label">🤖 OPPONENT</p>
|
||||||
<div class="solo-radio-row">
|
<div class="solo-radio-row">
|
||||||
|
|
@ -190,11 +185,22 @@
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button id="close-result-btn" class="btn ghost">Keep Comparing</button>
|
<button id="close-result-btn" class="btn ghost">Keep Comparing</button>
|
||||||
<button id="change-theme-btn" class="btn ghost">🔄 Change Theme</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>
|
<button id="play-again-btn" class="btn primary">Play Again</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 id="preview-overlay" class="modal-overlay hidden">
|
||||||
<div class="preview-box">
|
<div class="preview-box">
|
||||||
<div class="preview-countdown" id="preview-countdown">5</div>
|
<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
|
* actual color is never transmitted or known here (see PROTOCOL.md
|
||||||
* `round:progress`), so this is the only color this view can ever show
|
* `round:progress`), so this is the only color this view can ever show
|
||||||
* for a picked category. */
|
* 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. */
|
/** Flat grey used for categories the opponent hasn't picked yet. */
|
||||||
export const OPPONENT_UNASSIGNED_HEX = '#3a3a52';
|
export const OPPONENT_UNASSIGNED_HEX = '#3a3a52';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import './style.css';
|
import './style.css';
|
||||||
import { sound } from './engine/sound';
|
import { sound } from './engine/sound';
|
||||||
import { createThemeGrid } from './ui/themeGrid';
|
|
||||||
import { createSoloConfigFlow } from './ui/soloConfigFlow';
|
import { createSoloConfigFlow } from './ui/soloConfigFlow';
|
||||||
import { createMatchmakingFlow } from './ui/matchmakingFlow';
|
import { createMatchmakingFlow } from './ui/matchmakingFlow';
|
||||||
import { createPrivateRoomFlow } from './ui/privateRoomFlow';
|
import { createPrivateRoomFlow } from './ui/privateRoomFlow';
|
||||||
|
|
@ -14,9 +13,7 @@ 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 menuSoloSection = requireEl<HTMLElement>('menu-solo');
|
const menuSoloSection = requireEl<HTMLElement>('menu-solo');
|
||||||
const soloThemePickerWrap = requireEl<HTMLElement>('solo-theme-picker');
|
|
||||||
const soloBotDifficultyWrap = requireEl<HTMLElement>('solo-bot-difficulty');
|
const soloBotDifficultyWrap = requireEl<HTMLElement>('solo-bot-difficulty');
|
||||||
const soloTimerMount = requireEl<HTMLElement>('solo-timer-mount');
|
const soloTimerMount = requireEl<HTMLElement>('solo-timer-mount');
|
||||||
const startBtn = requireEl<HTMLButtonElement>('start-btn');
|
const startBtn = requireEl<HTMLButtonElement>('start-btn');
|
||||||
|
|
@ -28,6 +25,7 @@ const helpBtn = requireEl<HTMLButtonElement>('help-btn');
|
||||||
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
|
const muteBtn = requireEl<HTMLButtonElement>('mute-btn');
|
||||||
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
|
const themeNameBadge = requireEl<HTMLElement>('theme-name-badge');
|
||||||
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
|
const changeThemeBtn = requireEl<HTMLButtonElement>('change-theme-btn');
|
||||||
|
const backToMenuBtn = requireEl<HTMLButtonElement>('back-to-menu-btn');
|
||||||
const privateRoomMount = requireEl<HTMLElement>('private-room-mount');
|
const privateRoomMount = requireEl<HTMLElement>('private-room-mount');
|
||||||
|
|
||||||
const previewElements = {
|
const previewElements = {
|
||||||
|
|
@ -37,6 +35,12 @@ const previewElements = {
|
||||||
canvas: requireEl<HTMLCanvasElement>('preview-canvas'),
|
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
|
/** 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
|
* one is visible at a time; the game board underneath is always present and
|
||||||
* simply gets obscured while a screen is shown. */
|
* 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(
|
createSoloConfigFlow(
|
||||||
{
|
{
|
||||||
section: menuSoloSection,
|
section: menuSoloSection,
|
||||||
themePickerWrap: soloThemePickerWrap,
|
|
||||||
botDifficultyWrap: soloBotDifficultyWrap,
|
botDifficultyWrap: soloBotDifficultyWrap,
|
||||||
playBtn: startBtn,
|
playBtn: startBtn,
|
||||||
timerMount: soloTimerMount,
|
timerMount: soloTimerMount,
|
||||||
themeNameBadge,
|
themeNameBadge,
|
||||||
preview: previewElements,
|
preview: previewElements,
|
||||||
|
themePicker: themePickerElements,
|
||||||
|
changeThemeBtn,
|
||||||
|
backToMenuBtn,
|
||||||
},
|
},
|
||||||
themeGrid,
|
|
||||||
{ hideMenu: () => showView(null), showMenu: () => showView('menu') },
|
{ 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 ol { padding-left: 20px; line-height: 1.7; color: var(--fg-dim); font-size: 13px; }
|
||||||
.modal p { 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 {
|
.theme-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|
@ -592,8 +585,6 @@ body {
|
||||||
animation: revealPulse 1.6s steps(2) infinite;
|
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-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-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-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:has(input:checked) { background: var(--panel-3); color: var(--fg); border-color: var(--yellow); }
|
||||||
.solo-radio input { accent-color: var(--yellow); margin: 0; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.menu-sections { grid-template-columns: 1fr; }
|
.menu-sections { grid-template-columns: 1fr; }
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import type { ThemeGuessGame } from '../game/ThemeGuessGame';
|
||||||
import type { ThemeId } from '../types';
|
import type { ThemeId } from '../types';
|
||||||
import type { PreviewFlowElements } from './previewFlow';
|
import type { PreviewFlowElements } from './previewFlow';
|
||||||
import { runThemePreview } 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
|
/** Reuses `engine/bot`'s time-mode vocabulary so a solo config can be
|
||||||
* handed straight to `computeBotSchedule` once the bot opponent path
|
* handed straight to `computeBotSchedule` once the bot opponent path
|
||||||
|
|
@ -26,13 +26,16 @@ export interface SoloConfig {
|
||||||
botDifficulty: BotDifficulty;
|
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 {
|
export interface SoloConfigFlowElements {
|
||||||
/** Root of the #menu-solo section; radios are looked up inside it by
|
/** 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
|
* `name` so this module owns its own markup instead of main.ts wiring
|
||||||
* every individual input. */
|
* every individual input. */
|
||||||
section: HTMLElement;
|
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. */
|
/** Wrapper around the difficulty radios, shown only in "vs bot" mode. */
|
||||||
botDifficultyWrap: HTMLElement;
|
botDifficultyWrap: HTMLElement;
|
||||||
playBtn: HTMLButtonElement;
|
playBtn: HTMLButtonElement;
|
||||||
|
|
@ -43,6 +46,19 @@ export interface SoloConfigFlowElements {
|
||||||
themeNameBadge: HTMLElement;
|
themeNameBadge: HTMLElement;
|
||||||
/** Mounts for the pre-round "flash the real theme" overlay. */
|
/** Mounts for the pre-round "flash the real theme" overlay. */
|
||||||
preview: PreviewFlowElements;
|
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 {
|
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;
|
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 {
|
interface TimerHud {
|
||||||
/** Starts (or restarts) the countdown/elapsed-time display. `onExpire`,
|
/** Starts (or restarts) the countdown/elapsed-time display. `onExpire`,
|
||||||
* on the two clocked modes only, fires exactly once when the countdown
|
* 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 /
|
/** Wires the Solo menu section's config form (time mode / theme mode /
|
||||||
* opponent, with the theme grid and bot difficulty picker revealed
|
* opponent, with the bot difficulty picker revealed conditionally and
|
||||||
* conditionally), its inline timer/quit HUD, and every shared
|
* theme choice deferred to a standalone modal), its inline timer/quit
|
||||||
* result-modal button ("Keep Comparing" / "Play Again") for as long as a
|
* HUD, and every shared result-modal button ("Keep Comparing" / "Play
|
||||||
* solo round is the thing driving the shared board — see `soloActive`. */
|
* 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(
|
export function createSoloConfigFlow(
|
||||||
elements: SoloConfigFlowElements,
|
elements: SoloConfigFlowElements,
|
||||||
themeGrid: ThemeGrid,
|
|
||||||
hooks: SoloConfigFlowHooks,
|
hooks: SoloConfigFlowHooks,
|
||||||
): void {
|
): void {
|
||||||
const themeModeInputs = radios(elements.section, 'solo-theme-mode');
|
const themeModeInputs = radios(elements.section, 'solo-theme-mode');
|
||||||
|
|
@ -173,6 +226,10 @@ export function createSoloConfigFlow(
|
||||||
|
|
||||||
let lastConfig: SoloConfig | null = null;
|
let lastConfig: SoloConfig | null = null;
|
||||||
let activeGame: ThemeGuessGame | 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 —
|
/** True from the moment a solo round starts until the player quits —
|
||||||
* guards the shared, page-global result-modal buttons and the Quit
|
* guards the shared, page-global result-modal buttons and the Quit
|
||||||
* button so a stale click from a leftover matchmaking/private-room
|
* button so a stale click from a leftover matchmaking/private-room
|
||||||
|
|
@ -181,19 +238,36 @@ export function createSoloConfigFlow(
|
||||||
|
|
||||||
const timerHud = createTimerHud(elements.timerMount, quit);
|
const timerHud = createTimerHud(elements.timerMount, quit);
|
||||||
|
|
||||||
function syncThemePicker(): void {
|
const themeGrid = createThemeGrid(elements.themePicker.gridMount, readLastTheme() ?? 'gruvbox', (themeId) => {
|
||||||
const mode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
|
sound.playOpen();
|
||||||
elements.themePickerWrap.classList.toggle('hidden', mode !== 'choose');
|
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 {
|
function syncBotDifficulty(): void {
|
||||||
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
|
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
|
||||||
elements.botDifficultyWrap.classList.toggle('hidden', opponent !== 'bot');
|
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);
|
for (const input of opponentInputs) input.addEventListener('change', syncBotDifficulty);
|
||||||
syncThemePicker();
|
|
||||||
syncBotDifficulty();
|
syncBotDifficulty();
|
||||||
|
|
||||||
/** Starts (or restarts) a live round from a fully-resolved config:
|
/** Starts (or restarts) a live round from a fully-resolved config:
|
||||||
|
|
@ -202,6 +276,9 @@ export function createSoloConfigFlow(
|
||||||
function beginRound(config: SoloConfig): void {
|
function beginRound(config: SoloConfig): void {
|
||||||
lastConfig = config;
|
lastConfig = config;
|
||||||
soloActive = true;
|
soloActive = true;
|
||||||
|
writeLastTheme(config.themeId);
|
||||||
|
elements.changeThemeBtn.classList.add('hidden');
|
||||||
|
elements.backToMenuBtn.classList.remove('hidden');
|
||||||
hooks.hideMenu();
|
hooks.hideMenu();
|
||||||
runThemePreview(elements.preview, config.themeId, () => {
|
runThemePreview(elements.preview, config.themeId, () => {
|
||||||
const game = getSharedGame(config.themeId, config.snippetIndex, (id, hex) => botMatch.handleLocalAssignment(id, hex));
|
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 themeMode = checkedValue<SoloThemeMode>(themeModeInputs, 'random');
|
||||||
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
|
const opponent = checkedValue<SoloOpponent>(opponentInputs, 'alone');
|
||||||
const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy');
|
const botDifficulty = checkedValue<BotDifficulty>(botDifficultyInputs, 'easy');
|
||||||
|
const base: PendingSoloConfig = { timeMode, themeMode, opponent, botDifficulty };
|
||||||
|
|
||||||
const themeIds = Object.keys(THEMES) as ThemeId[];
|
if (themeMode === 'choose') {
|
||||||
const themeId = themeMode === 'choose'
|
hooks.hideMenu();
|
||||||
? themeGrid.selected
|
openThemePicker(base);
|
||||||
: themeIds[Math.floor(Math.random() * themeIds.length)];
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
beginRound({
|
beginRound({ ...base, themeId: pickRandomTheme(readLastTheme()), snippetIndex: pickRandomSnippetIndex() });
|
||||||
themeId,
|
|
||||||
snippetIndex: pickRandomSnippetIndex(),
|
|
||||||
timeMode,
|
|
||||||
themeMode,
|
|
||||||
opponent,
|
|
||||||
botDifficulty,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** "Keep Comparing": lets the player keep tweaking colors after a
|
/** "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
|
/** "Play Again": a random-theme round immediately rerolls a new theme
|
||||||
* and snippet and starts over, with no extra input needed. A
|
* (never repeating the last one played) and snippet, and starts over
|
||||||
* chosen-theme round instead reopens the menu (already sitting on
|
* with no extra input needed. A chosen-theme round instead reopens the
|
||||||
* "choose" with the theme grid visible) so the player picks their
|
* standalone theme-picker modal, carrying over every other setting, so
|
||||||
* next theme, same as starting any other chosen-theme round. */
|
* the player picks their next theme the same way a fresh round does. */
|
||||||
function playAgain(): void {
|
function playAgain(): void {
|
||||||
if (!soloActive || !lastConfig) return;
|
if (!soloActive || !lastConfig) return;
|
||||||
|
const base: PendingSoloConfig = {
|
||||||
|
timeMode: lastConfig.timeMode,
|
||||||
|
themeMode: lastConfig.themeMode,
|
||||||
|
opponent: lastConfig.opponent,
|
||||||
|
botDifficulty: lastConfig.botDifficulty,
|
||||||
|
};
|
||||||
|
|
||||||
if (lastConfig.themeMode === 'choose') {
|
if (lastConfig.themeMode === 'choose') {
|
||||||
hooks.showMenu();
|
openThemePicker(base);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const themeIds = Object.keys(THEMES) as ThemeId[];
|
|
||||||
const themeId = themeIds[Math.floor(Math.random() * themeIds.length)];
|
beginRound({ ...base, themeId: pickRandomTheme(readLastTheme()), snippetIndex: pickRandomSnippetIndex() });
|
||||||
beginRound({ ...lastConfig, themeId, snippetIndex: pickRandomSnippetIndex() });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Ends the active solo round and returns to the main menu — wired to
|
/** 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
|
* the timer HUD's own Quit button (visible for as long as a solo round
|
||||||
* solo round is live (menu, mid-round, and after a reveal alike). */
|
* is live) and the result modal's "Back to Menu" button. */
|
||||||
function quit(): void {
|
function quit(): void {
|
||||||
if (!soloActive) return;
|
if (!soloActive) return;
|
||||||
soloActive = false;
|
soloActive = false;
|
||||||
botMatch.stop();
|
botMatch.stop();
|
||||||
timerHud.hide();
|
timerHud.hide();
|
||||||
|
elements.changeThemeBtn.classList.remove('hidden');
|
||||||
|
elements.backToMenuBtn.classList.add('hidden');
|
||||||
hooks.showMenu();
|
hooks.showMenu();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
elements.backToMenuBtn.addEventListener('click', quit);
|
||||||
closeResultBtn.addEventListener('click', keepComparing);
|
closeResultBtn.addEventListener('click', keepComparing);
|
||||||
playAgainBtn.addEventListener('click', playAgain);
|
playAgainBtn.addEventListener('click', playAgain);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue