diff --git a/.claude/skills/jgengine-ui/SKILL.md b/.claude/skills/jgengine-ui/SKILL.md index 8bc4b8452..8b7c4cc66 100644 --- a/.claude/skills/jgengine-ui/SKILL.md +++ b/.claude/skills/jgengine-ui/SKILL.md @@ -29,7 +29,7 @@ Existing React games keep their entity store and use the focused - **Every game owns its UI end-to-end.** Custom composition, skin, placement, terminology, motion, and one main menu. The website/runner is a bare loader. Composing and reskinning the shipped blocks is correct use; a genre HUD kit, theme preset, or unarranged default stack dropped in as the game's identity is not. - **The main menu is a game front-end, not marketing.** The runner loads straight into the game's own menu; it offers the real front-end verbs the game supports — New Game, Continue/Load (save slots), Create/Choose character or class, Settings, Credits — each an authored screen in the game's art direction, not a landing page or raw scaffold. See [reference.md](reference.md#main-menu). -- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `TitleMenu`/`MenuScreen` (`@jgengine/react`) give the animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` the slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology. +- **Menus come from the shipped blocks, navigated by `useMenuNav`.** `GameMenu` (`@jgengine/react/gameMenu`) is the front-end surface: full-bleed over a live scene or key art, a logo sized by `--jg-menu-logo-size`, animated primary verbs with a generated selection tick, and a detail pane that follows the highlight — with `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (cards with thumbnails), `CreditsPane` and the game's own settings surface as its panes, and `GamePauseMenu` the same system sized down. It locks the document scroll, so the surface is exactly the viewport at any size. `TitleMenu`/`MenuScreen` (`@jgengine/react`) remain for a plainer animated screen over the live scene (transform/opacity only, reduced-motion aware, unmounted after exit), `PauseMenu` the small pause shape, `SettingsSection`/`SettingsTabs` the settings surface, `SaveSlotMenu` a bare slot list, `CreditsPanel` the credits screen, `LoadingScreen` the boot screen with rotating tips. `useMenuNav` (`@jgengine/react/menuNav`) is the one keyboard + gamepad focus model for any menu: roving focus, confirm/back, disabled rows skipped, no focus trap. Games keep wording, layout, art and terminology. - **Settings is game-owned and reachable from a sensible place.** Every game ships settings as *its* settings — skinned and placed inline with its UI (main menu and pause), never the engine's stock face floated into a corner. Credits are a reachable in-game screen, not attribution buried in a build artifact. - **Route the front-end with `useMenuRouter`, and generate the credits.** `useMenuRouter(root)` is the screen stack behind title → save select → settings → credits: `open`/`back`/`replace`/`reset`, `path` for depth, and Escape-to-back bound by default (`escapeToBack: false` when the game owns Escape). `menuStackReducer` is the same transition as a pure function. For credits, `creditsForSourceIds(ids)` (`@jgengine/assets/credits`) turns the pack ids a game declares to `buildCatalog` into licensed, attributed sections, `mergeCredits` folds them into hand-authored lines, and `CreditsScreen` renders the result HudTheme-skinned. - **Skin the shared chrome with one `HudTheme` token object.** `hudThemeVars(theme)` emits the whole `--jg-*` vocabulary — bar fills and troughs, `HudFrame variation="themed"`, painted slots, the minimap ring, plus surfaces, edges, text, status colors, fonts, and rarity — so one theme spread on any HUD ancestor reskins the `@jgengine/react` primitives and the `registry/jgengine` blocks together. Author your own with `deriveHudTheme({ accent, surface })` or `hudTheme({ ... })`; `HUD_THEME_PRESETS` / `resolveHudTheme("")` are demo/scaffold starting points, never a game's identity. A component that needs a token no theme emits fails `check-hud-tokens` — add it to `HudTheme` rather than inventing a second vocabulary. Caller `style`/`renderItem` overrides still win. diff --git a/.claude/skills/jgengine-ui/api.md b/.claude/skills/jgengine-ui/api.md index ed083cd75..bed8ee6f5 100644 --- a/.claude/skills/jgengine-ui/api.md +++ b/.claude/skills/jgengine-ui/api.md @@ -717,8 +717,12 @@ - `CompassProps` (interface): interface CompassProps { facingYaw: number; center?: WorldXZ; markers?: MarkerCollection; width?: number; fov?: number; kindStyles?: Record; className?: string } — ⚠ undocumented · used by `Compass`: Horizontal compass strip centered on the player's facing direction, with the eight cardinals and optional marker pips from static views, an … - `ConfirmDialog` (function): function ConfirmDialog({ title, body, confirmLabel = "Confirm", cancelLabel = "Cancel", onConfirm, onCancel, danger = false, width = 360, className, style, }: ConfirmDialogProps): ReactNode — A generic two-button confirmation dialog — title, optional body, and Cancel / Confirm buttons over a `HudTheme`-token-driven {@link HudSurface}. Presentation-only: wire `onConfirm`/`onCancel` to a modal stack's `resolve` (typically inside a {@link ModalHost} render callback) and reskin via `HudTheme` tokens or `danger` for a destructive action. It interprets nothing — the game supplies the copy. - `ConfirmDialogProps` (interface): interface ConfirmDialogProps — Props for {@link ConfirmDialog}. +- `ContinuePane` (function): function ContinuePane({ slot, onContinue, emptyMessage = "No run saved yet. Start a new game to begin one.", now = Date.now, className, style, }: ContinuePaneProps): ReactNode — The Continue detail pane: the save being continued, read big — captured thumbnail, character, time played, the game's progress summary, when it was saved, and the resume action. +- `ContinuePaneProps` (interface): interface ContinuePaneProps — Props for {@link ContinuePane}. - `ControlHint` (interface): interface ControlHint — One row of a control legend. Name the game action(s) whose bound key(s) to show (`action`) so the glyphs come straight from the keybind map — never re-typed — or give literal `keys` for controls that live outside the map (`"Mouse"`, `"LMB"`). `label` says what the control does. - `ControlsList` (function): function ControlsList({ bindings, controls, hideOnCoarsePointer = true, separator = " / ", className, rowClassName, keysClassName, keyClassName, separatorClassName, labelClassName, renderKey, renderRow, }: { bindings?: ActionCodesMap; controls: readonly ControlHint[]; hideOnCoarsePointer?: boolean; … — Renders a controls legend whose key glyphs come from the game's keybind map, so bindings live in one place and never drift from a hand-typed table. Hides itself on coarse-pointer devices (a touchscreen has no keyboard) via the same `data-jg-kbd-hint` marker as `KeyHint`. Headless: every part carries a `className` slot and `data-*` hook; pass `renderKey`/`renderRow` to fully own the markup. +- `CreditsPane` (function): function CreditsPane({ sections, heading = "Credits", notice, className, style }: CreditsPaneProps): ReactNode — The Credits detail pane: a scroller that belongs to the pane. Wheel, arrows, PageUp/PageDown, Home and End scroll it, the native scrollbar is hidden, and the page behind never moves. +- `CreditsPaneProps` (interface): interface CreditsPaneProps — Props for {@link CreditsPane}. - `CreditsPanel` (function): function CreditsPanel({ document, onBack, title = "Credits", backLabel = "Back", open = true, backdrop, className, style, contentClassName, contentStyle, }: CreditsPanelProps): ReactNode — A reachable credits screen: the generated or hand-authored {@link CreditsDocument} in a scrollable panel over the scene, with one back affordance that keyboard and gamepad both reach. The game owns the backdrop and the wording; the block keeps the lines HudTheme-skinned and the exit working. - `CreditsPanelProps` (interface): interface CreditsPanelProps — Props for {@link CreditsPanel}. - `Crosshair` (function): function Crosshair({ size = 18, gap = 5, thickness = 2, color = "rgba(255,255,255,0.85)", style, className, }: { size?: number; gap?: number; thickness?: number; color?: string; style?: CSSProperties; className?: string; }): React.JSX.Element — A minimal center crosshair reticle — four ticks around a gap. Purely presentational. @@ -781,6 +785,18 @@ - `FullscreenMapProps` (interface): interface FullscreenMapProps extends Omit — Props for {@link FullscreenMap}. - `GameContextBridge` (function): function GameContextBridge({ context, children, }: { context: GameContext | null; children?: ReactNode; }): React.JSX.Element — Re-provide a captured {@link GameContext} across a nested React reconciler boundary. The `` of `@react-three/fiber` renders its children in a *separate* React root, so a `GameProvider` in the outer tree does NOT reach inside it — hooks like `useGameContext` / `useEntityRenderCues` throw. The fix the shell already uses internally: capture the context outside the boundary (with {@link useOptionalGameContext}) and wrap the inner tree in `GameContextBridge`. A `null` context renders children unchanged, so the same code works in showcases with no running game. This is the seam behind `EntityPreview` — reach for it directly when building a bespoke nested canvas. - `GameIdentityProvider` (function): function GameIdentityProvider({ source, children, }: { source: IdentitySource; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented +- `GameMenu` (function): function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound = true, variant = "title", open = true, reducedMotion, label, className, style, detailClassName, detailStyle, }: GameMenuProps): ReactNode — A game front-end menu: full-bleed over a living background, with a logo, an animated primary list and a detail pane that follows the highlighted verb. One system covers the main menu and the pause menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation ticks are generated (no audio asset), and the document never scrolls while it is open. +- `GameMenuAvatar` (interface): interface GameMenuAvatar — One avatar/portrait choice in {@link NewCharacterPane}. +- `GameMenuItem` (interface): interface GameMenuItem — A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on the left that animate as the selection moves, and a detail pane on the right that changes with the highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from, so a game composes one menu system instead of a stack of modal panels. +- `GameMenuLogo` (interface): interface GameMenuLogo — The logo slot. Give it `image` for art, or `text` for a styled wordmark. +- `GameMenuPalette` (interface): interface GameMenuPalette — One palette choice in {@link NewCharacterPane}. +- `GameMenuProps` (interface): interface GameMenuProps — Props for {@link GameMenu}. +- `GameMenuSound` (interface): interface GameMenuSound — A menu sound player. Give it to `GameMenu`'s `sound` prop, or call its methods from your own menu. +- `GameMenuSoundConfig` (interface): interface GameMenuSoundConfig — How {@link createGameMenuSound} is tuned. +- `GameMenuSoundKind` (type): type GameMenuSoundKind = "move" | "confirm" | "back" — The short, quiet click a menu makes when the selection moves or is confirmed. Generated with an oscillator instead of shipped as an audio file, so a game gets menu feedback without authoring a sound and the whole thing unmounts with the screen. +- `GameMenuSoundState` (interface): interface GameMenuSoundState — The sound player's readable state: on/off, gain, and whether audio is actually running. +- `GameMenuVariant` (type): type GameMenuVariant = "title" | "pause" — How a {@link GameMenu} is used. `pause` is the same system at a smaller scale. +- `GamePauseMenu` (function): function GamePauseMenu(props: Omit & { onResume?: () => void }): ReactNode — A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a game composes one menu system rather than a second one. Escape (or B/Start) resumes. - `GamePreviewComponent` (type): type GamePreviewComponent = ComponentType — ⚠ undocumented - `GamePreviewProps` (type): type GamePreviewProps = { className?: string; } — ⚠ undocumented - `GamePreviewStates` (type): type GamePreviewStates = Record — ⚠ undocumented @@ -889,6 +905,9 @@ - `ModalHost` (function): function ModalHost({ stack, children, showBackdrop = true, backdrop, closeOnBackdrop = true, closeOnEsc = true, zIndexBase = 80, onInteract, className, style, }: ModalHostProps): ReactNode — Full-screen modal host: watches a stack, renders only the topmost modal centered over a dimmed backdrop, and manages accessibility — the panel is a `role="dialog" aria-modal`, focus moves into it on open and is trapped with Tab/Shift+Tab cycling, Escape and (optionally) a backdrop click cancel the top modal, and focus returns to the previously focused element on close. It renders nothing when the stack is empty. Skin the panel and backdrop via `HudTheme` tokens. - `ModalHostProps` (interface): interface ModalHostProps — Props for {@link ModalHost}. - `ModalStackView` (interface): interface ModalStackView — React chrome over the headless modal stack (`@jgengine/core/ui/modalStack`) — the blocking-overlay layer a game raises over its scene: a pause menu, a "quit to menu?" confirm, a reward popup. {@link useModalStack} is the DATA/HOOK layer (subscribe + the active record), {@link ModalHost} renders the top modal with a backdrop, focus trap, and Esc-to-cancel, and {@link ConfirmDialog} / {@link PauseMenu} are ready-to-wire, HudTheme-token-driven building blocks. All unskinned and game-owned: the model never interprets a modal's `kind`, so a game switches on it to pick which of these (or its own) content to show, and reskins everything via `HudTheme` tokens and the frame. +- `NewCharacterChoice` (interface): interface NewCharacterChoice — What {@link NewCharacterPane} reports when a run starts. +- `NewCharacterPane` (function): function NewCharacterPane({ name, onNameChange, palettes, paletteId, onPaletteChange, avatars, avatarId, onAvatarChange, onStart, heading = "New run", nameLabel = "Character name", namePlaceholder = "Name your character", maxLength = 24, startLabel = "Begin", className, style, }: NewCharacterPanePro… — The New Game detail pane: a character name field, an optional palette and avatar choice, and the action that starts the run. The name field is a real form field, so menu key handling leaves typing alone (Enter inside the field starts the run). +- `NewCharacterPaneProps` (interface): interface NewCharacterPaneProps — Props for {@link NewCharacterPane}. - `NotificationBell` (function): function NotificationBell({ store, onClick, icon = "🔔", className, style }: NotificationBellProps): ReactNode — Notification bell — an icon button with an unread-count badge, bound to a notification store. Toggle a {@link NotificationCenter} panel from its click. - `NotificationBellProps` (interface): interface NotificationBellProps — Props for {@link NotificationBell}. - `NotificationCenter` (function): function NotificationCenter({ store, title = "Notifications", emptyLabel = "You're all caught up.", onClose, formatTime, className, style, }: NotificationCenterProps): ReactNode — Notification center panel — the scrollable log toasts don't provide: newest notifications with a kind-colored marker, title/body, relative time, and an unread highlight; a header with the unread count, mark-all-read, and clear. Clicking an entry marks it read. Bind it to a `createNotificationCenter`. @@ -935,11 +954,17 @@ - `RegionRecord` (interface): interface RegionRecord extends LayoutRegion — A region registration: the full `LayoutRegion` plus the live element (dev outlining), rect measured by the shell/react side. - `RequireSession` (function): function RequireSession({ fallback, loading, children, }: { fallback?: ReactNode; loading?: ReactNode; children?: ReactNode; }): React.JSX.Element — ⚠ undocumented - `RotateDeviceScreen` (function): function RotateDeviceScreen({ title = "Turn your device", description, requiredOrientation = "landscape", accent = "var(--jg-accent, #8ea2ff)", icon, className, style, }: { /** Short headline. */ title?: string; /** One concise explanatory line. Defaults from `requiredOrientation`. */ description?: … — Full-viewport, non-dismissible rotate-device gate shown when a game requires an orientation the device isn't in. +- `SAVE_SLOT_CHARACTER_KEY` (const): const SAVE_SLOT_CHARACTER_KEY: "character" — The character name a save card shows first, when the game supplies one as its own meta key. +- `SAVE_SLOT_THUMBNAIL_KEY` (const): const SAVE_SLOT_THUMBNAIL_KEY: "thumbnail" — The save slot `meta` key a card reads as its thumbnail: a data URL or image URL captured when the save was written. Every other meta key is shown as a progress chip and never interpreted. - `SETTINGS_TRIGGER_CLASSNAME` (const): const SETTINGS_TRIGGER_CLASSNAME: "pointer-events-auto flex h-8 w-8 items-center justify-center rounded-md border border-[var(--jg-edge-bright)] bg-[var(--jg-surface)]/80 text-base text-[var(--jg-text-dim)] shadow-[0_1px_2px_rgba(0,0,0,0.6)] transition hover:bg-[var(--jg-surface-deep)] hover:text-[v… — Default HudTheme-token skin for {@link SettingsTrigger} — the 8×8 chrome button games re-authored seven times. Pass your own `className` only to override placement or brand chrome; the gear glyph already scales with `text-base` / `1em`. - `SUITS` (const): const SUITS: readonly Suit[] — Suits in canonical order (clubs, diamonds, hearts, spades). +- `SaveSlotCard` (function): function SaveSlotCard({ slot, now = Date.now, recent = false, onSelect, onDelete, loadLabel, className, style, }: SaveSlotCardProps): ReactNode — One save slot as a card: a captured thumbnail, the character name, playtime, the game's progress summary and when it was saved — or, for an empty slot, an empty slot with a New Game affordance. Deleting takes two presses: the first swaps the button for a confirm pair, so a mis-click cannot erase a run. +- `SaveSlotCardProps` (interface): interface SaveSlotCardProps — Props for {@link SaveSlotCard}. - `SaveSlotMenu` (function): function SaveSlotMenu({ model, onLoad, onNew, onDelete, onContinue, title = "Select Save", theme, now = Date.now, metaIcons, className, style, }: SaveSlotMenuProps): ReactNode — A drop-in save-select / profile menu that renders a {@link SaveSlots} index as a column of cards. A filled slot shows its name, the free-string `meta` the game supplied as chips, and a relative "saved" time, with Load and Delete actions; an empty slot shows a "New Game" affordance. A top-level Continue button targets the newest save (`model.mostRecent()`). This is a starting-point game-front-end building block — the game owns final art, wording, and layout; reskin it with {@link SaveSlotMenuTheme} or HudTheme CSS vars. Presentation only: all state lives in the core model, and `meta` keys are never interpreted. - `SaveSlotMenuProps` (interface): interface SaveSlotMenuProps — Props for {@link SaveSlotMenu}. - `SaveSlotMenuTheme` (interface): interface SaveSlotMenuTheme — Reskin tokens for {@link SaveSlotMenu}. All optional — each falls back to a HudTheme var then a neutral default. +- `SaveSlotsPane` (function): function SaveSlotsPane({ model, onContinue, onLoad, onNew, onDelete, heading = "Save slots", now = Date.now, columns = 2, className, style, }: SaveSlotsPaneProps): ReactNode — The slot grid for a {@link GameMenu} detail pane: Continue over the most recent save, then one card per slot. Arrow keys move between cards, Enter loads, and the grid scrolls inside its own pane rather than the page. +- `SaveSlotsPaneProps` (interface): interface SaveSlotsPaneProps — Props for {@link SaveSlotsPane}. - `SaveStoreView` (interface): interface SaveStoreView — The reactive slice of a {@link SaveStore} returned by {@link useSaveStore} — the live value plus bound save actions, all re-rendering on change. - `Scoreboard` (function): function Scoreboard({ entries, rows, options, title, scoreLabel = "Score", nameFor, formatScore, medalIcon, theme, className, style, }: ScoreboardProps): ReactNode — A reskinnable ranked-score table for the {@link rankLeaderboard} selector: a rank column (medal icon/color for the top three via {@link medalFor}, otherwise the number), a name column, and a score column. The local player's row (`isLocal`) is washed and accented so "you" stands out. Feed it already-ranked `entries`, or raw `rows` plus `options` and it ranks them for you. All colors and spacing come from {@link ScoreboardTheme} / HudTheme `--jg-*` tokens — the component reads the model and never styles by game meaning. - `ScoreboardProps` (interface): interface ScoreboardProps — Props for {@link Scoreboard}. Pass either `entries` (already ranked) or `rows` + `options`. @@ -1039,6 +1064,7 @@ - `canvasCover` (const): const canvasCover: CanvasCover — The page's canvas cover. A full-screen menu (or a game's own opaque overlay) holds it; the shell stops drawing the world, its shadow passes and postfx while it is held, and draws again from the untouched scene when it lifts. A translucent scrim or a side panel must not hold it — anything the scene shows through has to keep drawing. - `chatTransportFromSync` (function): function chatTransportFromSync(sync: ChatSync): ChatTransport — Lifts a callback-style ChatSync (e.g. createWsBackend().chatSyncFor(serverId)) into the hook-shaped ChatTransport contract. Create once per sync — outside render or inside useMemo — so subscriptions survive re-renders. - `clerkIdentity` (function): function clerkIdentity(state: ClerkUserState, options?: { signOut?: () => void }): IdentitySource — ⚠ undocumented +- `createGameMenuSound` (function): function createGameMenuSound(config: GameMenuSoundConfig = {}): GameMenuSound — A tiny WebAudio click generator for menu feedback: one oscillator burst per tick, created on the first play (so a browser autoplay policy does not log an error at mount) and reused after. - `createHeldKeyTracker` (function): function createHeldKeyTracker(target: HeldKeyEventTarget): { isDown: (code: string) => boolean; dispose: () => void; } — ⚠ undocumented - `defaultEquipLayout` (function): function defaultEquipLayout(items: Partial> = {}): EquipSlot[] — A sensible default paperdoll arrangement — worn armor down the left flank, held gear and jewelry down the right — that the game fills by passing worn items keyed by slot id. Every id is optional; unfilled slots render empty. Games that need a different slot set pass their own {@link EquipSlotLayout}. - `defaultHudTheme` (const): const defaultHudTheme: HudTheme — The engine's default HUD look, applied by the shell at the HUD root of every game that does not opt out: a strong system UI font stack, layered translucent panels with a lit top rim and crisp hairline borders, a 12px type floor, and motion that honours reduced-motion. Games override any part with their own theme on a HUD ancestor. @@ -1172,6 +1198,7 @@ - `useTicker` (function): function useTicker(hz = 10): number — Re-render at a steady rate. Returns a monotonically increasing tick count driven by a setInterval, for HUD elements that display wall-clock-derived values (cooldowns, cast bars, swing timers) without an engine subscription to hang off. `hz <= 0` disables the ticker. - `useTimerRead` (function): function useTimerRead(timer: TimerSet, id: string, active = true): TimerRead | null — Subscribe to a single timer and re-read it every animation frame while mounted, so a HUD readout stays live without the game hand-rolling interval math. Reuses one read object (allocation-aware). Returns `null` for an unknown id. Pass `active={false}` to freeze the per-frame tick (e.g. an off-screen HUD). - `useViewportMetrics` (function): function useViewportMetrics(): ViewportMetrics — Live visible viewport, tracking `window.visualViewport` (mobile browser chrome, pinch-zoom) with a layout-viewport fallback. +- `useViewportScrollLock` (function): function useViewportScrollLock(active: boolean): void — Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous inline styles are restored when the last one releases. - `useVoice` (function): function useVoice(options?: UseVoiceOptions): VoiceState — Mic capture + push-to-talk + channel roster over the VoiceTransport signaling seam. Transmission gates the captured tracks' `enabled` flag; the media plane that actually moves audio bytes (WebRTC/SFU) stays behind the transport, host-supplied. Call once per voice channel and hand the returned state to the voice components. - `useWaveRunner` (function): function useWaveRunner(runner: WaveRunner): WaveView — Subscribe to a {@link WaveRunner} and re-render whenever it notifies (wave, budget, spawns, alert change). Returns the runner's pooled {@link WaveView}. The runner is driven by the game/demo (call `runner.update(dt)`), not by this hook. - `useWaypoints` (function): function useWaypoints(source: WaypointTracker | readonly Waypoint[]): readonly Waypoint[] — Subscribe to a {@link WaypointTracker} and re-render on every change, returning its current waypoints. Pass a plain array instead and it is returned as-is (no subscription), so a game can drive the host from a tracker or from its own derived state. @@ -1450,6 +1477,42 @@ - `iconForItemId` (function): function iconForItemId(itemId: string): GameIconName | null — ⚠ undocumented - `isGameIconName` (function): function isGameIconName(value: string): value is GameIconName — ⚠ undocumented +## @jgengine/react/gameMenu + +- `GameMenu` (function): function GameMenu({ items, initialId, onSelect, onBack, onHighlight, detail, logo, eyebrow, tagline, background, art, secondary, footer, controlHint, sound = true, variant = "title", open = true, reducedMotion, label, className, style, detailClassName, detailStyle, }: GameMenuProps): ReactNode — A game front-end menu: full-bleed over a living background, with a logo, an animated primary list and a detail pane that follows the highlighted verb. One system covers the main menu and the pause menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation ticks are generated (no audio asset), and the document never scrolls while it is open. +- `GameMenuItem` (interface): interface GameMenuItem — A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on the left that animate as the selection moves, and a detail pane on the right that changes with the highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from, so a game composes one menu system instead of a stack of modal panels. +- `GameMenuLogo` (interface): interface GameMenuLogo — The logo slot. Give it `image` for art, or `text` for a styled wordmark. +- `GameMenuProps` (interface): interface GameMenuProps — Props for {@link GameMenu}. +- `GameMenuVariant` (type): type GameMenuVariant = "title" | "pause" — How a {@link GameMenu} is used. `pause` is the same system at a smaller scale. +- `GamePauseMenu` (function): function GamePauseMenu(props: Omit & { onResume?: () => void }): ReactNode — A pause menu built from {@link GameMenu}: the same surface and navigation at a smaller scale, so a game composes one menu system rather than a second one. Escape (or B/Start) resumes. +- `useViewportScrollLock` (function): function useViewportScrollLock(active: boolean): void — Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous inline styles are restored when the last one releases. + +## @jgengine/react/gameMenuSaves + +- `ContinuePane` (function): function ContinuePane({ slot, onContinue, emptyMessage = "No run saved yet. Start a new game to begin one.", now = Date.now, className, style, }: ContinuePaneProps): ReactNode — The Continue detail pane: the save being continued, read big — captured thumbnail, character, time played, the game's progress summary, when it was saved, and the resume action. +- `ContinuePaneProps` (interface): interface ContinuePaneProps — Props for {@link ContinuePane}. +- `CreditsPane` (function): function CreditsPane({ sections, heading = "Credits", notice, className, style }: CreditsPaneProps): ReactNode — The Credits detail pane: a scroller that belongs to the pane. Wheel, arrows, PageUp/PageDown, Home and End scroll it, the native scrollbar is hidden, and the page behind never moves. +- `CreditsPaneProps` (interface): interface CreditsPaneProps — Props for {@link CreditsPane}. +- `GameMenuAvatar` (interface): interface GameMenuAvatar — One avatar/portrait choice in {@link NewCharacterPane}. +- `GameMenuPalette` (interface): interface GameMenuPalette — One palette choice in {@link NewCharacterPane}. +- `NewCharacterChoice` (interface): interface NewCharacterChoice — What {@link NewCharacterPane} reports when a run starts. +- `NewCharacterPane` (function): function NewCharacterPane({ name, onNameChange, palettes, paletteId, onPaletteChange, avatars, avatarId, onAvatarChange, onStart, heading = "New run", nameLabel = "Character name", namePlaceholder = "Name your character", maxLength = 24, startLabel = "Begin", className, style, }: NewCharacterPanePro… — The New Game detail pane: a character name field, an optional palette and avatar choice, and the action that starts the run. The name field is a real form field, so menu key handling leaves typing alone (Enter inside the field starts the run). +- `NewCharacterPaneProps` (interface): interface NewCharacterPaneProps — Props for {@link NewCharacterPane}. +- `SAVE_SLOT_CHARACTER_KEY` (const): const SAVE_SLOT_CHARACTER_KEY: "character" — The character name a save card shows first, when the game supplies one as its own meta key. +- `SAVE_SLOT_THUMBNAIL_KEY` (const): const SAVE_SLOT_THUMBNAIL_KEY: "thumbnail" — The save slot `meta` key a card reads as its thumbnail: a data URL or image URL captured when the save was written. Every other meta key is shown as a progress chip and never interpreted. +- `SaveSlotCard` (function): function SaveSlotCard({ slot, now = Date.now, recent = false, onSelect, onDelete, loadLabel, className, style, }: SaveSlotCardProps): ReactNode — One save slot as a card: a captured thumbnail, the character name, playtime, the game's progress summary and when it was saved — or, for an empty slot, an empty slot with a New Game affordance. Deleting takes two presses: the first swaps the button for a confirm pair, so a mis-click cannot erase a run. +- `SaveSlotCardProps` (interface): interface SaveSlotCardProps — Props for {@link SaveSlotCard}. +- `SaveSlotsPane` (function): function SaveSlotsPane({ model, onContinue, onLoad, onNew, onDelete, heading = "Save slots", now = Date.now, columns = 2, className, style, }: SaveSlotsPaneProps): ReactNode — The slot grid for a {@link GameMenu} detail pane: Continue over the most recent save, then one card per slot. Arrow keys move between cards, Enter loads, and the grid scrolls inside its own pane rather than the page. +- `SaveSlotsPaneProps` (interface): interface SaveSlotsPaneProps — Props for {@link SaveSlotsPane}. + +## @jgengine/react/gameMenuSound + +- `GameMenuSound` (interface): interface GameMenuSound — A menu sound player. Give it to `GameMenu`'s `sound` prop, or call its methods from your own menu. +- `GameMenuSoundConfig` (interface): interface GameMenuSoundConfig — How {@link createGameMenuSound} is tuned. +- `GameMenuSoundKind` (type): type GameMenuSoundKind = "move" | "confirm" | "back" — The short, quiet click a menu makes when the selection moves or is confirmed. Generated with an oscillator instead of shipped as an audio file, so a game gets menu feedback without authoring a sound and the whole thing unmounts with the screen. +- `GameMenuSoundState` (interface): interface GameMenuSoundState — The sound player's readable state: on/off, gain, and whether audio is actually running. +- `createGameMenuSound` (function): function createGameMenuSound(config: GameMenuSoundConfig = {}): GameMenuSound — A tiny WebAudio click generator for menu feedback: one oscillator burst per tick, created on the first play (so a browser autoplay policy does not log an error at mount) and reused after. + ## @jgengine/react/gameViewport - `GameViewportProvider` (function): function GameViewportProvider({ platforms, className, style, children, }: { platforms?: readonly HudPlatform[]; className?: string; style?: CSSProperties; children?: ReactNode; }): React.JSX.Element — Provides the shared game viewport layout to everything it wraps. Mount it once around the whole game presentation (world, HUD, controls, system UI) so every subsystem reads one coordinated geometry and registers its rect for collision detection. Publishes live `--jg-viewport-*` / `--jg-visual-viewport-*` / `--jg-safe-*` CSS variables on its root. @@ -1921,6 +1984,11 @@ - `relativeSavedAt` (function): function relativeSavedAt(savedAt: number, nowMs = Date.now()): string — Turn a `savedAt` timestamp into a compact relative label ("just now", "4m ago", "2d ago"). Presentation-only helper — the model stores raw ms. - `useSaveSlots` (function): function useSaveSlots(model: SaveSlots): SaveSlotMeta[] — Subscribe to a {@link SaveSlots} index and re-render on any change (write, clear, rename, restore). Returns the live list of slots in menu order. Wire it into a save-select screen and mutate the model in event handlers — the menu re-renders itself. +## @jgengine/react/saveThumbnail + +- `SaveThumbnailOptions` (interface): interface SaveThumbnailOptions — Turning a rendered frame into the thumbnail a save card shows. Saves keep a small JPEG/WebP data URL (a couple of KB), not a full-resolution PNG, so a slot index still fits in local storage. +- `shrinkCapture` (function): function shrinkCapture(capture: string | null, options: SaveThumbnailOptions = {}): Promise — Downscale a captured frame (a data URL from `captureCanvas`) to a save-card thumbnail. Resolves to the thumbnail data URL, or `null` when the capture could not be decoded (a blank or tainted frame). + ## @jgengine/react/scoreboard - `MedalToken` (type): type MedalToken = "gold" | "silver" | "bronze" — The medal tokens {@link medalFor} emits for the podium. diff --git a/.claude/skills/jgengine-ui/capabilities.md b/.claude/skills/jgengine-ui/capabilities.md index d7f234b21..c02e5dd29 100644 --- a/.claude/skills/jgengine-ui/capabilities.md +++ b/.claude/skills/jgengine-ui/capabilities.md @@ -207,6 +207,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `ConfirmDialog` (function) · `import { ConfirmDialog } from "@jgengine/react"` +## continue-pane — continue detail pane — the save being resumed with thumbnail, character, playtime and progress + +- `ContinuePane` (function) · `import { ContinuePane } from "@jgengine/react"` + ## controller-camera-shake — R3F consumer that applies a core camera-shake controller's pooled per-frame offset to the active camera (translation + pitch/yaw/roll) so the view visibly shakes - `ControllerCameraShake` (function) · `import { ControllerCameraShake } from "@jgengine/shell/camera"` @@ -215,6 +219,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `ControlsList` (function) · `import { ControlsList } from "@jgengine/react"` +## credits-pane — credits detail pane — sections scrolled inside the pane + +- `CreditsPane` (function) · `import { CreditsPane } from "@jgengine/react"` + ## credits-panel — scrollable, navigable credits screen with a back affordance - `CreditsPanel` (function) · `import { CreditsPanel } from "@jgengine/react"` @@ -341,6 +349,14 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `GameContextBridge` (function) · `import { GameContextBridge } from "@jgengine/react"` +## game-menu — full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar + +- `GameMenu` (function) · `import { GameMenu } from "@jgengine/react"` + +## game-pause-menu — pause menu from the shared game-menu system — same navigation and surface, sized down + +- `GamePauseMenu` (function) · `import { GamePauseMenu } from "@jgengine/react"` + ## gesture-resume — unlock the audio context on the first pointer/key/touch gesture, so pre-gesture cues play instead of vanishing - `installGestureResume` (function) · `import { installGestureResume } from "@jgengine/shell/audio/gestureResume"` @@ -577,6 +593,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `MenuScreen` (function) · `import { MenuScreen } from "@jgengine/react"` +## menu-sound — generated click for menu move/confirm/back, no audio asset + +- `createGameMenuSound` (function) · `import { createGameMenuSound } from "@jgengine/react"` + ## menu-stack-reducer — pure push/back/replace/reset transitions for a menu screen stack - `menuStackReducer` (function) · `import { menuStackReducer } from "@jgengine/react/menuRouter"` @@ -647,6 +667,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `WorldNameplates` (function) · `import { WorldNameplates } from "@jgengine/shell/world/WorldHud"` +## new-character-pane — character creation pane — name field, palette and avatar choice, start action + +- `NewCharacterPane` (function) · `import { NewCharacterPane } from "@jgengine/react"` + ## notice-feed — render ctx.game.notices as an animated toned stack (enter/exit motion, ×N stacking, HUD avoidance) - `NoticeStack` (function) · `import { NoticeStack } from "@jgengine/react/notices"` @@ -785,10 +809,18 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `resolveGameLook` (function) · `import { resolveGameLook } from "@jgengine/core/ui"` +## save-slot-card — save slot as a card — thumbnail, character, playtime, progress, timestamp, confirmed delete + +- `SaveSlotCard` (function) · `import { SaveSlotCard } from "@jgengine/react"` + ## save-slot-menu — drop-in save-select / profile menu that renders a core save-slots index as New / Continue / Load / Delete cards with free-string meta chips and relative save times, theme-skinnable - `SaveSlotMenu` (function) · `import { SaveSlotMenu } from "@jgengine/react"` +## save-slots-pane — slot grid pane for the game menu — Continue newest, per-slot cards, keyboard navigation + +- `SaveSlotsPane` (function) · `import { SaveSlotsPane } from "@jgengine/react"` + ## scene-capture-binding — expose the in-Canvas scene-capture function to outside-Canvas HUD (photo mode) - `SceneCaptureBinding` (function) · `import { SceneCaptureBinding } from "@jgengine/shell/render/sceneCapture"` @@ -845,6 +877,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `ShopGrid` (function) · `import { ShopGrid } from "@jgengine/react"` +## shrink-capture — downscale a captured frame to a save-card thumbnail data URL + +- `shrinkCapture` (function) · `import { shrinkCapture } from "@jgengine/react/saveThumbnail"` + ## speed-format — render a m/s speed as km/h, mph, knots, or m/s for speedometers and telemetry HUDs - `formatSpeed` (function) · `import { formatSpeed } from "@jgengine/core/ui"` @@ -1019,6 +1055,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p - `useT` (function) · `import { useT } from "@jgengine/react"` +## use-viewport-scroll-lock — count-aware document scroll lock for full-bleed surfaces + +- `useViewportScrollLock` (function) · `import { useViewportScrollLock } from "@jgengine/react"` + ## use-wave-runner — React hook that subscribes to a wave runner and returns its pooled current-wave view - `useWaveRunner` (function) · `import { useWaveRunner } from "@jgengine/react"` diff --git a/.claude/skills/jgengine-ui/reference.md b/.claude/skills/jgengine-ui/reference.md index 6674581f3..d25e81387 100644 --- a/.claude/skills/jgengine-ui/reference.md +++ b/.claude/skills/jgengine-ui/reference.md @@ -243,7 +243,7 @@ Focus must look authored while remaining accessible. Menus should support keyboa Compose it from the game's own markup over the headless `StartScreen` (`@jgengine/react`, `start-screen` capability) — a full-bleed `data-jg-menu` overlay that centers your content and offers an opt-in settings corner; it imposes no look. Menu rows are the game's own buttons and focus handling restyled into its chrome; the shipped pieces to compose in are `ControlsList` (`@jgengine/react`) for the control legend and `KeyHint`/`Keycap` for key badges (`KeyHint` already renders nothing on a coarse pointer) — never shipped as raw scaffold. -**The engine ships the front-end machinery; the game owns its look.** `MenuScreen` (`@jgengine/react/menuScreen`) is one animated screen over the live scene: transform/opacity enter and exit that unmount when the exit finishes, a translucent scrim (`"none"` keeps the 3D scene fully visible), and a `backdrop` slot for a poster or preview. `TitleMenu` (`@jgengine/react/titleMenu`) is the title shape — wordmark, eyebrow, tagline, verb list, footer, control hint — where the game supplies the wording, verbs and art and skins it with `--jg-*` tokens. Both read `useMenuNav` (`@jgengine/react/menuNav`): arrow keys/WASD/Home/End, a standard gamepad's d-pad and left stick (with hold-to-repeat), Enter/Space/A confirm, Escape/Backspace/B/Start back. Tab is never intercepted and typing in a field is never swallowed, so no menu traps focus; keys still work if the canvas kept focus. A game that composes its own markup gets the same behavior from `useMenuNav` alone. +**The engine ships the front-end machinery; the game owns its look.** `GameMenu` (`@jgengine/react/gameMenu`) is the game menu: full-bleed `position: fixed` surface with the document scroll locked, a living background (the live scene behind it, a `background` layer, or drifting `art`), a logo sized by `--jg-menu-logo-size`, primary verbs whose highlight slides, glows and ticks (generated WebAudio, no asset), and a detail pane per verb. Ship the panes it exports: `ContinuePane` (the save being resumed), `NewCharacterPane` (name, palette, avatar), `SaveSlotsPane` (cards with a `meta.thumbnail` capture, playtime, progress, timestamp, two-press delete), `CreditsPane` (scroller inside the pane) and the game's real settings surface. `GamePauseMenu` is the same system at `variant="pause"`. Games supply the logo, art, wording, verbs and each pane's content; saves come from `createSaveSlots` + `refreshSaveSlots`, and a thumbnail from `captureCanvas` + `shrinkCapture`. `MenuScreen` (`@jgengine/react/menuScreen`) is one animated screen over the live scene: transform/opacity enter and exit that unmount when the exit finishes, a translucent scrim (`"none"` keeps the 3D scene fully visible), and a `backdrop` slot for a poster or preview. `TitleMenu` (`@jgengine/react/titleMenu`) is the plainer title shape - wordmark, eyebrow, tagline, verb list, footer, control hint. All read `useMenuNav` (`@jgengine/react/menuNav`): arrow keys/WASD/Home/End, a standard gamepad's d-pad and left stick (with hold-to-repeat), Enter/Space/A confirm, Escape/Backspace/B/Start back. Tab is never intercepted and typing in a field is never swallowed, so no menu traps focus; keys still work if the canvas kept focus. A game that composes its own markup gets the same behavior from `useMenuNav` alone. **The front-end entries, when the game has them.** A menu is more than one Start button. Offer the real front-end verbs a player expects, gated to what the game actually supports: diff --git a/apps/dev/src/appEnv.test.ts b/apps/dev/src/appEnv.test.ts new file mode 100644 index 000000000..9bcd5d20b --- /dev/null +++ b/apps/dev/src/appEnv.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "bun:test"; + +/** + * The runner's environment module is read by tests and tools that load the registry, not only by the + * browser app. Outside a browser it must still import and answer with empty defaults instead of + * touching `window` or an undefined Vite base URL. + */ +describe("runner environment outside a browser", () => { + test("imports without a window and defaults every URL param", async () => { + const env = await import("./appEnv"); + expect(env.GAME_ID).toBeNull(); + expect(env.STATE_PARAM).toBeNull(); + expect(env.MODE).toBe("play"); + expect(env.resolvedRun()).toEqual([]); + }); +}); diff --git a/apps/dev/src/appEnv.ts b/apps/dev/src/appEnv.ts index 98ce96863..cc4349754 100644 --- a/apps/dev/src/appEnv.ts +++ b/apps/dev/src/appEnv.ts @@ -12,9 +12,9 @@ import type { CaptureViewOverrides } from "./captureView"; // The site mounts this runner under /play/ — resolve games' root-absolute // /models and /materials paths against wherever the app is actually served. -installAssetBase(import.meta.env.BASE_URL); +installAssetBase(import.meta.env?.BASE_URL ?? "/"); -const urlParams = new URLSearchParams(window.location.search); +const urlParams = new URLSearchParams(typeof window === "undefined" ? "" : window.location.search); /** Explicit game only — bare `/` shows the picker so demo is never a silent surprise. */ export const GAME_ID = urlParams.get("game") ?? (import.meta.env.VITE_GAME_ID as string | undefined) ?? null; @@ -79,3 +79,4 @@ export const CONVEX_URL = import.meta.env.VITE_CONVEX_URL as string | undefined; export const P2P_ROLE = urlParams.get("p2p"); if (import.meta.env.DEV && GAME_ID !== null) installSaveEndpoint("/__jgengine/save", GAME_ID); + diff --git a/apps/dev/src/demo/gameMenuDemo.test.ts b/apps/dev/src/demo/gameMenuDemo.test.ts new file mode 100644 index 000000000..ff9cddfbe --- /dev/null +++ b/apps/dev/src/demo/gameMenuDemo.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; + +import { gameMenuDemoGame } from "./gameMenuDemo"; + +describe("game menu demo", () => { + test("declares the capture states its views use", () => { + const states = gameMenuDemoGame.capture?.states ?? {}; + const views = gameMenuDemoGame.capture?.views ?? {}; + for (const view of Object.values(views)) { + if (view.state !== undefined) expect(Object.keys(states)).toContain(view.state); + } + expect(Object.keys(views)).toEqual(expect.arrayContaining(["title", "character", "saves", "pause"])); + }); + + test("the runner registry exposes the demo without Vite", async () => { + const { gameRegistry } = await import("../registries"); + expect(typeof gameRegistry["game-menu"]).toBe("function"); + }); +}); + diff --git a/apps/dev/src/demo/gameMenuDemo.tsx b/apps/dev/src/demo/gameMenuDemo.tsx new file mode 100644 index 000000000..bc607345d --- /dev/null +++ b/apps/dev/src/demo/gameMenuDemo.tsx @@ -0,0 +1,511 @@ +import { useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from "react"; + +import { defineGameDefinition } from "@jgengine/core/game/defineGame"; +import { createGameSave, type GameSaveDocument } from "@jgengine/core/game/gameSave"; +import { createSaveSlots } from "@jgengine/core/game/saveSlots"; +import { formatSavePlaytime, gameSaveSlotSource, refreshSaveSlots } from "@jgengine/core/game/saveSlotIndex"; +import { localSaveBackend } from "@jgengine/core/game/saveStore"; +import { mergeCredits } from "@jgengine/core/game/credits"; +import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog"; +import type { GameContext } from "@jgengine/core/runtime/gameContext"; +import { environment, grass, terrain } from "@jgengine/core/world/features"; +import { ContinuePane, CreditsPane, GameMenu, GamePauseMenu, NewCharacterPane, SaveSlotsPane } from "@jgengine/react"; +import { shrinkCapture } from "@jgengine/react/saveThumbnail"; +import { useSettings } from "@jgengine/react/settings"; +import { SettingsSection, SettingsTabs } from "@jgengine/react/settingsBlocks"; +import { EnvironmentScene } from "@jgengine/shell/environment/EnvironmentScene"; +import { SceneCaptureBinding } from "@jgengine/shell/render/sceneCapture"; +import type { PlayableGame } from "@jgengine/shell/registry"; + +const terrainFeature = environment({ + terrain: terrain({ bounds: { w: 80, d: 80 }, height: 3.2, frequency: 0.028, seed: "game-menu" }), + vegetation: grass({ area: { w: 72, d: 72 }, density: 3, colors: ["#1d3a2a", "#7fae45"], seed: "game-menu" }), +}); + +const game = defineGameDefinition({ + name: "game-menu", + assets: createAssetCatalog(), + multiplayer: null, + inventories: {}, + input: { moveForward: ["KeyW"], moveBack: ["KeyS"], moveLeft: ["KeyA"], moveRight: ["KeyD"] }, +}); + +type Screen = "title" | "play" | "paused"; + +const screenStore = (() => { + let screen: Screen = "title"; + const listeners = new Set<() => void>(); + return { + get: () => screen, + set(next: Screen) { + if (next === screen) return; + screen = next; + for (const listener of listeners) listener(); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + highlight: null as string | null, + }; +})(); + +const progress = { waves: 0 }; +const run = { startedAt: Date.now() }; +const playtime = { seconds: 0 }; +const savedMeta: { character: string | null; thumbnail: string | null } = { character: null, thumbnail: null }; +const save = createGameSave({ + backend: localSaveBackend(), + game: "game-menu-demo", + slot: "slot-1", + playtime: () => playtime.seconds, +}); +save.register({ + name: "run", + snapshot: () => ({ seconds: playtime.seconds, waves: progress.waves }), + restore: (next) => { + const value = next as { seconds: number; waves: number }; + playtime.seconds = value.seconds; + progress.waves = value.waves; + }, + reset: () => { + playtime.seconds = 0; + progress.waves = 0; + }, +}); +save.register({ + name: "meta", + snapshot: () => ({ character: savedMeta.character, thumbnail: savedMeta.thumbnail }), + restore: (next) => { + const value = next as { character: string | null; thumbnail: string | null }; + savedMeta.character = value.character; + savedMeta.thumbnail = value.thumbnail; + }, + reset: () => { + savedMeta.character = null; + savedMeta.thumbnail = null; + }, +}); + +const SLOT_NAMES: Record = { "slot-1": "Mara Vell" }; +const slotIndex = createSaveSlots({ + capacity: 3, + slots: [ + { id: "slot-1", empty: true, meta: {} }, + { id: "slot-2", empty: true, meta: {} }, + { id: "slot-3", empty: true, meta: {} }, + ], +}); +const slotOptions = { + nameOf: (_slot: string, document: GameSaveDocument) => + ((document.systems.meta ?? {}) as { character?: string | null }).character ?? "Wanderer", + metaOf: (_slot: string, document: GameSaveDocument) => { + const meta = (document.systems.meta ?? {}) as { character?: string | null; thumbnail?: string | null }; + return { + character: meta.character ?? "Wanderer", + waves: Math.max(1, progress.waves), + playtime: formatSavePlaytime(document.playtime), + ...(typeof meta.thumbnail === "string" && meta.thumbnail.length > 0 ? { thumbnail: meta.thumbnail } : {}), + }; + }, +}; + +const credits = mergeCredits( + { + title: "Game Menu", + sections: [ + { heading: "Engine", entries: [{ label: "JGengine", detail: "menu, saves, input, sound" }] }, + { heading: "Scene", entries: [{ label: "Procedural terrain and grass", detail: "engine features" }] }, + ], + notice: "Demo content only — no third-party assets ship in this scene.", + }, + { sections: [{ heading: "Thanks", entries: [{ label: "Every game that hand-rolled a menu panel before this block existed" }] }] }, +); + +const PALETTES = [ + { id: "dusk", label: "Dusk", colors: ["#7dd3fc", "#1e3a5f", "#f0abfc"] }, + { id: "moss", label: "Moss", colors: ["#a3e635", "#14532d", "#fde68a"] }, + { id: "ember", label: "Ember", colors: ["#fb923c", "#7c2d12", "#fca5a5"] }, +]; +const AVATARS = [ + { id: "warden", label: "Warden", glyph: "W" }, + { id: "scout", label: "Scout", glyph: "S" }, + { id: "mason", label: "Mason", glyph: "M" }, +]; + +const captureRef: { current: (() => string | null) | null } = { current: null }; + +/** + * Authored key art for the demo: a still-horizon wall, two beacon glows and a slow drift. A real + * game passes its own `art` image or leaves the live 3D scene behind the menu; this keeps the demo + * readable when it is captured headless, where WebGL is not drawn. + */ +function DemoKeyArt(): ReactNode { + return ( +
+
+
+
+
+
+ ); +} + + +/** + * The card thumbnail when the frame cannot be read (a headless browser with no WebGL draws nothing + * into the canvas buffer). A real game gets its thumbnail from `captureCanvas`; this keeps the demo's + * card honest in a capture that has no scene. + */ +function posterThumbnail(): string | null { + if (typeof document === "undefined") return null; + const canvas = document.createElement("canvas"); + canvas.width = 320; + canvas.height = 180; + const context = canvas.getContext("2d"); + if (context === null) return null; + const sky = context.createLinearGradient(0, 0, 0, 180); + sky.addColorStop(0, "#0b1622"); + sky.addColorStop(0.45, "#173244"); + sky.addColorStop(1, "#08111a"); + context.fillStyle = sky; + context.fillRect(0, 0, 320, 180); + const glow = context.createRadialGradient(176, 76, 2, 176, 76, 96); + glow.addColorStop(0, "rgba(125,211,252,0.75)"); + glow.addColorStop(1, "rgba(125,211,252,0)"); + context.fillStyle = glow; + context.fillRect(60, 0, 240, 180); + context.fillStyle = "#22404a"; + context.beginPath(); + context.moveTo(0, 180); + context.lineTo(0, 126); + context.lineTo(70, 104); + context.lineTo(132, 134); + context.lineTo(196, 92); + context.lineTo(258, 128); + context.lineTo(320, 108); + context.lineTo(320, 180); + context.closePath(); + context.fill(); + context.fillStyle = "rgba(216,193,105,0.85)"; + context.fillRect(172, 62, 8, 26); + return canvas.toDataURL("image/jpeg", 0.6); +} + +async function writeSave(name: string, thumbnail: string | null): Promise { + playtime.seconds = Math.max(playtime.seconds, Math.floor((Date.now() - run.startedAt) / 1000)); + SLOT_NAMES["slot-1"] = name; + const capture = captureRef.current; + const shot = thumbnail ?? (capture === null ? null : await shrinkCapture(capture(), { width: 320, height: 180 })) ?? posterThumbnail(); + savedMeta.character = name; + savedMeta.thumbnail = shot; + await save.save(); + slotIndex.write("slot-1", { + name, + meta: { + character: name, + playtime: formatSavePlaytime(playtime.seconds), + waves: Math.max(1, progress.waves), + ...(shot === null ? {} : { thumbnail: shot }), + }, + }); + await refreshSaveSlots(gameSaveSlotSource(save), slotIndex, slotOptions); +} + +function SettingsBody(): ReactNode { + const settings = useSettings(); + const ids = useMemo(() => settings.categories.map((category) => category.id), [settings.categories]); + const [activeId, setActiveId] = useState(ids[0] ?? ""); + const category = settings.categories.find((candidate) => candidate.id === activeId) ?? settings.categories[0] ?? null; + return ( +
+
+ Settings +
+ +
+ {category === null ? null : } +
+
+ ); +} + +function GameMenuDemoUI(): ReactNode { + const screen = useSyncExternalStore(screenStore.subscribe, screenStore.get, screenStore.get); + const [recent, setRecent] = useState(() => slotIndex.mostRecent()); + const [name, setName] = useState("Mara Vell"); + const [paletteId, setPaletteId] = useState(PALETTES[0]!.id); + const [avatarId, setAvatarId] = useState(AVATARS[0]!.id); + const focusId = screenStore.highlight; + const playing = screen === "play"; + + useEffect(() => { + void refreshSaveSlots(gameSaveSlotSource(save), slotIndex, slotOptions).then(() => setRecent(slotIndex.mostRecent())); + return slotIndex.subscribe(() => setRecent(slotIndex.mostRecent())); + }, []); + + useEffect(() => { + if (!playing) return; + const timer = setInterval(() => { + playtime.seconds += 3; + if (Math.floor(playtime.seconds / 30) > progress.waves) progress.waves += 1; + }, 3000); + return () => clearInterval(timer); + }, [playing]); + + useEffect(() => { + const onKey = (event: KeyboardEvent): void => { + if (event.code !== "Escape") return; + if (screenStore.get() === "play") screenStore.set("paused"); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); + + const resume = (slotId: string): void => { + run.startedAt = Date.now(); + void save.switchSlot(slotId, { flush: false }).then(() => { + void refreshSaveSlots(gameSaveSlotSource(save), slotIndex, slotOptions).then(() => setRecent(slotIndex.mostRecent())); + screenStore.set("play"); + }); + }; + + const detailFor = (id: string | null): ReactNode => { + ((window as unknown as { __menuLog?: string[] }).__menuLog ??= []).push(String(id)); + switch (id) { + case "continue": + return resume(slot.id)} />; + case "new": + return ( + { + SLOT_NAMES["slot-1"] = choice.name; + run.startedAt = Date.now(); + playtime.seconds = 0; + progress.waves = 0; + void writeSave(choice.name, null).then(() => screenStore.set("play")); + }} + /> + ); + case "load": + return ( + Date.now()} + onContinue={(slot) => resume(slot.id)} + onLoad={(slot) => resume(slot.id)} + onNew={(slot) => { + SLOT_NAMES[slot.id] = name; + playtime.seconds = 0; + progress.waves = 0; + void save.switchSlot(slot.id, { flush: false }).then(() => screenStore.set("play")); + }} + onDelete={(slot) => { + slotIndex.clear(slot.id); + void save.deleteSlot(slot.id).then(() => setRecent(slotIndex.mostRecent())); + }} + /> + ); + case "settings": + return ; + case "credits": + return ; + default: + return null; + } + }; + + return ( +
+ {playing ? ( +
+ Playing — Esc pauses +
+ ) : null} + +
+ } + logo={{ text: "Beacon Bastion", title: "Beacon Bastion" }} + eyebrow="Front-end demo" + tagline="A live scene behind the menu, a detail pane per verb, and saves that resume a real character." + items={[ + { id: "continue", label: "Continue", hint: recent === null ? "No run saved yet" : `${recent.name ?? recent.id} · ${String(recent.meta.playtime ?? "")}`, disabled: recent === null }, + { id: "new", label: "New defense", hint: "Name a commander and take the wall" }, + { id: "load", label: "Load & save", hint: `${slotIndex.list().filter((slot) => !slot.empty).length} of 3 slots used` }, + { id: "settings", label: "Settings" }, + { id: "credits", label: "Credits" }, + ]} + detail={detailFor} + onHighlight={(id) => { + if (id === "load") void refreshSaveSlots(gameSaveSlotSource(save), slotIndex, slotOptions); + }} + onSelect={(id) => { + if (id === "new") { + document.querySelector("[data-new-character-name]")?.focus(); + return; + } + if (id === "continue" && recent !== null) return resume(recent.id); + if (id === "load") { + document.querySelector("[data-save-slot-load]")?.focus(); + return; + } + if (id === "credits") { + document.querySelector("[data-credits-scroller]")?.focus(); + return; + } + }} + footer="game-menu demo · v0.0.0" + /> +
+ +
+ (id === "settings" ? : null)} + onResume={() => screenStore.set("play")} + onSelect={(id) => { + if (id === "resume") screenStore.set("play"); + else if (id === "save") { + void writeSave(name, null).then(() => setRecent(slotIndex.mostRecent())); + } else if (id === "title") screenStore.set("title"); + }} + /> +
+
+ ); +} + +function onInit(ctx: GameContext): void { + ctx.game.commands.define("menu.play", { apply: (state) => { screenStore.set("play"); return state; } }); + ctx.game.commands.define("menu.pause", { apply: (state) => { screenStore.set("paused"); return state; } }); + ctx.game.commands.define("menu.title", { apply: (state) => { screenStore.set("title"); return state; } }); + ctx.game.commands.define("menu.focusNew", { apply: (state) => { screenStore.highlight = "new"; screenStore.set("title"); return state; } }); + ctx.game.commands.define("menu.focusLoad", { apply: (state) => { screenStore.highlight = "load"; screenStore.set("title"); return state; } }); + ctx.game.commands.define("menu.focusCredits", { apply: (state) => { screenStore.highlight = "credits"; screenStore.set("title"); return state; } }); + ctx.game.commands.define("menu.seedSave", { + apply: (state) => { + playtime.seconds = 4287; + progress.waves = 12; + void writeSave("Mara Vell", null).then(() => screenStore.set("title")); + return state; + }, + }); +} + +export const gameMenuDemoGame: PlayableGame = { + game, + content: { entityById: () => null, objectById: () => null }, + loop: { onInit, onNewPlayer: () => {}, onTick: () => {}, onReset: () => {}, onDispose: () => {} }, + GameUI: GameMenuDemoUI, + environment: () => , + WorldOverlay: () => { captureRef.current = capture; }} />, + camera: { initialDistance: 30, initialHeight: 22, minDistance: 14, maxDistance: 60, targetHeight: 0, maxPolarAngle: 1.32 }, + settings: { variant: "panel" }, + capture: { + states: { + title: [{ name: "menu.seedSave" }], + character: [{ name: "menu.seedSave" }, { name: "menu.focusNew" }], + saves: [{ name: "menu.seedSave" }, { name: "menu.focusLoad" }], + credits: [{ name: "menu.seedSave" }, { name: "menu.focusCredits" }], + pause: [{ name: "menu.play" }, { name: "menu.pause" }], + }, + views: { + title: { description: "Title menu over the live scene, Continue highlighted", settleMs: 1400 }, + character: { description: "Character creation pane", state: "character", settleMs: 1400 }, + saves: { description: "Slot grid with a real save", state: "saves", settleMs: 1600 }, + credits: { description: "Credits scroller pane", state: "credits", settleMs: 1400 }, + pause: { description: "Pause menu from the same system", state: "pause", settleMs: 900 }, + }, + }, +}; + + + + + + + + + + + diff --git a/apps/dev/src/registries.tsx b/apps/dev/src/registries.tsx index 91cebd756..69433ddb8 100644 --- a/apps/dev/src/registries.tsx +++ b/apps/dev/src/registries.tsx @@ -103,20 +103,24 @@ function rigConfigOf(config: GameCameraConfig): CameraRigConfig { return rest; } -const gameModules = import.meta.glob<{ +// Vite rewrites every `import.meta.glob(...)` call below into a module map. Outside a browser +// (a test or a tool importing this registry) those calls cannot run, so the registry has no Games. +const hasViteGlob = typeof window !== "undefined"; + +const gameModules = (hasViteGlob ? import.meta.glob<{ game: PlayableGame; uiScenario?: UiPreviewScenario; editorLayers?: import("@jgengine/core/editor/index").EditorLayersInput; editorCatalogs?: import("@jgengine/core/editor/index").EditorCatalogsInput; -}>("../../../Games/*/src/index.tsx"); +}>("../../../Games/*/src/index.tsx") : {}); -const gameStyleModules = import.meta.glob>("../../../Games/*/src/style.css"); +const gameStyleModules = (hasViteGlob ? import.meta.glob>("../../../Games/*/src/style.css") : {}); -const gameSourceModules = import.meta.glob>([ +const gameSourceModules = (hasViteGlob ? import.meta.glob>([ "../../../Games/*/src/**/*.{ts,tsx}", "!**/main.tsx", "!**/*.test.*", -]); +]) : {}); export async function loadGameStyle(gameId: string): Promise { await gameStyleModules[`../../../Games/${gameId}/src/style.css`]?.(); @@ -149,9 +153,9 @@ const gameEntries = Object.fromEntries( gameLoaders.map(([id, loader]) => [id, () => loader().then((module) => module.game)]), ); -const editorSceneModules = import.meta.glob<{ default: unknown }>( +const editorSceneModules = (hasViteGlob ? import.meta.glob<{ default: unknown }>( "../../../Games/*/src/editor.scene.json", -); +) : {}); const editorSceneRegistry: Partial Promise>> = Object.fromEntries( Object.entries(editorSceneModules).map(([path, loader]) => [ @@ -193,6 +197,7 @@ export const gameRegistry: GameRegistry = { "terrain-layers": () => import("./demo/terrainLayersDemo").then((module) => module.terrainLayersGame), "terrain-macro": () => import("./demo/terrainMacroDemo").then((module) => module.terrainMacroGame), "menu-framework": () => import("./demo/menuFrameworkDemo").then((module) => module.menuFrameworkDemoGame), + "game-menu": () => import("./demo/gameMenuDemo").then((module) => module.gameMenuDemoGame), demo: () => import("./demo/demoGame").then((module) => module.demoGame), "physics-probe": () => import("./demo/physicsProbeDemo").then((module) => module.physicsProbeGame), "guard-probe": () => import("./demo/guardProbeDemo").then((module) => module.guardProbeGame), @@ -271,11 +276,15 @@ export const uiScenarioRegistry: Partial Promise; -const previewModules = import.meta.glob<{ +const previewModules = (hasViteGlob ? import.meta.glob<{ default: PreviewComponent; states?: Record; -}>("../../../Games/*/src/preview.tsx"); +}>("../../../Games/*/src/preview.tsx") : {}); export const previewLoaders = Object.fromEntries( Object.entries(previewModules).map(([path, loader]) => [path.split("/").at(-3)!, loader] as const), ); + + + + diff --git a/changes/agent-lane-main-menu.md b/changes/agent-lane-main-menu.md new file mode 100644 index 000000000..5b1976a3a --- /dev/null +++ b/changes/agent-lane-main-menu.md @@ -0,0 +1,10 @@ +### Added + +- `GameMenu` and `GamePauseMenu` (`@jgengine/react/gameMenu`): a full-bleed menu surface over the live scene or drifting key art, with a token-sized logo, an animated navigable verb list, a generated selection tick and a detail pane per verb. The document scroll is locked while it is open, so the surface is exactly the viewport at any size. +- Panes for the detail slot: `ContinuePane`, `NewCharacterPane`, `SaveSlotsPane` (slot cards with a `meta.thumbnail`, playtime, progress, timestamp and a two-press delete) and `CreditsPane` (`@jgengine/react/gameMenuSaves`). +- `shrinkCapture` (`@jgengine/react/saveThumbnail`) turns a `captureCanvas` frame into a card-sized thumbnail data URL. + +### Changed + +- `switchSlot(slot, { flush: false })` (`@jgengine/core/game/gameSave`) loads a slot without first writing the live state over it. Opening a save from a menu uses it so Continue cannot overwrite the save it resumes. +- The dev runner's registry and environment modules import outside Vite, so a test or tool can read them. diff --git a/packages/core/src/game/gameSave.test.ts b/packages/core/src/game/gameSave.test.ts index 727dae430..b42f563e7 100644 --- a/packages/core/src/game/gameSave.test.ts +++ b/packages/core/src/game/gameSave.test.ts @@ -422,3 +422,37 @@ describe("createGameSave — failures and digest", () => { expect(otherRng()).toBe(fresh()); }); }); + +describe("opening a save from a menu", () => { + test("switchSlot with flush false loads the slot without overwriting it", async () => { + const { backend } = recordingBackend(); + const live = { level: 3 }; + const save = createGameSave({ + backend, + game: "flush-test", + slot: "slot-1", + playtime: () => 0, + }); + save.register({ + name: "run", + snapshot: () => ({ level: live.level }), + restore: (next) => { + live.level = (next as { level: number }).level; + }, + reset: () => { + live.level = 0; + }, + }); + await save.save(); + const stored = await save.peek("slot-1"); + + live.level = 99; + await save.switchSlot("slot-1", { flush: false }); + expect(live.level).toBe(3); + expect(await save.peek("slot-1")).toEqual(stored); + + live.level = 42; + await save.switchSlot("slot-1"); + expect((await save.peek("slot-1"))?.systems.run).toEqual({ level: 42 }); + }); +}); diff --git a/packages/core/src/game/gameSave.ts b/packages/core/src/game/gameSave.ts index f77520251..cc8afa530 100644 --- a/packages/core/src/game/gameSave.ts +++ b/packages/core/src/game/gameSave.ts @@ -117,8 +117,12 @@ export interface GameSave { save(): Promise; /** Read the active slot and apply every section to its system. */ load(): Promise; - /** Flush the current slot, switch, and load the next one. */ - switchSlot(slot: string): Promise; + /** + * Switch the active slot and load it. By default the live state is flushed into the slot being + * left first; pass `{ flush: false }` when opening a save from a menu, where the live state is + * either empty or already the slot being opened and flushing would overwrite the save. + */ + switchSlot(slot: string, options?: { flush?: boolean }): Promise; /** * Read a slot's document without applying it to the live systems — the save-select menu's data * source. Returns `null` for an empty slot. @@ -286,8 +290,8 @@ export function createGameSave(config: GameSaveConfig): GameSave { apply(document); return document; }, - async switchSlot(slot) { - store.set(capture()); + async switchSlot(slot, options) { + if (options?.flush !== false) store.set(capture()); const document = await store.switchSlot(slot); apply(document); return document; @@ -324,3 +328,4 @@ export function createGameSave(config: GameSaveConfig): GameSave { }, }; } + diff --git a/packages/react/src/engineStyles.ts b/packages/react/src/engineStyles.ts index 3fcf10d1d..b1d9c3413 100644 --- a/packages/react/src/engineStyles.ts +++ b/packages/react/src/engineStyles.ts @@ -57,6 +57,11 @@ export const ENGINE_STYLESHEET = `/* @jgengine/react engine stylesheet */ @keyframes jg-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } @keyframes jg-delta-up { 0% { color: var(--jg-success, #3fa34d); text-shadow: 0 0 10px var(--jg-success, #3fa34d); scale: 1.12; } 100% { scale: 1; } } @keyframes jg-delta-down { 0% { color: var(--jg-danger, #e5484d); text-shadow: 0 0 10px var(--jg-danger, #e5484d); scale: 0.94; } 100% { scale: 1; } } +@keyframes jg-menu-drift { from { transform: scale(1.06) translate3d(-1.4%, -1%, 0); } to { transform: scale(1.12) translate3d(1.6%, 1.2%, 0); } } +@keyframes jg-menu-rise { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: translateY(0); } } +@keyframes jg-menu-pane-in { from { opacity: 0; transform: translateX(14px); } to { opacity: 1; transform: translateX(0); } } +[data-jg-game-menu] * { scrollbar-width: none; } +[data-jg-game-menu] *::-webkit-scrollbar { width: 0; height: 0; } [data-jg-hud] { font-family: var(--jg-font-body, ${HUD_FONT_STACK}); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; } :where([data-jg-hud]) :where(button, [role="button"], a[href], summary, [data-jg-interactive]):focus-visible { outline: 2px solid var(--jg-accent, #38bdf8); outline-offset: 2px; } diff --git a/packages/react/src/gameMenu.test.ts b/packages/react/src/gameMenu.test.ts new file mode 100644 index 000000000..805c670b9 --- /dev/null +++ b/packages/react/src/gameMenu.test.ts @@ -0,0 +1,255 @@ +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdir, rm, symlink } from "node:fs/promises"; +import { resolve } from "node:path"; +import { chromium, type Browser, type Page } from "playwright-core"; +import { buildBrowserFixture, findChromeExecutable } from "../../../scripts/browser-lib"; + +/** + * A game menu is judged on the rendered surface, not on its props: the page must never scroll, the + * slot cards must carry real save data, Continue must resume the newest slot, delete must ask first, + * and every verb must be reachable from the keyboard. These run in Chrome at three viewport sizes. + */ + +setDefaultTimeout(45_000); + +const scratch = resolve(import.meta.dir, "../../../.scratch/game-menu-test"); +let browser: Browser; +let page: Page; +let server: ReturnType; + +const VIEWPORTS = [ + { width: 1600, height: 900 }, + { width: 1024, height: 640 }, + { width: 844, height: 390 }, +]; + +beforeAll(async () => { + await mkdir(scratch, { recursive: true }); + await symlink(resolve(import.meta.dir, "../node_modules"), `${scratch}/node_modules`, "dir"); + const entry = `${scratch}/fixture.tsx`; + await Bun.write( + entry, + ` + import React, { useState } from 'react'; + import { createRoot } from 'react-dom/client'; + import { flushSync } from 'react-dom'; + import { createSaveSlots } from '../../packages/core/src/game/saveSlots'; + import { GameMenu } from '../../packages/react/src/gameMenu'; + import { ContinuePane, SaveSlotsPane } from '../../packages/react/src/gameMenuSaves'; + + const now = 1_700_000_000_000; + const model = createSaveSlots({ + now: () => now, + slots: [ + { id: 'slot-1', empty: true, meta: {} }, + { id: 'slot-2', empty: true, meta: {} }, + { id: 'slot-3', empty: true, meta: {} }, + ], + }); + model.write('slot-1', { + name: 'Old Hand', + savedAt: now - 90 * 60_000, + meta: { playtime: '1h 4m', character: 'Old Hand', level: 4, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' }, + }); + model.write('slot-2', { + name: 'Mara Vell', + savedAt: now - 60_000, + meta: { playtime: '4h 12m', character: 'Mara Vell', level: 9, thumbnail: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7' }, + }); + + const record = { chosen: [], continued: [], loaded: [], deleted: [] }; + const root = createRoot(document.getElementById('root')); + + function Fixture() { + const [name, setName] = useState(''); + const [active, setActive] = useState('continue'); + window.fixture = { + record, + setActive(id) { flushSync(() => setActive(id)); }, + active: () => document.querySelector('[data-jg-game-menu-item][data-active]')?.getAttribute('data-jg-game-menu-item') ?? null, + }; + const detail = (id) => { + if (id === 'continue') return now} onContinue={(slot) => record.continued.push(slot.id)} />; + if (id === 'new') return
name: {name}
; + if (id === 'load') return now} onContinue={(slot) => record.continued.push(slot.id)} onLoad={(slot) => record.loaded.push(slot.id)} onNew={(slot) => record.chosen.push(slot.id)} onDelete={(slot) => record.deleted.push(slot.id)} />; + return
tall pane
; + }; + return ( + { if (id !== null) setActive(id); }} + onSelect={(id) => record.chosen.push(id)} + onBack={() => record.chosen.push('back')} + /> + ); + } + + root.render(); + `, + ); + const script = buildBrowserFixture(entry); + server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: (request) => + new URL(request.url).pathname === "/fixture.js" + ? new Response(script, { headers: { "Content-Type": "text/javascript" } }) + : new Response('
', { + headers: { "Content-Type": "text/html" }, + }), + }); + browser = await chromium.launch({ executablePath: findChromeExecutable(), headless: true, args: ["--no-sandbox"] }); + page = await browser.newPage({ viewport: VIEWPORTS[0]! }); + await page.goto(`http://127.0.0.1:${server.port}`); + await page.waitForFunction(() => typeof (window as unknown as { fixture?: unknown }).fixture !== "undefined"); +}, 60_000); + +afterAll(async () => { + await browser?.close(); + server?.stop(true); + await rm(scratch, { recursive: true, force: true }); +}); + +interface FixtureWindow { + fixture: { + record: { chosen: string[]; continued: string[]; loaded: string[]; deleted: string[] }; + setActive(id: string): void; + active(): string | null; + }; +} + +async function showSlotGrid(): Promise { + await page.click("[data-jg-game-menu-item='load']"); + await page.waitForSelector("[data-save-slots-grid]"); +} + +describe("GameMenu surface", () => { + test("the document never scrolls at any viewport size", async () => { + for (const viewport of VIEWPORTS) { + await page.setViewportSize(viewport); + const measured = await page.evaluate(() => { + const root = document.querySelector("[data-jg-game-menu]") as HTMLElement; + const rect = root.getBoundingClientRect(); + return { + documentScrollX: document.documentElement.scrollWidth - window.innerWidth, + documentScrollY: document.documentElement.scrollHeight - window.innerHeight, + bodyScrollY: document.body.scrollHeight - window.innerHeight, + width: Math.round(rect.width), + height: Math.round(rect.height), + position: getComputedStyle(root).position, + overflow: getComputedStyle(root).overflow, + innerWidth: window.innerWidth, + innerHeight: window.innerHeight, + documentOverflow: getComputedStyle(document.documentElement).overflow, + }; + }); + expect({ viewport, x: measured.documentScrollX }).toEqual({ viewport, x: 0 }); + expect({ viewport, y: measured.documentScrollY }).toEqual({ viewport, y: 0 }); + expect(measured.bodyScrollY).toBeLessThanOrEqual(0); + expect(measured.position).toBe("fixed"); + expect(measured.overflow).toBe("hidden"); + expect(measured.documentOverflow).toBe("hidden"); + expect(measured.width).toBe(measured.innerWidth); + expect(measured.height).toBe(measured.innerHeight); + } + await page.setViewportSize(VIEWPORTS[0]!); + }); + + test("the title treatment is sized by the logo token, not a heading tag", async () => { + const logo = await page.evaluate(() => { + const node = document.querySelector("[data-jg-game-menu-logo]") as HTMLElement; + return { tag: node.tagName, text: node.textContent, size: getComputedStyle(node).fontSize }; + }); + expect(logo.tag).not.toBe("H1"); + expect(logo.tag).not.toBe("H2"); + expect(logo.text).toContain("Beacon Bastion"); + expect(Number.parseFloat(logo.size)).toBeGreaterThan(24); + }); + + test("the highlighted verb drives the detail pane", async () => { + await page.click("[data-jg-game-menu-item='continue']"); + await page.waitForSelector("[data-continue-pane]"); + expect(await page.getAttribute("[data-jg-game-menu-detail]", "data-detail-id")).toBe("continue"); + await page.click("[data-jg-game-menu-item='credits']"); + await page.waitForSelector("[data-other-pane='credits']"); + expect(await page.getAttribute("[data-jg-game-menu-detail]", "data-detail-id")).toBe("credits"); + }); + + test("a slot card renders its character, playtime, progress, timestamp and thumbnail", async () => { + await showSlotGrid(); + const card = await page.evaluate(() => { + const node = document.querySelector("[data-save-slot-id='slot-2']") as HTMLElement; + return { + name: node.querySelector("[data-save-slot-name]")?.textContent ?? "", + character: node.querySelector("[data-save-slot-character]")?.textContent ?? "", + playtime: node.querySelector("[data-save-slot-playtime]")?.textContent ?? "", + chip: node.querySelector("[data-save-slot-chip='level']")?.textContent ?? "", + savedAt: node.querySelector("[data-save-slot-saved-at]")?.textContent ?? "", + thumb: (node.querySelector("[data-save-slot-thumb-img]") as HTMLImageElement | null)?.src.slice(0, 20) ?? "", + recent: node.getAttribute("data-recent"), + }; + }); + expect(card.name).toBe("Mara Vell"); + expect(card.character).toBe("Mara Vell"); + expect(card.playtime).toContain("4h 12m"); + expect(card.chip).toBe("level 9"); + expect(card.savedAt).toBe("1m ago"); + expect(card.thumb.startsWith("data:image/")).toBe(true); + expect(card.recent).toBe(""); + }); + + test("an empty slot looks like an empty slot", async () => { + const empty = await page.evaluate(() => { + const node = document.querySelector("[data-save-slot-id='slot-3']") as HTMLElement; + return { empty: node.hasAttribute("data-empty"), label: node.textContent ?? "" }; + }); + expect(empty.empty).toBe(true); + expect(empty.label).toContain("New game"); + }); + + test("Continue resumes the newest slot", async () => { + await page.click("[data-save-slots-continue]"); + const continued = await page.evaluate(() => (window as unknown as FixtureWindow).fixture.record.continued); + expect(continued.at(-1)).toBe("slot-2"); + }); + + test("delete asks before it deletes", async () => { + await page.click("[data-save-slot-id='slot-1'] [data-save-slot-delete]"); + expect(await page.$("[data-save-slot-id='slot-1'] [data-save-slot-confirm-delete]")).not.toBeNull(); + expect(await page.evaluate(() => (window as unknown as FixtureWindow).fixture.record.deleted)).toEqual([]); + await page.click("[data-save-slot-id='slot-1'] [data-save-slot-cancel-delete]"); + expect(await page.evaluate(() => (window as unknown as FixtureWindow).fixture.record.deleted)).toEqual([]); + await page.click("[data-save-slot-id='slot-1'] [data-save-slot-delete]"); + await page.click("[data-save-slot-id='slot-1'] [data-save-slot-confirm-delete]"); + expect(await page.evaluate(() => (window as unknown as FixtureWindow).fixture.record.deleted)).toEqual(["slot-1"]); + }); + + test("the keyboard reaches every verb and confirms it", async () => { + await page.click("[data-jg-game-menu-item='continue']"); + await page.focus("[data-jg-game-menu-item][data-active]"); + const seen: (string | null)[] = [await page.evaluate(() => (window as unknown as FixtureWindow).fixture.active())]; + for (let step = 0; step < 5; step += 1) { + await page.keyboard.press("ArrowDown"); + seen.push(await page.evaluate(() => (window as unknown as FixtureWindow).fixture.active())); + } + expect(new Set(seen)).toEqual(new Set(["continue", "new", "load", "settings", "credits"])); + await page.keyboard.press("ArrowUp"); + await page.keyboard.press("Enter"); + const chosen = await page.evaluate(() => (window as unknown as FixtureWindow).fixture.record.chosen); + expect(chosen.at(-1)).toBe("credits"); + }); +}); + + + + diff --git a/packages/react/src/gameMenu.tsx b/packages/react/src/gameMenu.tsx new file mode 100644 index 000000000..039825ef7 --- /dev/null +++ b/packages/react/src/gameMenu.tsx @@ -0,0 +1,704 @@ +import { + useEffect, + useMemo, + useRef, + type CSSProperties, + type ReactNode, +} from "react"; + +import { useReducedMotion } from "./accessibility"; +import { createGameMenuSound, type GameMenuSound } from "./gameMenuSound"; +import { hudDuration, hudEase, hudFont, hudType } from "./hudTokens"; +import { useMenuNav } from "./menuNav"; + +/** + * A game's front-end menu: a full-bleed surface over a living background, a logo, primary verbs on + * the left that animate as the selection moves, and a detail pane on the right that changes with the + * highlighted verb. It is the block a main menu, a pause menu and their sub-screens are built from, + * so a game composes one menu system instead of a stack of modal panels. + * + * The surface is exactly the viewport: `position: fixed` with `overflow: hidden`, and the document + * scroll is locked while it is open, so a tall menu can never grow a page scrollbar. Content that + * genuinely scrolls (credits) scrolls inside its own pane. + */ + +/** One primary verb in a {@link GameMenu}. */ +export interface GameMenuItem { + /** Stable id the game routes on. */ + id: string; + /** Player-facing label. */ + label: string; + /** One short line under the label — a save summary, a state note. Omit for a clean row. */ + hint?: string; + /** Show the row but skip it in navigation and clicks. */ + disabled?: boolean; + /** Small marker at the end of the row (a badge, a count, a key glyph). */ + badge?: ReactNode; +} + +/** The logo slot. Give it `image` for art, or `text` for a styled wordmark. */ +export interface GameMenuLogo { + /** Image URL for the game's logo. Sized by the `--jg-menu-logo` token, never by a heading tag. */ + image?: string; + /** Alt text for the logo image. Defaults to `title` when that is a string. */ + alt?: string; + /** Styled type instead of an image. */ + text?: ReactNode; + /** Plain title, used for the accessible name and as the alt fallback. */ + title?: string; +} + +/** How a {@link GameMenu} is used. `pause` is the same system at a smaller scale. */ +export type GameMenuVariant = "title" | "pause"; + +/** Props for {@link GameMenu}. */ +export interface GameMenuProps { + /** Primary verbs, in focus order. */ + items: readonly GameMenuItem[]; + /** The verb focused on mount. Default the first enabled one. */ + initialId?: string; + /** A verb was confirmed (Enter, Space, A, or a click). */ + onSelect: (id: string) => void; + /** Back (Escape, Backspace, B, Start). On a pause menu this is the resume action. */ + onBack?: () => void; + /** The highlighted verb changed — use it to load the detail for `id`. */ + onHighlight?: (id: string | null) => void; + /** The right-hand pane's content for the highlighted verb. */ + detail?: ReactNode | ((id: string | null) => ReactNode); + /** Logo slot: an image or styled type, sized by the logo token. */ + logo?: GameMenuLogo | ReactNode; + /** Small line above the logo (studio, chapter, mode). */ + eyebrow?: ReactNode; + /** One line under the logo. */ + tagline?: ReactNode; + /** + * Layer painted across the whole viewport behind the menu: a live scene component, key art, a + * video. Leave it out when the engine's own 3D canvas sits behind the menu — the menu stays + * transparent and only tints and vignettes it, so play keeps rendering underneath. + */ + background?: ReactNode; + /** + * Key-art URL for the drifting background layer. Omit when the live scene behind the menu is the + * background. With neither, the menu still tints and vignettes whatever is behind it. + */ + art?: string; + /** Secondary verbs at the bottom-left (quit, back). Same navigation, quieter styling. */ + secondary?: readonly GameMenuItem[]; + /** Footer left slot: build version, studio. */ + footer?: ReactNode; + /** Footer right slot: replaces the default control hint. */ + controlHint?: ReactNode; + /** Selection sound. Default on. Pass `false` for silence, or a player from `createGameMenuSound`. */ + sound?: boolean | GameMenuSound; + /** Title menu or the smaller pause menu. Default `"title"`. */ + variant?: GameMenuVariant; + /** Open state the game owns. A closed menu renders nothing and releases the document scroll. */ + open?: boolean; + /** Force motion off. Reduced motion is also read from the accessibility preference and the OS. */ + reducedMotion?: boolean; + /** Accessible name of the surface. Default `"Main menu"` (`"Paused"` for the pause variant). */ + label?: string; + className?: string; + style?: CSSProperties; + detailClassName?: string; + detailStyle?: CSSProperties; +} + +let scrollLocks = 0; +let savedDocumentOverflow = ""; +let savedBodyOverflow = ""; + +function lockDocumentScroll(): () => void { + if (typeof document === "undefined") return () => {}; + if (scrollLocks === 0) { + savedDocumentOverflow = document.documentElement.style.overflow; + savedBodyOverflow = document.body.style.overflow; + document.documentElement.style.overflow = "hidden"; + document.body.style.overflow = "hidden"; + } + scrollLocks += 1; + let released = false; + return () => { + if (released) return; + released = true; + scrollLocks -= 1; + if (scrollLocks > 0 || typeof document === "undefined") return; + document.documentElement.style.overflow = savedDocumentOverflow; + document.body.style.overflow = savedBodyOverflow; + }; +} + +/** + * Lock the page scroll while `active`, so a fixed full-bleed surface is exactly the viewport and the + * document behind it never gains a scrollbar. Nested and repeated locks are counted, and the previous + * inline styles are restored when the last one releases. + * + * @capability use-viewport-scroll-lock count-aware document scroll lock for full-bleed surfaces + */ +export function useViewportScrollLock(active: boolean): void { + useEffect(() => { + if (!active) return; + return lockDocumentScroll(); + }, [active]); +} + +const ROW_HEIGHT: Record = { title: 58, pause: 46 }; + +const VIGNETTE = + "radial-gradient(125% 110% at 26% 42%, rgba(4,7,12,0.02) 0%, rgba(4,7,12,0.34) 66%, rgba(2,4,7,0.8) 100%)"; + +const TINT = + "linear-gradient(100deg, rgba(6,10,16,0.9) 0%, rgba(6,10,16,0.74) 34%, rgba(6,10,16,0.34) 56%, rgba(6,10,16,0.08) 78%, rgba(6,10,16,0.28) 100%)"; + +function logoNode(logo: GameMenuProps["logo"], title: string | undefined, reduced: boolean): ReactNode { + if (logo === undefined || logo === null || logo === false) return null; + if (typeof logo === "object" && !Array.isArray(logo) && !("type" in (logo as object))) { + const spec = logo as GameMenuLogo; + const size = "var(--jg-menu-logo-size, clamp(30px, 4.6vw, 66px))"; + if (spec.image !== undefined) { + return ( + {spec.alt + ); + } + return ( +
+ {spec.text ?? spec.title ?? title} +
+ ); + } + return ( +
+ {logo as ReactNode} +
+ ); +} + +/** + * A game front-end menu: full-bleed over a living background, with a logo, an animated primary list + * and a detail pane that follows the highlighted verb. One system covers the main menu and the pause + * menu (`variant="pause"`), keyboard and gamepad navigation come from `useMenuNav`, confirmation + * ticks are generated (no audio asset), and the document never scrolls while it is open. + * + * The engine owns the surface, layout, motion, focus order, sound and the save/character panes it + * exports next to this component. The game owns the art, the logo, the wording, the verbs and what + * each detail pane shows. + * + * @capability game-menu full-bleed game menu — living background, token-sized logo, animated navigable verbs, detail pane, selection sound, pause variant, no page scrollbar + */ +export function GameMenu({ + items, + initialId, + onSelect, + onBack, + onHighlight, + detail, + logo, + eyebrow, + tagline, + background, + art, + secondary, + footer, + controlHint, + sound = true, + variant = "title", + open = true, + reducedMotion, + label, + className, + style, + detailClassName, + detailStyle, +}: GameMenuProps): ReactNode { + const prefersReduced = useReducedMotion(); + const reduced = reducedMotion === undefined ? prefersReduced : reducedMotion || prefersReduced; + useViewportScrollLock(open); + + const allItems = useMemo(() => [...items, ...(secondary ?? [])], [items, secondary]); + const disabled = useMemo( + () => allItems.filter((item) => item.disabled === true).map((item) => item.id), + [allItems], + ); + + const soundRef = useRef(null); + useEffect(() => { + if (sound === false) return; + const player = typeof sound === "object" ? sound : createGameMenuSound(); + soundRef.current = player; + return () => { + if (typeof sound !== "object") player.dispose(); + soundRef.current = null; + }; + }, [sound]); + + const onHighlightRef = useRef(onHighlight); + onHighlightRef.current = onHighlight; + + const nav = useMenuNav({ + ids: allItems.map((item) => item.id), + disabled, + enabled: open, + ...(initialId === undefined ? {} : { initialId }), + onConfirm: (id) => { + const item = allItems.find((candidate) => candidate.id === id); + if (item === undefined || item.disabled === true) return; + soundRef.current?.play("confirm"); + onSelect(id); + }, + ...(onBack === undefined + ? {} + : { + onBack: () => { + soundRef.current?.play("back"); + onBack(); + }, + }), + }); + + // A cursor that happens to rest over a row when the menu opens must not select it: hover only + // counts once the pointer has actually moved in this session. + const pointerMoved = useRef(false); + useEffect(() => { + if (typeof window === "undefined") return; + const mark = (): void => { pointerMoved.current = true; }; + window.addEventListener("pointermove", mark, { passive: true }); + return () => window.removeEventListener("pointermove", mark); + }, []); + const hoverProps = (id: string) => { + const props = nav.itemProps(id); + return { ...props, onPointerEnter: () => { if (pointerMoved.current) props.onPointerEnter(); } }; + }; + + const activeId = nav.activeId; + const activeIndex = allItems.findIndex((item) => item.id === activeId); + const mounted = useRef(false); + const appliedInitial = useRef(initialId); + // `initialId` is the verb the game wants focused (Continue when a save exists). When that changes + // after mount — a save finished loading, a screen swapped — move the selection rather than + // remounting the whole menu, which would replay the entry animation and drop focus. + useEffect(() => { + if (initialId === undefined || appliedInitial.current === initialId || disabled.includes(initialId)) return; + appliedInitial.current = initialId; + nav.setActive(initialId); + }, [initialId, disabled, nav.activeId, nav.setActive]); + // The first pass can run before item refs exist, so focus the selection once after paint when the + // menu owns an idle document. Arrow keys then land on the menu itself instead of the page. + useEffect(() => { + if (!open) return; + if (typeof requestAnimationFrame !== "function") return; + const frame = requestAnimationFrame(() => { + const active = typeof document === "undefined" ? null : (document.activeElement as HTMLElement | null); + const insideMenu = active !== null && typeof active.closest === "function" && active.closest("[data-jg-game-menu]") !== null; + if (insideMenu && active !== document.body) return; + const target = document.querySelector("[data-jg-game-menu] [data-jg-menu-item][aria-current='true']"); + target?.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, activeId]); + useEffect(() => { + if (!mounted.current) { + mounted.current = true; + } else if (activeId !== null) { + soundRef.current?.play("move"); + } + onHighlightRef.current?.(activeId); + }, [activeId]); + + if (!open) return null; + + const isPause = variant === "pause"; + const rowHeight = ROW_HEIGHT[variant]; + const primaryCount = items.length; + const detailNode = typeof detail === "function" ? detail(activeId) : detail; + const title = typeof logo === "object" && logo !== null && !Array.isArray(logo) ? (logo as GameMenuLogo).title : undefined; + const surfaceLabel = label ?? (isPause ? "Paused" : "Main menu"); + + return ( +
+