From 30cba5374eca576cb2ab66d33f2045e8173b516e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 18:50:46 +0000 Subject: [PATCH 01/34] docs(demos): redesign the car demo page layout Add a DemoPage layout (game with fullscreen and focus, a controls card, a short 'how it works' list linked to the code, and a code explorer with an always-visible, grouped file tree), use it for the car demo, and trim the car demo's source comments. Share the back link and fullscreen button with the existing Demo component. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- documentation-site/src/components/Demo.tsx | 38 +- .../src/components/_Demo.module.css | 17 - .../components/_FullscreenButton.module.css | 16 + .../src/components/_FullscreenButton.tsx | 27 ++ .../components/demo-page/DemoPage.module.css | 351 ++++++++++++++++++ .../src/components/demo-page/DemoPage.tsx | 185 +++++++++ .../components/demo-page/_CodeExplorer.tsx | 76 ++++ .../components/demo-page/_DemoControls.tsx | 32 ++ .../src/components/demo-page/types.ts | 47 +++ .../src/hooks/useDemoBackLink.ts | 29 ++ .../src/hooks/usePreventPageScrollKeys.ts | 41 ++ .../pages/demos/car/_air-control.component.ts | 23 +- .../pages/demos/car/_air-control.system.ts | 18 +- .../demos/car/_camera-follow.component.ts | 29 +- .../pages/demos/car/_camera-follow.system.ts | 10 +- .../pages/demos/car/_car-reset.component.ts | 6 +- .../src/pages/demos/car/_car-reset.system.ts | 7 +- .../car/_chassis-stabilizer.component.ts | 28 +- .../demos/car/_chassis-stabilizer.system.ts | 11 +- .../src/pages/demos/car/_create-car.ts | 287 +++----------- .../src/pages/demos/car/_create-game.ts | 60 +-- .../src/pages/demos/car/_create-terrain.ts | 77 +--- .../demos/car/_ground-contact.component.ts | 24 +- .../pages/demos/car/_ground-contact.system.ts | 9 +- .../pages/demos/car/_wheel-drive.component.ts | 49 +-- .../pages/demos/car/_wheel-drive.system.ts | 41 +- .../src/pages/demos/car/index.tsx | 226 ++++++----- 27 files changed, 1074 insertions(+), 690 deletions(-) create mode 100644 documentation-site/src/components/_FullscreenButton.module.css create mode 100644 documentation-site/src/components/_FullscreenButton.tsx create mode 100644 documentation-site/src/components/demo-page/DemoPage.module.css create mode 100644 documentation-site/src/components/demo-page/DemoPage.tsx create mode 100644 documentation-site/src/components/demo-page/_CodeExplorer.tsx create mode 100644 documentation-site/src/components/demo-page/_DemoControls.tsx create mode 100644 documentation-site/src/components/demo-page/types.ts create mode 100644 documentation-site/src/hooks/useDemoBackLink.ts create mode 100644 documentation-site/src/hooks/usePreventPageScrollKeys.ts diff --git a/documentation-site/src/components/Demo.tsx b/documentation-site/src/components/Demo.tsx index 304264526..6c5697cef 100644 --- a/documentation-site/src/components/Demo.tsx +++ b/documentation-site/src/components/Demo.tsx @@ -1,13 +1,12 @@ import React, { FC, ReactNode, useRef } from 'react'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; -import { useLocation } from '@docusaurus/router'; -import clsx from 'clsx'; import { CreateDemoGame, useGame } from '@site/src/hooks/useGame'; import { useFullscreen } from '@site/src/hooks/useFullscreen'; -import { demoCategories } from '@site/src/data/demo-categories'; +import { useDemoBackLink } from '@site/src/hooks/useDemoBackLink'; import styles from './_Demo.module.css'; import { CodeSelector } from './_CodeSelector'; +import { FullscreenButton } from './_FullscreenButton'; interface CodeFile { name: string; @@ -42,21 +41,7 @@ export const Demo: FC = ({ // game in place instead of needing a restart. useGame(createGame); - // The category page a demo was navigated from is threaded through as a - // `from` query param (see `DemoCard`), so the back button returns to the - // specific category the user was browsing rather than always the full - // demo catalogue. - const location = useLocation(); - const fromCategory = demoCategories.find( - (category) => - category.slug === new URLSearchParams(location.search).get('from'), - ); - const backLink = fromCategory - ? { - to: `/demos/category/${fromCategory.slug}`, - label: `← Back to ${fromCategory.title}`, - } - : { to: '/demos', label: '← All demos' }; + const backLink = useDemoBackLink(); return ( = ({ {interactions}
- +
diff --git a/documentation-site/src/components/_Demo.module.css b/documentation-site/src/components/_Demo.module.css index 6dc437047..af6422e0e 100644 --- a/documentation-site/src/components/_Demo.module.css +++ b/documentation-site/src/components/_Demo.module.css @@ -23,23 +23,6 @@ height: 100%; } -.fullscreenButton { - position: absolute; - top: 10px; - right: 10px; - z-index: 1; - background-color: rgb(50, 50, 50); - border: none; - border-radius: 5px; - color: white; - padding: 8px 10px; - cursor: pointer; -} - -.fullscreenButton:hover { - background-color: var(--card-highlight-color); -} - .demoContainer h1 { width: 100%; text-align: center; diff --git a/documentation-site/src/components/_FullscreenButton.module.css b/documentation-site/src/components/_FullscreenButton.module.css new file mode 100644 index 000000000..27756549c --- /dev/null +++ b/documentation-site/src/components/_FullscreenButton.module.css @@ -0,0 +1,16 @@ +.fullscreenButton { + position: absolute; + top: 10px; + right: 10px; + z-index: 1; + background-color: rgb(50, 50, 50); + border: none; + border-radius: 5px; + color: white; + padding: 8px 10px; + cursor: pointer; +} + +.fullscreenButton:hover { + background-color: var(--card-highlight-color); +} diff --git a/documentation-site/src/components/_FullscreenButton.tsx b/documentation-site/src/components/_FullscreenButton.tsx new file mode 100644 index 000000000..679b8bead --- /dev/null +++ b/documentation-site/src/components/_FullscreenButton.tsx @@ -0,0 +1,27 @@ +import React, { FC } from 'react'; +import clsx from 'clsx'; +import styles from './_FullscreenButton.module.css'; + +interface FullscreenButtonProps { + isFullscreen: boolean; + onToggle: () => void; +} + +export const FullscreenButton: FC = ({ + isFullscreen, + onToggle, +}) => { + return ( + + ); +}; diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css new file mode 100644 index 000000000..d0e872226 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -0,0 +1,351 @@ +.page { + width: 100%; + max-width: 1400px; + margin: 0 auto; + padding: 1.5rem 1.5rem 3rem; +} + +.back { + display: inline-block; + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +.header { + margin-bottom: 1.25rem; +} + +.title { + margin-bottom: 0.25rem; +} + +.summary { + max-width: 70ch; + margin: 0; + font-size: 1.1rem; + color: var(--ifm-color-emphasis-700); +} + +/* Game and sidebar */ + +.stage { + display: grid; + grid-template-columns: minmax(0, 1fr) 340px; + gap: 1.25rem; + align-items: start; +} + +.game { + position: relative; + aspect-ratio: 16 / 9; + width: 100%; + overflow: hidden; + border-radius: 8px; + background-color: black; + box-shadow: 0 0 0 1px var(--ifm-color-emphasis-300); +} + +.game:focus { + outline: none; +} + +.game:focus-visible { + box-shadow: 0 0 0 3px var(--ifm-color-primary); +} + +.game canvas { + position: absolute; +} + +.game:fullscreen { + width: 100%; + height: 100%; + border-radius: 0; + aspect-ratio: auto; +} + +.focusHint { + position: absolute; + left: 50%; + bottom: 16px; + z-index: 1; + transform: translateX(-50%); + padding: 6px 14px; + border-radius: 999px; + background-color: rgba(11, 19, 43, 0.75); + color: white; + font-size: 0.9rem; + pointer-events: none; + transition: opacity 0.2s; +} + +.focusHintHidden { + opacity: 0; +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.card { + padding: 1rem 1.1rem; + border-radius: 8px; + border: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.cardTitle { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 0.8rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-700); +} + +/* Controls */ + +.controls { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0; +} + +.control { + display: grid; + grid-template-columns: 8rem 1fr; + align-items: center; + gap: 0.75rem; +} + +.controlKeys { + display: flex; + align-items: center; + gap: 0.35rem; + margin: 0; + font-weight: normal; +} + +.controlOr { + font-size: 0.75rem; + color: var(--ifm-color-emphasis-600); +} + +.key { + min-width: 2rem; + padding: 0.2rem 0.45rem; + border: 1px solid var(--ifm-color-emphasis-400); + border-bottom-width: 3px; + border-radius: 6px; + background-color: var(--ifm-color-emphasis-100); + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-base); + font-size: 0.95rem; + font-weight: bold; + line-height: 1.4; + text-align: center; + box-shadow: none; +} + +.controlAction { + margin: 0; + font-weight: 600; +} + +.controlDetail { + display: block; + font-size: 0.8rem; + font-weight: normal; + color: var(--ifm-color-emphasis-700); +} + +/* How it works */ + +.highlights { + display: flex; + flex-direction: column; + gap: 0.6rem; + margin: 0; + padding-left: 1.1rem; + font-size: 0.92rem; +} + +.fileLink { + display: inline-flex; + align-items: center; + gap: 0.3rem; + margin-left: 0.4rem; + padding: 0 0.4rem; + border: none; + border-radius: 4px; + background-color: var(--ifm-color-emphasis-200); + color: var(--ifm-link-color); + font-family: var(--ifm-font-family-monospace); + font-size: 0.78rem; + cursor: pointer; +} + +.fileLink:hover { + background-color: var(--ifm-color-emphasis-300); +} + +.docLinks { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 0.75rem; + margin-top: 0.9rem; + padding-top: 0.75rem; + border-top: 1px solid var(--ifm-color-emphasis-200); + font-size: 0.9rem; +} + +.docLinks span { + color: var(--ifm-color-emphasis-700); +} + +/* Code explorer */ + +.source { + margin-top: 2.5rem; + scroll-margin-top: calc(var(--ifm-navbar-height) + 1rem); +} + +.sourceTitle { + margin-bottom: 0.75rem; +} + +.explorer { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + height: 640px; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--ifm-color-emphasis-300); +} + +.fileTree { + overflow-y: auto; + padding: 0.75rem 0; + border-right: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.fileGroup + .fileGroup { + margin-top: 0.75rem; +} + +.fileGroupTitle { + padding: 0 1rem 0.25rem; + font-size: 0.72rem; + font-weight: bold; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-600); +} + +.fileTree ul { + margin: 0; + padding: 0; + list-style: none; +} + +.fileButton { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.3rem 1rem; + border: none; + border-left: 3px solid transparent; + background: none; + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.fileName { + overflow: hidden; + text-overflow: ellipsis; +} + +.fileButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.fileButtonSelected, +.fileButtonSelected:hover { + border-left-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.fileIcon { + flex-shrink: 0; + width: 1rem; + color: var(--ifm-color-primary); + text-align: center; +} + +.filePane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.filePaneHeader { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.filePaneName { + font-family: var(--ifm-font-family-monospace); + font-weight: bold; +} + +.filePaneSummary { + font-size: 0.88rem; + color: var(--ifm-color-emphasis-700); +} + +.codeBlock { + flex: 1; + min-height: 0; + margin: 0; + overflow-y: auto; + border-radius: 0; + box-shadow: none; +} + +@media (max-width: 996px) { + .page { + padding: 1rem 1rem 2rem; + } + + .stage { + grid-template-columns: minmax(0, 1fr); + } + + .explorer { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + height: 80vh; + } + + .fileTree { + max-height: 30vh; + border-right: none; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + } +} diff --git a/documentation-site/src/components/demo-page/DemoPage.tsx b/documentation-site/src/components/demo-page/DemoPage.tsx new file mode 100644 index 000000000..b226ca0f3 --- /dev/null +++ b/documentation-site/src/components/demo-page/DemoPage.tsx @@ -0,0 +1,185 @@ +import React, { FC, ReactNode, useEffect, useRef, useState } from 'react'; +import Layout from '@theme/Layout'; +import Link from '@docusaurus/Link'; +import clsx from 'clsx'; +import { CreateDemoGame, useGame } from '@site/src/hooks/useGame'; +import { useFullscreen } from '@site/src/hooks/useFullscreen'; +import { useDemoBackLink } from '@site/src/hooks/useDemoBackLink'; +import { usePreventPageScrollKeys } from '@site/src/hooks/usePreventPageScrollKeys'; +import { FullscreenButton } from '@site/src/components/_FullscreenButton'; +import styles from './DemoPage.module.css'; +import { CodeExplorer } from './_CodeExplorer'; +import { DemoControls } from './_DemoControls'; +import { + DemoControl, + DemoDocLink, + DemoFile, + DemoFileGroup, + DemoHighlight, +} from './types'; + +export type { + DemoControl, + DemoDocLink, + DemoFile, + DemoFileGroup, + DemoHighlight, +}; + +interface DemoPageProps { + title: string; + /** One or two sentences: what the demo shows. Also the page's meta description. */ + summary: string; + createGame: CreateDemoGame; + controls: DemoControl[]; + /** The few ideas a reader should take away, each linked to its code. */ + highlights: DemoHighlight[]; + docLinks?: DemoDocLink[]; + /** The first group's first file is the one shown when the page opens. */ + fileGroups: DemoFileGroup[]; +} + +const findFile = (fileGroups: DemoFileGroup[], name: string): DemoFile => { + for (const group of fileGroups) { + const file = group.files.find((candidate) => candidate.name === name); + + if (file) { + return file; + } + } + + throw new Error(`The demo has no source file named "${name}".`); +}; + +/** + * A demo page: the running game with its controls and a short explanation, + * and a code explorer for the demo's source below. + */ +export const DemoPage: FC = ({ + title, + summary, + createGame, + controls, + highlights, + docLinks = [], + fileGroups, +}) => { + const gameRef = useRef(null); + const explorerRef = useRef(null); + const backLink = useDemoBackLink(); + const { isFullscreen, toggleFullscreen } = useFullscreen(gameRef); + const [hasFocus, setHasFocus] = useState(false); + const [selectedFileName, setSelectedFileName] = useState( + fileGroups[0].files[0].name, + ); + + useGame(createGame); + usePreventPageScrollKeys(gameRef); + + // Focus the game straight away, so the controls work without a click and + // the arrow keys drive the game instead of scrolling the page. + useEffect(() => { + gameRef.current?.focus({ preventScroll: true }); + }, []); + + for (const highlight of highlights) { + if (highlight.file) { + findFile(fileGroups, highlight.file); + } + } + + const openFile = (fileName: string): void => { + setSelectedFileName(fileName); + explorerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + +
+ + {backLink.label} + +
+

{title}

+

{summary}

+
+ +
+
setHasFocus(true)} + onBlur={() => setHasFocus(false)} + > + + +
+ + +
+ +
+

Source code

+ +
+
+
+ ); +}; diff --git a/documentation-site/src/components/demo-page/_CodeExplorer.tsx b/documentation-site/src/components/demo-page/_CodeExplorer.tsx new file mode 100644 index 000000000..88949cb1e --- /dev/null +++ b/documentation-site/src/components/demo-page/_CodeExplorer.tsx @@ -0,0 +1,76 @@ +import React, { FC } from 'react'; +import CodeBlock from '@theme/CodeBlock'; +import clsx from 'clsx'; +import { cleanCodeSnippet } from '@site/src/utils/clean-code-snippet'; +import { + fileTypeIconLookup, + getFileTypeIcon, +} from '@site/src/components/_CodeSelector.utils'; +import styles from './DemoPage.module.css'; +import { DemoFile, DemoFileGroup } from './types'; + +interface CodeExplorerProps { + fileGroups: DemoFileGroup[]; + selectedFile: DemoFile; + onSelectFile: (fileName: string) => void; +} + +export const CodeExplorer: FC = ({ + fileGroups, + selectedFile, + onSelectFile, +}) => { + return ( +
+ +
+
+ {selectedFile.name} + {selectedFile.summary} +
+ + {cleanCodeSnippet(selectedFile.content)} + +
+
+ ); +}; diff --git a/documentation-site/src/components/demo-page/_DemoControls.tsx b/documentation-site/src/components/demo-page/_DemoControls.tsx new file mode 100644 index 000000000..23f3f6e9b --- /dev/null +++ b/documentation-site/src/components/demo-page/_DemoControls.tsx @@ -0,0 +1,32 @@ +import React, { FC, Fragment } from 'react'; +import styles from './DemoPage.module.css'; +import { DemoControl } from './types'; + +interface DemoControlsProps { + controls: DemoControl[]; +} + +export const DemoControls: FC = ({ controls }) => { + return ( +
+ {controls.map((control) => ( +
+
+ {control.keys.map((key, index) => ( + + {index > 0 && or} + {key} + + ))} +
+
+ {control.action} + {control.detail && ( + {control.detail} + )} +
+
+ ))} +
+ ); +}; diff --git a/documentation-site/src/components/demo-page/types.ts b/documentation-site/src/components/demo-page/types.ts new file mode 100644 index 000000000..bdca1da6f --- /dev/null +++ b/documentation-site/src/components/demo-page/types.ts @@ -0,0 +1,47 @@ +import { ReactNode } from 'react'; + +/** + * One action the player can take, and the keys (or other inputs) that + * trigger it. Alternatives in `keys` are shown joined by "or". + */ +export interface DemoControl { + keys: string[]; + action: string; + /** A short note on what the action does in special situations. */ + detail?: string; +} + +/** + * A source file shown in the code explorer. + */ +export interface DemoFile { + name: string; + /** One sentence on what the file does, shown above its code. */ + summary: string; + content: string; +} + +/** + * Files listed together under one heading in the code explorer. + */ +export interface DemoFileGroup { + title: string; + files: DemoFile[]; +} + +/** + * One point in a demo's "How it works" list, optionally linked to the file + * that implements it. + */ +export interface DemoHighlight { + text: ReactNode; + file?: string; +} + +/** + * A link to the documentation for a feature the demo uses. + */ +export interface DemoDocLink { + label: string; + to: string; +} diff --git a/documentation-site/src/hooks/useDemoBackLink.ts b/documentation-site/src/hooks/useDemoBackLink.ts new file mode 100644 index 000000000..0617142f3 --- /dev/null +++ b/documentation-site/src/hooks/useDemoBackLink.ts @@ -0,0 +1,29 @@ +import { useLocation } from '@docusaurus/router'; +import { demoCategories } from '@site/src/data/demo-categories'; + +interface DemoBackLink { + to: string; + label: string; +} + +/** + * Where a demo page's back link goes. The category page a demo was opened + * from is passed as a `from` query param (see `DemoCard`), so the link + * returns to that category instead of the full demo catalogue. + */ +export const useDemoBackLink = (): DemoBackLink => { + const location = useLocation(); + const fromCategory = demoCategories.find( + (category) => + category.slug === new URLSearchParams(location.search).get('from'), + ); + + if (fromCategory) { + return { + to: `/demos/category/${fromCategory.slug}`, + label: `← Back to ${fromCategory.title}`, + }; + } + + return { to: '/demos', label: '← All demos' }; +}; diff --git a/documentation-site/src/hooks/usePreventPageScrollKeys.ts b/documentation-site/src/hooks/usePreventPageScrollKeys.ts new file mode 100644 index 000000000..388b93c73 --- /dev/null +++ b/documentation-site/src/hooks/usePreventPageScrollKeys.ts @@ -0,0 +1,41 @@ +import { RefObject, useEffect } from 'react'; + +const pageScrollKeys = new Set([ + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + ' ', + 'PageUp', + 'PageDown', +]); + +/** + * Stops the keys that scroll the page from scrolling it while `elementRef` + * has focus, so playing a demo with the arrow keys or space doesn't move + * the page. The engine's keyboard input listens on the whole page, so the + * game still receives the keys. + */ +export const usePreventPageScrollKeys = ( + elementRef: RefObject, +): void => { + useEffect(() => { + const element = elementRef.current; + + if (!element) { + return undefined; + } + + const handleKeyDown = (event: KeyboardEvent): void => { + if (pageScrollKeys.has(event.key)) { + event.preventDefault(); + } + }; + + element.addEventListener('keydown', handleKeyDown); + + return () => { + element.removeEventListener('keydown', handleKeyDown); + }; + }, [elementRef]); +}; diff --git a/documentation-site/src/pages/demos/car/_air-control.component.ts b/documentation-site/src/pages/demos/car/_air-control.component.ts index b323fa748..0d9d05f06 100644 --- a/documentation-site/src/pages/demos/car/_air-control.component.ts +++ b/documentation-site/src/pages/demos/car/_air-control.component.ts @@ -3,26 +3,9 @@ import { Axis1dAction } from '@forge-game-engine/forge/input'; import { GroundContactEcsComponent } from './_ground-contact.component'; /** - * Marks the chassis for mid-air pitch control: - * `createAirControlEcsSystem` drives the chassis's angular velocity - * towards a target proportional to `throttleInput` (the same - * targetVelocity/maxTorque approach `AngularVelocityMotorEcsComponent` uses - * for the wheels) whenever both `frontWheelGroundContact` and - * `rearWheelGroundContact` report their wheel touching no ground - gas - * pitches the nose up and back, brake pitches it down and forward, the same - * "tilt in the air" control the genre is named for. Driving towards a - * target angular *velocity*, rather than just applying a constant torque, - * gives the player direct, bounded control of the rotation rate: releasing - * the input targets zero rotation and actively cancels existing spin - * (within `maxTorque`) instead of only bleeding off via the chassis's own - * angular drag, so a bad rotation can be caught and corrected mid-air - * rather than fought against. - * - * `frontWheelGroundContact`/`rearWheelGroundContact` are each the same - * `GroundContactEcsComponent` object attached to that wheel's own entity - * (see `createWheel`) - held here by direct reference rather than joined - * via an ECS query, since this component lives on the chassis's entity, not - * either wheel's. + * Mid-air pitch control. While both wheels are off the ground, the chassis + * spins towards `throttleInput * maxAngularSpeed`: gas tilts the nose up, + * brake tilts it down, and letting go stops the spin. */ export interface AirControlEcsComponent { chassisEntity: number; diff --git a/documentation-site/src/pages/demos/car/_air-control.system.ts b/documentation-site/src/pages/demos/car/_air-control.system.ts index d361f4ec3..6b6f39461 100644 --- a/documentation-site/src/pages/demos/car/_air-control.system.ts +++ b/documentation-site/src/pages/demos/car/_air-control.system.ts @@ -9,21 +9,9 @@ import { AirControlEcsComponent, airControlId } from './_air-control.component'; import { isGrounded } from './_ground-contact.component'; /** - * While a matched entity's `AirControlEcsComponent.frontWheelGroundContact` - * and `rearWheelGroundContact` both report their wheel touching no ground, - * drives the chassis's angular velocity towards - * `throttleInput.value * maxAngularSpeed`, spending no more than `maxTorque` - * to do so - the same targetVelocity/maxTorque approach - * `createAngularVelocityMotorEcsSystem` uses for the wheels, applied - * directly here (rather than via `AngularVelocityMotorEcsComponent`) so it - * only ever acts while airborne; wired onto the wheels' motors instead, a - * target of `0` at neutral throttle would fight the chassis's ground-level - * suspension lean too. Does nothing while grounded, leaving the chassis - * entirely to the suspension and `ChassisStabilizerEcsComponent`. - * - * Must run after `createGroundContactEcsSystem` in the same tick (so it - * sees this tick's grounded state) and before whatever system integrates - * velocity into position (`createEulerIntegrationEcsSystem`). + * While both wheels are airborne, applies the torque that moves the + * chassis's spin towards the throttle's target speed, capped at `maxTorque`. + * Does nothing on the ground. * @param time - The time instance used to scale torque by the tick's delta * time. */ diff --git a/documentation-site/src/pages/demos/car/_camera-follow.component.ts b/documentation-site/src/pages/demos/car/_camera-follow.component.ts index a5710812f..4512806ab 100644 --- a/documentation-site/src/pages/demos/car/_camera-follow.component.ts +++ b/documentation-site/src/pages/demos/car/_camera-follow.component.ts @@ -2,39 +2,22 @@ import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; import { Vec2, Vector2 } from '@forge-game-engine/forge/math'; /** - * Smoothly moves this entity's `PositionEcsComponent` towards `targetEntity`'s - * current position (plus `offset`), via `smoothDampVector2`. - * `createCameraFollowEcsSystem` applies this every tick; demo-only, since - * the engine's `CameraEcsComponent` only supports input-driven pan/zoom, not - * following an arbitrary entity. Used here to keep the car on screen as it - * drives across a course much wider than the canvas. + * Smoothly moves this entity (the camera) towards `targetEntity`, so the + * car stays on screen on a course much wider than the canvas. */ export interface CameraFollowEcsComponent { targetEntity: number; - /** - * A fixed world-space offset added to `target.position`, e.g. to frame - * more of the track ahead of the car than behind it. - */ + /** Added to the target's position, to show more of the road ahead. */ offset: Vector2; - /** - * The approximate time, in seconds, `smoothDampVector2` takes to catch up - * to `target`. Smaller values follow more tightly. - */ + /** Roughly how long, in seconds, the camera takes to catch up. */ smoothTime: number; - /** - * The maximum speed, in world units/s, the camera may move at while - * catching up, so a sudden large jump in `target.position` (a reset, a - * hard landing) doesn't whip the camera across the screen. - */ + /** The camera's top speed, in world units per second. */ maxSpeed: number; - /** - * `smoothDampVector2`'s running velocity state, carried from tick to - * tick. Starts at zero. - */ + /** The camera's current velocity, carried between ticks. */ velocity: Vector2; } diff --git a/documentation-site/src/pages/demos/car/_camera-follow.system.ts b/documentation-site/src/pages/demos/car/_camera-follow.system.ts index 23427f0d5..a16bab012 100644 --- a/documentation-site/src/pages/demos/car/_camera-follow.system.ts +++ b/documentation-site/src/pages/demos/car/_camera-follow.system.ts @@ -11,14 +11,8 @@ import { } from './_camera-follow.component'; /** - * Smoothly moves each matched entity's `PositionEcsComponent` towards its - * `CameraFollowEcsComponent.targetEntity` position every tick, via - * `smoothDampVector2`. Reads the target's `local` position (it has no - * parent, so that's its current position whenever this runs) and writes the - * camera's `local` position, which `createTransformEcsSystem` turns into the - * `world` position the render system reads. - * @param time - The time instance used to advance `smoothDampVector2` by - * the tick's delta time. + * Moves each following camera towards its target with `smoothDampVector2`. + * @param time - The time instance used to advance the smoothing. */ export const createCameraFollowEcsSystem = ( time: Time, diff --git a/documentation-site/src/pages/demos/car/_car-reset.component.ts b/documentation-site/src/pages/demos/car/_car-reset.component.ts index 34132fdac..30e3ad7e2 100644 --- a/documentation-site/src/pages/demos/car/_car-reset.component.ts +++ b/documentation-site/src/pages/demos/car/_car-reset.component.ts @@ -12,10 +12,8 @@ export interface CarResetBody { } /** - * Teleports every one of `bodies` back to its recorded spawn position, - * angle, and zero velocity whenever `restartInput` fires, undoing however - * far the car has driven, flipped, or tumbled down a hill. - * `createCarResetEcsSystem` applies this every tick. + * Moves every body in `bodies` back to its spawn point when + * `restartInput` fires. */ export interface CarResetEcsComponent { restartInput: TriggerAction; diff --git a/documentation-site/src/pages/demos/car/_car-reset.system.ts b/documentation-site/src/pages/demos/car/_car-reset.system.ts index ea2bfad21..f61a4c445 100644 --- a/documentation-site/src/pages/demos/car/_car-reset.system.ts +++ b/documentation-site/src/pages/demos/car/_car-reset.system.ts @@ -5,11 +5,8 @@ import { Vec2 } from '@forge-game-engine/forge/math'; import { CarResetEcsComponent, carResetId } from './_car-reset.component'; /** - * Teleports every body in each matched entity's `CarResetEcsComponent.bodies` - * back to its recorded spawn transform, with zero velocity, on the tick - * `restartInput` fires. Writes each body's `local` pose, so it must run - * before `createTransformEcsSystem`, so a restart applied this tick is - * reflected in this same tick's physics step and render. + * Moves the car's bodies back to their spawn transforms, at rest, on the + * tick the restart input fires. */ export const createCarResetEcsSystem = (): EcsSystem< [CarResetEcsComponent] diff --git a/documentation-site/src/pages/demos/car/_chassis-stabilizer.component.ts b/documentation-site/src/pages/demos/car/_chassis-stabilizer.component.ts index 7d992da0b..a9c6da114 100644 --- a/documentation-site/src/pages/demos/car/_chassis-stabilizer.component.ts +++ b/documentation-site/src/pages/demos/car/_chassis-stabilizer.component.ts @@ -2,30 +2,10 @@ import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; import { GroundContactEcsComponent } from './_ground-contact.component'; /** - * Applies a restoring torque pulling the chassis's rotation back towards - * zero (`-angle * levelingStiffness`) and resisting its `angularVelocity` - * (`-angularVelocity * levelingDamping`), via `applyTorque`. - * `createChassisStabilizerEcsSystem` applies this only while - * `frontWheelGroundContact` or `rearWheelGroundContact` reports its wheel - * touching the ground - never while airborne, so it doesn't fight - * `AirControlEcsComponent`'s deliberate tilt input. - * - * `frontWheelGroundContact`/`rearWheelGroundContact` are each the same - * `GroundContactEcsComponent` object attached to that wheel's own entity - * (see `createWheel`) - held here by direct reference rather than joined - * via an ECS query, since this component lives on its own control entity, - * not either wheel's (or the chassis's own). - * - * A real car's suspension keeps the chassis roughly level through its - * control-arm geometry, not just spring force; the chassis here hangs from - * only two independently-solved `LinearSpring`/`LinearDamper` pairs (see - * `createCar`), which has no equivalent geometric constraint and lets small - * disturbances (an uneven landing, a bump straddling two ground segments) - * accumulate into a persistent tilt over time rather than settling back to - * level. This is deliberately much weaker than the pitch torque a hard - * acceleration or brake produces, so the car still visibly leans under - * throttle - it only pulls the chassis back level once nothing else is - * actively tipping it. + * Gently pulls the chassis back to level while a wheel is on the ground. + * Two independent springs alone let small bumps add up into a lasting + * tilt. It's much weaker than the lean from accelerating or braking, so + * the car still leans. */ export interface ChassisStabilizerEcsComponent { chassisEntity: number; diff --git a/documentation-site/src/pages/demos/car/_chassis-stabilizer.system.ts b/documentation-site/src/pages/demos/car/_chassis-stabilizer.system.ts index c83d06a7a..0c4715e7f 100644 --- a/documentation-site/src/pages/demos/car/_chassis-stabilizer.system.ts +++ b/documentation-site/src/pages/demos/car/_chassis-stabilizer.system.ts @@ -8,15 +8,8 @@ import { import { isGrounded } from './_ground-contact.component'; /** - * While a matched entity's `ChassisStabilizerEcsComponent.frontWheelGroundContact` - * or `rearWheelGroundContact` reports its wheel touching the ground, applies - * the restoring torque to the chassis every tick, via `applyTorque`. Does - * nothing while both wheels are airborne, leaving the chassis entirely to - * `AirControlEcsComponent`'s deliberate tilt input. - * - * Must run after `createGroundContactEcsSystem` in the same tick (so it - * sees this tick's grounded state) and before whatever system integrates - * velocity into position (`createEulerIntegrationEcsSystem`). + * Applies a spring-like torque towards level while either wheel is on the + * ground. Does nothing in the air, where air control takes over. * @param time - The time instance used to scale the torque by the tick's * delta time. */ diff --git a/documentation-site/src/pages/demos/car/_create-car.ts b/documentation-site/src/pages/demos/car/_create-car.ts index 491201025..892ba3627 100644 --- a/documentation-site/src/pages/demos/car/_create-car.ts +++ b/documentation-site/src/pages/demos/car/_create-car.ts @@ -36,208 +36,54 @@ import { addWheelDriveComponent } from './_wheel-drive.component'; const gravity = { x: 0, y: -600 }; +// The chassis is the heaviest body in the car, so it has enough rotational +// inertia to absorb the wheels' drive torque instead of flipping forward. const chassisWidth = 450; const chassisHeight = 150; - -// With the wheel/upright mass this rig needs for stable joints (see -// `uprightDensity` below), a chassis this light ends up the *lightest* -// individual body in the car - `chassisWidth * chassisHeight * -// chassisDensity` is only ~23,600 against ~15,700 per wheel and ~16,100 per -// upright, i.e. under a third of the car's total mass, when in a real car -// the body (chassis + engine) dwarfs the wheels. A chassis that light has -// little rotational inertia to resist the torque the drive wheels' -// ground-friction reaction transmits back through the suspension anchors -// (see `createWheelMount`'s comment on that coupling) - accelerating hard, -// especially uphill, could tip it into an uncontrolled forward pitch -// (front digging in, the opposite of the intended throttle-lean) that then -// only compounded once it went airborne. Raising `chassisDensity` so the -// chassis is comfortably the heaviest single body (now ~56% of total mass) -// gives it enough of its own rotational inertia to absorb that reaction -// smoothly instead of snapping into it - confirmed empirically (accelerate -// from a stop, sustain it uphill, brake hard) that this removes the -// unwanted forward pitch while still leaving the deliberately weaker -// stoppie-under-hard-braking-at-speed rotation clearly intact, and barely -// changes resting suspension sag. const chassisDensity = 0.5; const wheelRadius = 100; const wheelDensity = 0.2; - -// Slightly below the wheel-ground friction a bare tire/asphalt pairing -// would use (`1`, still the historical default here) - the peak transient -// slip right as full throttle is first requested (before the wheel's spin -// catches up to the car's speed) briefly demands close to this much -// friction force at the contact patch, and with `chassisDensity` this low -// used to translate a chunk of that force into unwanted chassis rotation -// via the tilted suspension anchors. A little less peak grip there, -// combined with the heavier chassis above, was enough (empirically) to -// keep that transient from reaching the chassis at all, without making the -// car noticeably harder to accelerate or climb with once rolling. const wheelFriction = 1; -// The wheel doesn't mount to the chassis directly. A single linear spring -// only constrains a wheel's *distance* from its anchor, leaving it free to -// swing around that anchor like a pendulum - and a plain revolute joint or -// prismatic joint wired straight to the wheel isn't right either: a -// revolute joint would pin the wheel rigidly in place (no suspension travel -// at all), and a prismatic joint locks the two bodies' *relative rotation* -// (it captures a referenceAngle and holds it), which would lock the -// wheel's spin to the chassis and make driving impossible. -// -// Instead each wheel gets a small intermediate "upright" body (a real car's -// wheel hub/knuckle) invisible and non-colliding (no -// `ColliderEcsComponent`), connected two different ways: -// - A prismatic joint pins the upright to the chassis, free to slide only -// along `suspensionAxis` (in the chassis's local space) - this is the -// suspension's travel, and it's fine that the joint locks the upright's -// rotation to the chassis's, since the upright itself never needs to -// spin. -// - A revolute joint pins the wheel's position to the upright (coincident -// centers) while leaving rotation completely free, so the wheel can -// still spin for driving. -// A linear spring/damper pair along the same axis, between the chassis and -// the upright, then provides the suspension's actual force. Unlike a -// spring alone, both joints are hard, warm-started constraints (solved -// every tick, the same as collision contacts) with no lateral give of -// their own - so the wheel only ever moves along `suspensionAxis` relative -// to the chassis, no swinging. -// A tiny, near-massless upright (relative to the wheel and chassis it sits -// between) makes both joint solvers badly ill-conditioned: resolving a -// wheel-ground collision impulse through a light body sandwiched between -// two much heavier ones (the wheel at one joint, the chassis at the other) -// blows up within a couple of iterations. Keeping the upright's mass close -// to the wheel's (`wheelRadius * wheelRadius * Math.PI * wheelDensity`, -// currently ~15,700) keeps both joints' effective mass ratios reasonable - -// `uprightDensity` has to scale with `wheelRadius`/`wheelDensity` to hold -// that, since the upright's own radius stays small and unnoticeable. +// Each wheel hangs from a small, invisible "upright" (a wheel hub). Its mass +// is kept close to the wheel's: a near-massless body between two heavy ones +// makes the joint solvers unstable. const uprightRadius = 8; const uprightDensity = 80; -// Anchors are in the chassis's local space: roughly at the bottom corners, -// inset a bit so the wheels sit under the body rather than past its edges. +// Suspension anchors, in the chassis's local space. const frontAnchor = { x: chassisWidth / 2 - 115, y: -chassisHeight / 2 }; const rearAnchor = { x: -(chassisWidth / 2 - 115), y: -chassisHeight / 2 }; -// Each wheel is constrained (via its mount's prismatic joint - see -// `createWheelMount`) to slide only along this axis relative to its -// anchor, so tilting it away from straight-up/down carries the wheel -// itself further out from the chassis than its anchor: the line connecting -// each anchor to its wheel splays outward into a trapezoid rather than a -// rectangle, like a monster truck's lifted suspension. This also means an -// impact that's perpendicular to the chassis (hitting a wall or ledge -// face-on) now has a component along the suspension axis for the spring to -// absorb, instead of landing entirely on the joint's hard, unsprung -// constraint. +// The suspension axes splay outwards like a monster truck's, so a head-on +// impact is partly absorbed by the spring instead of the rigid joint. const frontSuspensionAxis = Vec2.rotate(Vec2.up, degreesToRadians(35)); const rearSuspensionAxis = Vec2.rotate(Vec2.up, degreesToRadians(-35)); -// How far below each anchor a wheel starts, on top of the anchor's own -// offset. Since `addLinearSpringComponent` defaults `restLength` to the -// anchors' distance at attach time, this becomes the suspension's rest -// length, and starting the wheel slightly higher than that (see -// `wheelSpawnDrop` in `createCar`) lets the car visibly settle onto its -// suspension as soon as the demo starts, the same way the Linear Spring and -// Damper demo's wheels do. Kept just large enough that the wheels tuck in -// close under the chassis (rather than dangling below it with an -// unrealistic gap) while still leaving several times the equilibrium sag -// of margin, so the wheel never has to cross (or come numerically close -// to) the chassis anchor itself, where `createLinearSpringEcsSystem`'s -// direction normalization becomes unstable as the anchor-to-wheel distance -// approaches zero. +// How far below its anchor a wheel starts. This becomes the springs' rest +// length. const wheelDropHeight = 25; -// Chosen so the car's weight compresses each suspension by a small fraction -// of `wheelDropHeight` at rest (leaving visible, but bounded, suspension -// travel) rather than anywhere close to all the way to the chassis anchor. -// Kept well below the stiffness a spring-only mount would want: the -// prismatic/revolute joint pair already hard-constrains everything but -// the vertical travel every tick, so a stiffer spring on top of -// that mostly ends up fighting the joints instead of damping out - a wheel -// slamming into the ground can end up launching the whole car into the air -// instead of just compressing the suspension. -// -// Being stable *at rest* isn't enough to verify this against: with -// `frontSuspensionAxis`/`rearSuspensionAxis` tilted rather than vertical, -// each wheel's ground-friction reaction force has a component along that -// wheel's own axis, so accelerating or braking couples horizontal force -// into each suspension corner's loading, not just vertical weight. The two -// corners have no shared geometry (no anti-roll bar, no rigid axle) tying -// their loading together, only independent springs - so under hard -// driving torque this coupling is enough for one corner to unload faster -// than the other can compensate, letting that wheel lift off entirely and -// the chassis pitch hard, without player input, while still grounded. Raising -// stiffness to shrink the *resting* sag (as this was previously tuned to -// do) only pushes that dynamic imbalance further out of the range this -// rig's independent-spring, no-shared-axle suspension can absorb - the -// resting sag it leaves is a real trade-off, not a leftover to tune away. -// Verify empirically against *driving* stability (accelerate from a stop, -// sustain it, brake hard), not just resting stability, before changing -// this. +// Soft enough that the joints, not the spring, hold the wheel in line. A +// stiffer spring fights the joints and can launch the car on hard landings. const suspensionStiffness = 1_000_000; const suspensionDamping = 165_000; -// An engine is meant to feel overpowered enough to punch -// through bumps and keep climbing rather than stalling on them. -// `motorMaxTorque` is scaled up to match the car's current total mass: -// raising `uprightDensity` to fix the wheel-mount mass ratio (see its -// comment above) added a lot of mass that wasn't here when this was last -// tuned - each upright went from ~1,200 to ~16,000, roughly a 50% increase -// in the whole car's mass - and without a matching torque increase the -// drivetrain no longer had enough force to meaningfully accelerate it. -// Confirmed empirically that the car was still torque-limited (not just -// grip-limited) at that scaled-up value - doubling it again visibly sped up -// both the wheels' own spin-up and the chassis's acceleration, rather than -// just wasting the extra torque as more wheel spin, so it was raised -// further. Tested up to 3x this value too: that made launches snappier -// still, but also made hard braking's chassis dive noticeably harder -// (pushing back towards the exaggerated flip a previous fix addressed) for -// only a marginal further gain in sustained speed - this is the point -// past which more torque stops being "more power" and starts being "less -// control." -// -// Raising `chassisDensity` (see its comment above, fixing an unwanted -// forward-pitch-under-throttle bug) added roughly another 66% to the car's -// total mass on top of that - scaled up again by the same ratio to keep -// the previously-tuned acceleration feel rather than let the extra weight -// quietly turn "overpowered" back into "sluggish." -// -// `maxWheelSpeed` is deliberately far higher than the car could ever -// actually roll at - `WheelDriveEcsComponent.maxSlipAngularSpeed` is what -// actually keeps a wheel grounded in reality (see its comment), by -// clamping the target this produces to a bounded slip band around the -// wheel's *current* rolling speed. That clamp is what matters; this is -// just "go as fast as grip allows", not a speed the wheel is meant to -// reach unassisted. +// More torque than the tires can use, so grip (friction) is what limits +// acceleration. `maxWheelSpeed` means "as fast as grip allows"; +// `maxSlipAngularSpeed` stops an airborne wheel from spinning up uselessly. const motorMaxTorque = 25_500_000_000; const maxWheelSpeed = 350; - -// How far past a wheel's current rolling speed its target is allowed to -// stray (see `WheelDriveEcsComponent.maxSlipAngularSpeed`) - generous -// enough for a deliberate wheel spin launch from a stop, bounded enough that -// a wheel briefly unloaded by the chassis's throttle-lean can't run away to -// `maxWheelSpeed` and waste torque spinning uselessly fast instead of -// quickly regaining grip once it lands. const maxSlipAngularSpeed = 6; -// See `ChassisStabilizerEcsComponent` for why this exists. Strong enough to -// pull the chassis back to (roughly) level within a second or two of -// nothing else disturbing it, but still far weaker than the pitch torque a -// hard acceleration or brake produces, so the car still visibly leans under -// throttle. +// Pulls the chassis back to level on the ground, but far weaker than the +// lean from accelerating or braking. const chassisLevelingStiffness = 300_000_000; const chassisLevelingDamping = 40_000_000; -// The chassis's target angular speed at full throttle while airborne, and -// the torque budget `AirControlEcsComponent` spends chasing it - the -// classic "tilt in mid-air" control, gas pitching the nose -// up and back, brake pitching it down and forward. Targeting a speed -// (rather than just applying a constant torque) gives the player direct, -// bounded control: releasing the input targets zero rotation and actively -// cancels existing spin instead of coasting on whatever momentum was built -// up. `airControlMaxTorque` is high enough (matching the chassis's -// `chassisDensity`-driven moment of inertia) to reach `airControlMaxAngularSpeed` -// within a few tenths of a second, so input reads as immediate rather than -// a slow wind-up. +// Mid-air pitch control: the chassis's target spin speed at full throttle, +// and the torque spent reaching it. const airControlMaxAngularSpeed = 3.5; const airControlMaxTorque = 8_000_000_000; @@ -247,13 +93,8 @@ interface CarSprites { } /** - * A driven wheel's entity id alongside the `GroundContactEcsComponent` - * tracking its own grounded state - `createWheel` attaches the latter - * directly to the wheel's entity (so `WheelDriveEcsSystem` can query it - * jointly), and returns it too so `createCar` can hand the same object by - * reference to `AirControlEcsComponent`/`ChassisStabilizerEcsComponent`, - * which live on the chassis's control entity and need to read both wheels' - * grounded state. + * A wheel's entity and its ground contact, which the chassis's stabilizer + * and air control read to know whether the car is airborne. */ interface Wheel { entity: number; @@ -299,6 +140,9 @@ async function loadCarSprites( }; } +/** + * Creates a motor-driven wheel that tracks what it's touching. + */ function createWheel( world: EcsWorld, sprite: SpriteEcsComponent, @@ -348,21 +192,16 @@ function createWheel( } /** - * Mounts `wheelEntity` to `chassisEntity` at `chassisAnchor` through an - * intermediate "upright" body (see the module doc comment above for why: - * a prismatic joint constrains the upright to slide only along - * `suspensionAxis` relative to the chassis, a revolute joint pins the wheel - * to that upright with its rotation left free, and a linear spring/ - * damper pair along the same axis supplies the suspension force). - * @param world - The ECS world to add the mount's entities to. - * @param chassisEntity - The chassis the wheel mounts to. - * @param wheelEntity - The wheel being mounted. - * @param chassisAnchor - Where on the chassis (in its local space) the - * upright's prismatic joint and the spring/damper attach. - * @param uprightPosition - The upright's initial world-space position, - * directly below `chassisAnchor` by the suspension's rest length. - * @returns The upright's entity id, so it can be included alongside the - * chassis and wheel in `CarResetEcsComponent.bodies`. + * Mounts a wheel to the chassis through an invisible upright: + * - a prismatic joint lets the upright slide only along `suspensionAxis`, + * - a revolute joint pins the wheel to the upright but leaves it free to + * spin, + * - a spring and damper along the same axis are the suspension. + * + * Joining the wheel to the chassis directly doesn't work: a revolute joint + * alone has no suspension travel, and a prismatic joint alone would stop + * the wheel from spinning. + * @returns The upright's entity, so the car reset can move it too. */ function createWheelMount( world: EcsWorld, @@ -423,24 +262,16 @@ function createWheelMount( } /** - * Builds a car: a chassis with two wheels mounted - * beneath it (see `createWheelMount`), each driven by an - * `AngularVelocityMotorEcsComponent` whose target speed tracks - * `throttleInput`. The mount constrains a wheel to only slide vertically - * relative to the chassis - it's the linear spring/damper providing - * that mount's force (not a rigid frame) that lets the chassis pitch under - * acceleration and braking, the same "leaning" feel the genre is named for - * - a light `ChassisStabilizerEcsComponent` only pulls it back level once - * nothing else is actively tipping it. + * Builds the car: a chassis with two motor-driven wheels on spring + * suspension, plus the components that keep it level, steer it in mid-air + * and reset it. * @param world - The ECS world to add the car's entities to. * @param renderContext - The render context used to load sprites. - * @param renderLayer - The render layer the car should be drawn on. - * @param groundPosition - A point on the ground the car should spawn above. - * @param throttleInput - Drives both wheels' motors: positive accelerates - * forward, negative reverses/brakes. - * @param restartInput - Teleports the car back to its spawn transform when - * triggered (see `createCarResetEcsSystem`). - * @returns The chassis's entity id, for the camera to follow. + * @param renderLayer - The render layer the car is drawn on. + * @param groundPosition - A point on the ground to spawn the car above. + * @param throttleInput - Positive drives forward, negative brakes/reverses. + * @param restartInput - Moves the car back to its spawn point. + * @returns The chassis's entity, for the camera to follow. */ export async function createCar( world: EcsWorld, @@ -452,12 +283,9 @@ export async function createCar( ): Promise { const sprites = await loadCarSprites(renderContext, renderLayer); - // Spawn the chassis slightly above its resting ride height so the car - // visibly settles onto its suspension as the demo starts, without - // starting so high that the initial impact injects a lot of energy into - // the springs. + // Spawn slightly above ride height so the car visibly settles onto its + // suspension. const wheelSpawnDrop = wheelDropHeight - 8; - // clone: groundPosition is a caller-owned parameter, must not mutate it. const chassisPosition = Vec2.add(Vec2.clone(groundPosition), { x: 0, y: wheelRadius + wheelDropHeight + chassisHeight / 2 + 100, @@ -484,23 +312,13 @@ export async function createCar( restitution: 0.1, }); addRigidBodyComponent(world, chassisEntity, { - // Each wheel mount's prismatic joint hard-constrains it against - // swinging (see the module doc comment above), so this isn't - // compensating for that the way it originally was - it's just a - // small amount of drag so any pitch imparted while settling onto the - // suspension (or while landing after a jump) damps out over time - // instead of persisting indefinitely. + // A little drag so pitch from landings dies out over time. angularDrag: 0.5, }); addGravityComponent(world, chassisEntity, { amount: gravity }); - // Offset along the same tilted axis each wheel's mount constrains it to - // (see `frontSuspensionAxis`/`rearSuspensionAxis`), not straight down, so - // the wheel spawns already on its prismatic joint's constraint line - // instead of being yanked sideways onto it over the first few frames. - // clone: chassisPosition is reused below for the rear wheel too, and - // frontSuspensionAxis/rearSuspensionAxis are shared with the joint's - // `axis` below - none of these may be mutated in place. + // Each wheel spawns on its tilted suspension axis, so the joint doesn't + // yank it sideways in the first frames. const frontWheelPosition = Vec2.add( Vec2.add( Vec2.add(Vec2.clone(chassisPosition), frontAnchor), @@ -516,6 +334,7 @@ export async function createCar( { x: 0, y: -wheelRadius }, ); + // Rear-wheel biased: the front wheel gets half the torque. const frontWheel = createWheel( world, sprites.wheel, @@ -550,14 +369,8 @@ export async function createCar( rearSuspensionAxis, ); - // Both live on this one entity so they're easy to find alongside each - // other, though neither is queried jointly with the other - each just - // holds direct references to `frontWheel.groundContact`/ - // `rearWheel.groundContact` (see `AirControlEcsComponent`/ - // `ChassisStabilizerEcsComponent`'s doc comments for why: those - // components live on their own control entity, not either wheel's, so an - // ECS query can't join them the way `WheelDriveEcsSystem` joins a wheel - // with its own `GroundContactEcsComponent`). + // Both need to know whether either wheel is on the ground, so they hold + // the wheels' ground-contact components directly. const chassisControlEntity = world.createEntity(); addChassisStabilizerComponent(world, chassisControlEntity, { diff --git a/documentation-site/src/pages/demos/car/_create-game.ts b/documentation-site/src/pages/demos/car/_create-game.ts index 2fbcc0a88..fbc597515 100644 --- a/documentation-site/src/pages/demos/car/_create-game.ts +++ b/documentation-site/src/pages/demos/car/_create-game.ts @@ -43,9 +43,7 @@ const renderLayers = { export const createCarGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); - // `isStatic: true` since this camera's position is driven by - // `createCameraFollowEcsSystem` rather than `createCameraEcsSystem`'s - // input-driven pan/zoom. + // Static: the camera-follow system moves this camera, not pan/zoom input. const cameraEntity = createCamera(world, { isStatic: true, zoom: 0.5, @@ -80,53 +78,23 @@ export const createCarGame = async (): Promise => { const collisionManifolds: CollisionManifold[] = []; const contactConstraints: ContactConstraint[] = []; - // Each wheel mount chains two joints through its upright (chassis <-> - // upright via the prismatic joint, upright <-> wheel via the revolute - // joint), and both mounts share the chassis body - the single-iteration - // default is enough for an isolated joint, but this shared-body chain - // needs several more per tick to stay stable at this rig's mass/torque - // scale (confirmed empirically: with the default of 1, the chassis - // tumbles and the car flies apart within the first second). + // Each wheel hangs from two chained joints that share the chassis, which + // needs more solver iterations than the default to stay stable. const jointIterations = { iterations: 8 }; - // `createCollisionResolutionEcsSystem`'s default `maxBiasSpeed` (3 units/ - // second) is tuned for Box2D's own meters-scale default (its - // `contactPushMaxSpeed` is `3.0 * b2_lengthUnitsPerMeter`) - this course's - // world units are pixel-scale instead (`gravity` above is -600, roughly - // 60x real-world `g`, and `wheelRadius` alone is 100 units), so a wheel - // that lands hard after catching air off a hill can end up tens of units - // deep in the terrain, and 3 units/second of correction then takes many - // seconds to dig it back out - long enough to read as the wheel being - // stuck clipped into the ground rather than momentarily compressed into - // it. Scaling the cap up by roughly the same ~60-100x - // this course's units are bigger than Box2D's assumed meters (confirmed - // empirically: 300 clears a hard landing within a fraction of a second, - // matching how quickly the suspension itself settles, without changing - // resting behavior on flat ground - the cap only matters once penetration - // is already large) restores the "quickly digs itself back out" feel - // `maxBiasSpeed` is meant to provide at this course's actual scale. + // The default correction speed is tuned for metre-scale worlds. This + // world is pixel-scale, so a wheel that lands hard would take seconds to + // dig itself out of the ground. const collisionResolutionOptions = { maxBiasSpeed: 300 }; - // `createCarResetEcsSystem` may teleport every body back to its spawn - // transform, so it runs first, followed by `createTransformEcsSystem`, - // which turns every entity's `local` pose into the `world` pose that the - // physics and render systems read. `createGroundContactEcsSystem` recomputes - // each wheel's grounded state from the wheel's contacts (filled by - // narrow-phase, just before it), and - // `createWheelDriveEcsSystem` (sets each wheel's motor target from - // `throttleInput`, but only requests full speed while that wheel's own - // ground contact says it's grounded) / `createChassisStabilizerEcsSystem` - // / `createAirControlEcsSystem` must run after it in this same list, so - // they see this tick's grounded state rather than last tick's. The - // suspension's spring/damper forces run before collision resolution (like - // gravity), and the prismatic/revolute joints that hard-constrain each - // wheel mount run after it, so they get the "last word" on velocity each - // tick. `createContinuousCollisionEcsSystem` runs right after - // `createEulerIntegrationEcsSystem`, so it can stop a wheel that this - // tick's integration would sink deep into the terrain at the surface - // instead. `createCameraFollowEcsSystem` writes the camera's `local` position - // after `createTransformEcsSystem` has run, so its smoothed camera - // position is rendered from the next tick on. + // System order matters: + // 1. Reset, then transforms, so a restart shows up this tick. + // 2. Ground contact after narrow phase (which fills contacts), and before + // the systems that read it (wheel drive, stabilizer, air control). + // 3. Springs and dampers before collision resolution; joints after it, so + // they get the last word on velocity. + // 4. Continuous collision right after integration, so a fast wheel stops + // at the ground instead of sinking into it. world.addSystem(createCarResetEcsSystem()); world.addSystem(createTransformEcsSystem()); world.addSystem(createGravityEcsSystem(time)); diff --git a/documentation-site/src/pages/demos/car/_create-terrain.ts b/documentation-site/src/pages/demos/car/_create-terrain.ts index 50a414d20..9931e9f25 100644 --- a/documentation-site/src/pages/demos/car/_create-terrain.ts +++ b/documentation-site/src/pages/demos/car/_create-terrain.ts @@ -17,69 +17,30 @@ import { } from '@forge-game-engine/forge/rendering'; import { getAssetUrl } from '@site/src/utils/get-asset-url'; -// How far apart (in world x) consecutive sampled height points are. Small -// enough, relative to the wheel radius (see `wheelRadius` in -// `_create-car.ts`), that a wheel spans more than one of the collider's -// surface edges at once - deliberately exercising the multi-edge case the -// old column-based terrain (each column its own `PolygonCollider` entity) -// couldn't, since `TerrainCollider` resolves a body against every stretch -// of its surface chain the body actually reaches (see -// `detectCircleTerrainCollision`/`detectPolygonTerrainCollision`), rather -// than against one column at a time. +// Horizontal distance between sampled height points. Smaller than a wheel, +// so a wheel often touches several surface edges at once. const pointSpacing = 60; -// How far the solid slab extends below the lowest sampled point. A single -// flat bottom edge for the whole course, so this only needs to cover the -// tallest hill's height *above* the flat launch pad, not the pad's own -// height (see `TerrainCollider`'s docs: `depth` is measured from the -// lowest point across the whole collider). +// How far the solid ground extends below the lowest point. const terrainDepth = 500; -// `block_square.png` is a 64x64 rounded, bolted panel; tiled at its native -// size it reads as a plated floor rather than smearing across the whole -// course. const groundTextureUrl = getAssetUrl('img/physics/block_square.png'); -/** - * How far the flat launch pad the car spawns on extends before the terrain - * starts climbing, in world units. - */ +// Length of the flat launch pad the car spawns on. const flatStartLength = 400; -/** - * Total horizontal distance the generated course covers, including the - * flat launch pad. - */ const courseLength = 20000; -/** - * How far before the flat launch pad the sampled terrain starts, so the car - * always has solid ground under it even while braking/reversing near the - * spawn point. - */ +// Ground behind the spawn point, so the car can reverse off the pad. const runoffLength = 500; -/** - * How far past `flatStartLength` the hills take to ramp up to full - * amplitude. Without this, `rollingHills`/`climb`/`noise` would switch on - * abruptly at `flatStartLength`, and since their slope there is nonzero, - * that would make the very first sampled point a sharp kink (a car arriving - * at speed would feel a sudden bump rather than easing onto a slope). - * Smoothstep ramps `rollingHills`/`climb`/`noise` in with a slope of zero at - * `flatStartLength`, so the ground eases out of the flat pad instead of - * kinking. - */ +// Distance over which the hills fade in, so the pad eases into the first +// slope instead of ending in a kink. const hillRampLength = 400; /** - * Computes the ground height at `x`: flat for `flatStartLength`, then a mix - * of two sine waves at different frequencies (rolling hills), a slow upward - * trend (so the course is a net "climb" rather than just undulating), and - * small per-point noise so it doesn't read as perfectly periodic - all - * ramped in smoothly over `hillRampLength` so the transition out of the - * flat pad has no sudden change in slope. - * @param x - The world-space x coordinate to sample. - * @param random - The seeded random source used for per-point noise. + * The ground height at `x`: flat for the launch pad, then two sine waves + * (rolling hills), a slow climb and a little noise. */ function heightAt(x: number, random: Random): number { if (x <= flatStartLength) { @@ -101,10 +62,7 @@ function heightAt(x: number, random: Random): number { } /** - * Samples `heightAt` left to right across the whole course, as the - * terrain's surface points in world coordinates (the terrain entity sits at - * the origin, unrotated, and its solid slab extends below the points). - * @param random - The seeded random source used for per-point noise. + * Samples `heightAt` across the course, left to right. */ function buildSurfacePoints(random: Random): Vector2[] { const points: Vector2[] = []; @@ -138,17 +96,10 @@ function toCurvePoints(points: readonly Vector2[]): TerrainCurvePoint[] { } /** - * Builds the course's terrain: a single, continuous `TerrainCollider` - * following a procedurally generated height profile, starting with a flat - * launch pad and climbing into gently rolling hills, plus a matching mesh - * (see `createTerrainMesh`) built from the exact same points, so what's - * drawn always matches what's touched. The mesh is attached to the terrain - * entity via `addTerrainMeshComponent`, so `createTerrainRenderEcsSystem` - * draws it automatically - the caller doesn't need to wire it up itself. - * @param world - The ECS world to add the terrain entity to. - * @param renderContext - The render context used to load the ground texture and build the mesh. - * @param random - The seeded random source used to vary the terrain. - * @returns A point on the flat launch pad, suitable for spawning the car above. + * Builds the course: one `TerrainCollider` for physics and a terrain mesh + * for rendering, both from the same points so what you see is what the car + * touches. + * @returns A point on the launch pad to spawn the car above. */ export async function createTerrain( world: EcsWorld, diff --git a/documentation-site/src/pages/demos/car/_ground-contact.component.ts b/documentation-site/src/pages/demos/car/_ground-contact.component.ts index 4509b95ca..bf1daa2f1 100644 --- a/documentation-site/src/pages/demos/car/_ground-contact.component.ts +++ b/documentation-site/src/pages/demos/car/_ground-contact.component.ts @@ -1,17 +1,8 @@ import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; /** - * Tracks how many static (ground) bodies this entity is currently touching, - * recomputed every tick by `createGroundContactEcsSystem` from the entity's - * `ContactsEcsComponent`. A count rather than a boolean so it generalizes to - * any number of static bodies a wheel might simultaneously touch. - * - * Attached directly to the wheel's own entity, so anything else on that - * same entity (e.g. `WheelDriveEcsComponent`) can query for it jointly. - * `AirControlEcsComponent`/`ChassisStabilizerEcsComponent` live on a - * different entity (the chassis) but need to know about *both* wheels, so - * they instead hold direct references to each wheel's - * `GroundContactEcsComponent` object. + * How many static (ground) bodies this wheel is touching. Updated every + * tick by `createGroundContactEcsSystem`. */ export interface GroundContactEcsComponent { groundContacts: number; @@ -21,9 +12,7 @@ export const groundContactId = createComponentId('groundContact'); /** - * Returns whether `groundContact` currently reports its entity touching no - * ground. - * @param groundContact - The ground-contact state to check. + * Returns whether the entity is touching the ground. */ export function isGrounded(groundContact: GroundContactEcsComponent): boolean { return groundContact.groundContacts > 0; @@ -31,12 +20,7 @@ export function isGrounded(groundContact: GroundContactEcsComponent): boolean { /** * Attaches a {@link GroundContactEcsComponent} to `entity`. - * @param world - The ECS world `entity` belongs to. - * @param entity - The entity to attach the component to. - * @returns The attached component, so it can be handed to other components - * that need to read this entity's grounded state (see the class doc - * comment). `groundContacts` starts at `0`, since it's system-managed - * state, not configuration. + * @returns The component, so the chassis's components can read it too. */ export function addGroundContactComponent( world: EcsWorld, diff --git a/documentation-site/src/pages/demos/car/_ground-contact.system.ts b/documentation-site/src/pages/demos/car/_ground-contact.system.ts index 7eec3cf18..4ab1289a5 100644 --- a/documentation-site/src/pages/demos/car/_ground-contact.system.ts +++ b/documentation-site/src/pages/demos/car/_ground-contact.system.ts @@ -10,13 +10,8 @@ import { } from './_ground-contact.component'; /** - * Recomputes each matched entity's `GroundContactEcsComponent.groundContacts` - * from its `ContactsEcsComponent`, counting how many of the entities it's - * touching are static (no `RigidBodyEcsComponent`) bodies. Must run after - * `createNarrowPhaseEcsSystem`, which fills the contacts, and before any - * system that reads a `GroundContactEcsComponent` this same tick - * (`createWheelDriveEcsSystem`, `createChassisStabilizerEcsSystem`, - * `createAirControlEcsSystem`). + * Counts how many static bodies each wheel is touching, from the contacts + * the narrow phase found this tick. */ export const createGroundContactEcsSystem = (): EcsSystem< [GroundContactEcsComponent, ContactsEcsComponent] diff --git a/documentation-site/src/pages/demos/car/_wheel-drive.component.ts b/documentation-site/src/pages/demos/car/_wheel-drive.component.ts index 2498f867d..656087139 100644 --- a/documentation-site/src/pages/demos/car/_wheel-drive.component.ts +++ b/documentation-site/src/pages/demos/car/_wheel-drive.component.ts @@ -2,64 +2,31 @@ import { createComponentId, EcsWorld } from '@forge-game-engine/forge/ecs'; import { Axis1dAction } from '@forge-game-engine/forge/input'; /** - * Marks an entity as a driven wheel: `createWheelDriveEcsSystem` queries for - * this alongside the entity's `AngularVelocityMotorEcsComponent` and - * `GroundContactEcsComponent`, and sets the motor's `targetVelocity` and - * `maxTorque` from `throttleInput` every tick. There's no engine-provided - * "steer this motor from an input action" component, since what an axis - * should drive (a wheel, a turret, a slider) is always game-specific - this - * component plays that role for the demo. + * Drives a wheel's motor from the throttle input. */ export interface WheelDriveEcsComponent { throttleInput: Axis1dAction; - /** - * The chassis this wheel belongs to, used to compute the wheel's rolling - * angular speed (`-chassisVelocity.x / wheelRadius`) that - * `targetVelocity` is clamped around via `maxSlipAngularSpeed` while the - * wheel is airborne. - */ + /** The chassis, whose speed tells how fast the wheel should roll. */ chassisEntity: number; - /** - * This wheel's radius, used for the same rolling-speed conversion as - * `chassisBody`. - */ wheelRadius: number; /** - * The wheel's angular speed, in rad/s, at full throttle - * (`throttleInput.value` of `1` or `-1`) while grounded. The engine's own - * Coulomb friction model (see `resolveCollision`) is what actually caps - * how much of this the wheel can turn into real acceleration without - * slipping - this is deliberately far higher than the car could ever - * really roll at, an "as fast as grip allows" request rather than a speed - * the wheel is expected to reach unassisted. + * Wheel spin at full throttle, in rad/s. Deliberately unreachable: ground + * friction decides how much of it turns into speed. */ maxWheelSpeed: number; /** - * How far past the wheel's current rolling speed `targetVelocity` is - * allowed to stray *while airborne*, in rad/s. A grounded wheel's slip is - * already correctly limited by ground friction, the same way a real - * tire's is - but an airborne wheel has no friction at all, nothing but - * its own rotational inertia to resist the motor, so left unclamped it - * would accelerate towards `maxWheelSpeed` almost instantly regardless of - * whether that speed is at all useful, and be spinning far faster than - * the car is actually moving by the time it lands. This bounds that - * runaway without limiting how hard a *grounded* wheel can actually - * accelerate the car. + * In the air, how far the wheel may spin past its rolling speed, in + * rad/s. Without it, an airborne wheel spins up uselessly fast. */ maxSlipAngularSpeed: number; /** - * The motor's torque budget, in N·m, while `throttleInput` is nonzero. - * `createWheelDriveEcsSystem` drops the motor's actual `maxTorque` to `0` - * whenever `throttleInput.value` is exactly `0` instead of driving - * `targetVelocity` to `0` at this full budget - the latter would brake the - * wheel to a dead stop the instant the player lets go of the controls - * (effectively an always-on parking brake), rather than letting it - * freewheel and coast downhill under gravity the way an idling car does. + * Motor torque while the throttle is held. With no throttle the motor + * lets go, so the car coasts instead of parking. */ maxTorque: number; } diff --git a/documentation-site/src/pages/demos/car/_wheel-drive.system.ts b/documentation-site/src/pages/demos/car/_wheel-drive.system.ts index 2676b241b..323bb9efb 100644 --- a/documentation-site/src/pages/demos/car/_wheel-drive.system.ts +++ b/documentation-site/src/pages/demos/car/_wheel-drive.system.ts @@ -13,42 +13,13 @@ import { import { WheelDriveEcsComponent, wheelDriveId } from './_wheel-drive.component'; /** - * Sets each matched entity's `AngularVelocityMotorEcsComponent.targetVelocity` - * and `maxTorque` from its `WheelDriveEcsComponent.throttleInput` every tick, - * so `createAngularVelocityMotorEcsSystem` (registered afterwards) drives the - * wheel towards it. + * Sets each wheel motor's target speed and torque from the throttle. * - * The sign is flipped: with this engine's Y-up convention, a wheel rolling - * without slipping to the right (positive world-space x velocity `V`) spins - * with angular velocity `-V / radius` (positive angular velocity is - * counter-clockwise, and the bottom contact point of a wheel moving right - * traces backwards, i.e. clockwise). So driving the car forward (positive - * throttle, positive x) needs a *negative* target angular velocity. - * - * While the wheel's `GroundContactEcsComponent` reports it touching the - * ground, the throttle-desired target is used directly - the engine's own - * friction model (see `createCollisionResolutionEcsSystem`) is what - * correctly limits how much of that becomes real acceleration versus slip, - * the same way it would for a real tire. Only while airborne is the target - * clamped to within `maxSlipAngularSpeed` of the wheel's current rolling - * speed - see `WheelDriveEcsComponent.maxSlipAngularSpeed` for why an - * unclamped target would let an airborne wheel run away regardless of - * `maxWheelSpeed`. - * - * `maxTorque` is dropped to `0` whenever `throttleInput.value` is exactly - * `0`, rather than leaving it at `wheelDrive.maxTorque` and letting - * `targetVelocity` fall to `0` - at `wheelDrive.maxTorque` (large enough to - * punch through bumps), driving `targetVelocity` to `0` would brake the - * wheel to a dead stop the instant the player releases the controls, acting - * as a permanent parking brake and preventing the car from coasting downhill - * under gravity. Dropping the torque budget to `0` instead leaves the motor - * with nothing to push with, so the wheel spins freely at whatever rate - * rolling contact and gravity give it. - * - * Must run after `createGroundContactEcsSystem` in the same tick (so it sees - * this tick's grounded state) and before `createAngularVelocityMotorEcsSystem`, - * which itself must run before whatever system integrates velocity into - * position (`createEulerIntegrationEcsSystem`). + * - The sign is flipped: in a Y-up world a wheel rolling right spins + * clockwise, which is a negative angle. + * - On the ground the target is used as is; friction limits the grip. + * - In the air the target stays close to the wheel's rolling speed. + * - With no throttle the torque drops to zero, so the car coasts. */ export const createWheelDriveEcsSystem = (): EcsSystem< [ diff --git a/documentation-site/src/pages/demos/car/index.tsx b/documentation-site/src/pages/demos/car/index.tsx index d48a0e921..581fa26b4 100644 --- a/documentation-site/src/pages/demos/car/index.tsx +++ b/documentation-site/src/pages/demos/car/index.tsx @@ -17,109 +17,165 @@ import airControlSystemCode from '!!raw-loader!./_air-control.system'; import groundContactComponentCode from '!!raw-loader!./_ground-contact.component'; import groundContactSystemCode from '!!raw-loader!./_ground-contact.system'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; -import { KeyboardKey } from '@site/src/components/_KeyboardKey'; +import { DemoPage } from '@site/src/components/demo-page/DemoPage'; export default function Car(): JSX.Element { return ( - - } - text="Accelerate" - /> - } - text="Accelerate" - /> - } - text="Brake / Reverse" - /> - } - text="Brake / Reverse" - /> - } - text="Restart" - /> - - } - codeFiles={[ + controls={[ { - name: 'game.ts', - content: gameCode, + keys: ['→', 'D'], + action: 'Accelerate', + detail: 'In the air: tilt back', }, { - name: 'create-car.ts', - content: createCarCode, - }, - { - name: 'create-terrain.ts', - content: createTerrainCode, - }, - { - name: 'create-inputs.ts', - content: createInputsCode, - }, - { - name: 'wheel-drive.component.ts', - content: wheelDriveComponentCode, - }, - { - name: 'wheel-drive.system.ts', - content: wheelDriveSystemCode, - }, - { - name: 'camera-follow.component.ts', - content: cameraFollowComponentCode, - }, - { - name: 'camera-follow.system.ts', - content: cameraFollowSystemCode, - }, - { - name: 'car-reset.component.ts', - content: carResetComponentCode, - }, - { - name: 'car-reset.system.ts', - content: carResetSystemCode, + keys: ['←', 'A'], + action: 'Brake / reverse', + detail: 'In the air: tilt forward', }, + { keys: ['R'], action: 'Restart' }, + ]} + highlights={[ { - name: 'chassis-stabilizer.component.ts', - content: chassisStabilizerComponentCode, + text: 'The chassis and both wheels are separate rigid bodies.', + file: 'create-car.ts', }, { - name: 'chassis-stabilizer.system.ts', - content: chassisStabilizerSystemCode, + text: 'Each wheel hangs from a hidden hub: a prismatic joint for suspension travel, a revolute joint so it can spin, and a spring and damper for the bounce.', + file: 'create-car.ts', }, { - name: 'air-control.component.ts', - content: airControlComponentCode, + text: 'A motor spins each wheel, and friction with the ground decides how much of that becomes speed.', + file: 'wheel-drive.system.ts', }, { - name: 'air-control.system.ts', - content: airControlSystemCode, + text: 'With both wheels off the ground, the throttle tilts the car so you can line up a landing.', + file: 'air-control.system.ts', }, { - name: 'ground-contact.component.ts', - content: groundContactComponentCode, + text: 'The hills are a single terrain collider, drawn with a mesh built from the same points.', + file: 'create-terrain.ts', }, - { - name: 'ground-contact.system.ts', - content: groundContactSystemCode, + ]} + docLinks={[ + { label: 'Rigid bodies', to: '/docs/docs/physics/rigid-bodies' }, + { label: 'Joints', to: '/docs/docs/physics/joints' }, + { label: 'Forces', to: '/docs/docs/physics/forces' }, + { label: 'Terrain', to: '/docs/docs/physics/terrain' }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Sets up the camera, terrain and car, and registers every system in the order they run.', + content: gameCode, + }, + ], + }, + { + title: 'Building the scene', + files: [ + { + name: 'create-car.ts', + summary: + 'Builds the chassis, wheels and suspension, and the tuning values that make it drive well.', + content: createCarCode, + }, + { + name: 'create-terrain.ts', + summary: + 'Generates the hills, their collider and the mesh that draws them.', + content: createTerrainCode, + }, + { + name: 'create-inputs.ts', + summary: 'Binds the arrow keys, A/D and R to game actions.', + content: createInputsCode, + }, + ], + }, + { + title: 'Driving', + files: [ + { + name: 'ground-contact.component.ts', + summary: 'Stores how many ground bodies a wheel is touching.', + content: groundContactComponentCode, + }, + { + name: 'ground-contact.system.ts', + summary: "Counts each wheel's ground contacts every tick.", + content: groundContactSystemCode, + }, + { + name: 'wheel-drive.component.ts', + summary: + "Settings for driving a wheel's motor from the throttle.", + content: wheelDriveComponentCode, + }, + { + name: 'wheel-drive.system.ts', + summary: + "Sets each wheel motor's speed and torque from the throttle.", + content: wheelDriveSystemCode, + }, + ], + }, + { + title: 'Balance', + files: [ + { + name: 'chassis-stabilizer.component.ts', + summary: 'Settings for pulling the chassis back to level.', + content: chassisStabilizerComponentCode, + }, + { + name: 'chassis-stabilizer.system.ts', + summary: 'Levels the chassis while a wheel is on the ground.', + content: chassisStabilizerSystemCode, + }, + { + name: 'air-control.component.ts', + summary: 'Settings for tilting the car in mid-air.', + content: airControlComponentCode, + }, + { + name: 'air-control.system.ts', + summary: 'Tilts the car with the throttle while it is airborne.', + content: airControlSystemCode, + }, + ], + }, + { + title: 'Camera and reset', + files: [ + { + name: 'camera-follow.component.ts', + summary: 'Settings for a camera that follows an entity.', + content: cameraFollowComponentCode, + }, + { + name: 'camera-follow.system.ts', + summary: 'Moves the camera smoothly after the car.', + content: cameraFollowSystemCode, + }, + { + name: 'car-reset.component.ts', + summary: "Each body's spawn point, for restarting.", + content: carResetComponentCode, + }, + { + name: 'car-reset.system.ts', + summary: 'Puts the car back at the start when R is pressed.', + content: carResetSystemCode, + }, + ], }, ]} /> From 2491810d3ae145097b382f9baa148f647659a3ea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:08:05 +0000 Subject: [PATCH 02/34] docs(demos): add file search and type filter to the demo code explorer Label each file's type (setup, component, system, shader) with a colored icon, a filter chip and a badge on the open file, add a file-name search, and remove the empty space under the game by keeping the game and its sidebar the same height and moving the docs links into the header. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../components/demo-page/DemoPage.module.css | 195 ++++++++++++++++-- .../src/components/demo-page/DemoPage.tsx | 22 +- .../components/demo-page/_CodeExplorer.tsx | 175 ++++++++++++---- .../src/components/demo-page/file-types.ts | 43 ++++ 4 files changed, 368 insertions(+), 67 deletions(-) create mode 100644 documentation-site/src/components/demo-page/file-types.ts diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css index d0e872226..2faab6f77 100644 --- a/documentation-site/src/components/demo-page/DemoPage.module.css +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -30,13 +30,15 @@ .stage { display: grid; - grid-template-columns: minmax(0, 1fr) 340px; + grid-template-columns: minmax(0, 1fr) 380px; gap: 1.25rem; - align-items: start; } .game { position: relative; + align-self: stretch; + /* At least 16:9, and taller when the sidebar beside it is, so the two + * columns end level. */ aspect-ratio: 16 / 9; width: 100%; overflow: hidden; @@ -90,7 +92,7 @@ } .card { - padding: 1rem 1.1rem; + padding: 0.9rem 1.1rem; border-radius: 8px; border: 1px solid var(--ifm-color-emphasis-300); background-color: var(--ifm-background-surface-color); @@ -100,7 +102,7 @@ display: flex; align-items: center; gap: 0.5rem; - margin-bottom: 0.75rem; + margin-bottom: 0.6rem; font-size: 0.8rem; letter-spacing: 0.06em; text-transform: uppercase; @@ -112,7 +114,7 @@ .controls { display: flex; flex-direction: column; - gap: 0.6rem; + gap: 0.5rem; margin: 0; } @@ -169,10 +171,11 @@ .highlights { display: flex; flex-direction: column; - gap: 0.6rem; + gap: 0.45rem; margin: 0; padding-left: 1.1rem; - font-size: 0.92rem; + font-size: 0.9rem; + line-height: 1.5; } .fileLink { @@ -198,10 +201,8 @@ display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem; - margin-top: 0.9rem; - padding-top: 0.75rem; - border-top: 1px solid var(--ifm-color-emphasis-200); - font-size: 0.9rem; + margin-top: 0.5rem; + font-size: 0.95rem; } .docLinks span { @@ -228,11 +229,98 @@ border: 1px solid var(--ifm-color-emphasis-300); } +.fileBrowser { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.fileFilters { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.75rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} + +.fileSearch { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background-color: var(--ifm-background-color); + color: var(--ifm-color-emphasis-600); +} + +.fileSearch:focus-within { + border-color: var(--ifm-color-primary); +} + +.fileSearch input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.9rem; +} + +.typeFilter { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.typeFilterButton { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 999px; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.78rem; + cursor: pointer; +} + +.typeFilterButton .fileIcon { + width: auto; +} + +.typeFilterButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.typeFilterButtonSelected, +.typeFilterButtonSelected:hover { + border-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.typeFilterCount { + color: var(--ifm-color-emphasis-600); + font-weight: normal; +} + .fileTree { + flex: 1; + min-height: 0; overflow-y: auto; padding: 0.75rem 0; - border-right: 1px solid var(--ifm-color-emphasis-300); - background-color: var(--ifm-background-surface-color); +} + +.noFiles { + margin: 0; + padding: 0 1rem; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-600); } .fileGroup + .fileGroup { @@ -290,10 +378,66 @@ .fileIcon { flex-shrink: 0; width: 1rem; - color: var(--ifm-color-primary); text-align: center; } +.fileType_setup { + --file-type-color: #5d6789; +} + +.fileType_component { + --file-type-color: #2a6fa8; +} + +.fileType_system { + --file-type-color: #9a6400; +} + +.fileType_shader { + --file-type-color: #8d3fa0; +} + +:global([data-theme='dark']) .fileType_setup { + --file-type-color: #a3acc9; +} + +:global([data-theme='dark']) .fileType_component { + --file-type-color: #6db3e8; +} + +:global([data-theme='dark']) .fileType_system { + --file-type-color: #ffbc42; +} + +:global([data-theme='dark']) .fileType_shader { + --file-type-color: #d38ae3; +} + +i.fileIcon { + color: var(--file-type-color); +} + +.typeBadge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0 0.5rem; + border: 1px solid var(--file-type-color); + border-radius: 999px; + color: var(--ifm-font-color-base); + font-size: 0.75rem; +} + +.typeBadge .fileIcon { + width: auto; +} + +.filePaneTitle { + display: flex; + align-items: center; + gap: 0.6rem; +} + .filePane { display: flex; flex-direction: column; @@ -328,13 +472,28 @@ box-shadow: none; } +@media (max-width: 1200px) { + .stage { + grid-template-columns: minmax(0, 1fr); + } + + .game { + align-self: auto; + } + + .sidebar { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + } +} + @media (max-width: 996px) { .page { padding: 1rem 1rem 2rem; } - .stage { - grid-template-columns: minmax(0, 1fr); + .sidebar { + display: flex; } .explorer { @@ -343,8 +502,8 @@ height: 80vh; } - .fileTree { - max-height: 30vh; + .fileBrowser { + max-height: 40vh; border-right: none; border-bottom: 1px solid var(--ifm-color-emphasis-300); } diff --git a/documentation-site/src/components/demo-page/DemoPage.tsx b/documentation-site/src/components/demo-page/DemoPage.tsx index b226ca0f3..648e3dd3e 100644 --- a/documentation-site/src/components/demo-page/DemoPage.tsx +++ b/documentation-site/src/components/demo-page/DemoPage.tsx @@ -102,6 +102,18 @@ export const DemoPage: FC = ({

{title}

{summary}

+ {docLinks.length > 0 && ( + + )}
@@ -157,16 +169,6 @@ export const DemoPage: FC = ({ ))} - {docLinks.length > 0 && ( -
- Docs: - {docLinks.map((docLink) => ( - - {docLink.label} - - ))} -
- )}
diff --git a/documentation-site/src/components/demo-page/_CodeExplorer.tsx b/documentation-site/src/components/demo-page/_CodeExplorer.tsx index 88949cb1e..be35f6d16 100644 --- a/documentation-site/src/components/demo-page/_CodeExplorer.tsx +++ b/documentation-site/src/components/demo-page/_CodeExplorer.tsx @@ -1,12 +1,14 @@ -import React, { FC } from 'react'; +import React, { FC, useState } from 'react'; import CodeBlock from '@theme/CodeBlock'; import clsx from 'clsx'; import { cleanCodeSnippet } from '@site/src/utils/clean-code-snippet'; -import { - fileTypeIconLookup, - getFileTypeIcon, -} from '@site/src/components/_CodeSelector.utils'; import styles from './DemoPage.module.css'; +import { + DemoFileType, + demoFileTypeOrder, + demoFileTypes, + getDemoFileType, +} from './file-types'; import { DemoFile, DemoFileGroup } from './types'; interface CodeExplorerProps { @@ -15,52 +17,147 @@ interface CodeExplorerProps { onSelectFile: (fileName: string) => void; } +const FileTypeIcon: FC<{ type: DemoFileType }> = ({ type }) => ( + +); + export const CodeExplorer: FC = ({ fileGroups, selectedFile, onSelectFile, }) => { + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + + const allFiles = fileGroups.flatMap((group) => group.files); + const typesPresent = demoFileTypeOrder.filter((type) => + allFiles.some((file) => getDemoFileType(file.name) === type), + ); + const normalizedQuery = query.trim().toLowerCase(); + + const matches = (file: DemoFile): boolean => + (typeFilter === 'all' || getDemoFileType(file.name) === typeFilter) && + file.name.toLowerCase().includes(normalizedQuery); + + const visibleGroups = fileGroups + .map((group) => ({ ...group, files: group.files.filter(matches) })) + .filter((group) => group.files.length > 0); + + const selectedType = getDemoFileType(selectedFile.name); + + const filterOptions: { value: DemoFileType | 'all'; label: string }[] = [ + { value: 'all', label: 'All' }, + ...typesPresent.map((type) => ({ + value: type, + label: demoFileTypes[type].pluralLabel, + })), + ]; + return (
- +
+
- {selectedFile.name} +
+ {selectedFile.name} + + + {demoFileTypes[selectedType].label} + +
{selectedFile.summary}
= { + setup: { label: 'Setup', pluralLabel: 'Setup', icon: 'fa-wrench' }, + component: { label: 'Component', pluralLabel: 'Components', icon: 'fa-cube' }, + system: { label: 'System', pluralLabel: 'Systems', icon: 'fa-gears' }, + shader: { label: 'Shader', pluralLabel: 'Shaders', icon: 'fa-palette' }, +}; + +export const demoFileTypeOrder: readonly DemoFileType[] = [ + 'setup', + 'component', + 'system', + 'shader', +]; + +export const getDemoFileType = (fileName: string): DemoFileType => { + if (fileName.includes('.component.')) { + return 'component'; + } + + if (fileName.includes('.system.')) { + return 'system'; + } + + if (fileName.includes('.shader.') || fileName.endsWith('.glsl')) { + return 'shader'; + } + + return 'setup'; +}; From 714811a36721028e5467995b4c2ad99886cc74d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:13:50 +0000 Subject: [PATCH 03/34] docs(demos): drop per-type colors from code explorer icons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../components/demo-page/DemoPage.module.css | 36 ++----------------- .../components/demo-page/_CodeExplorer.tsx | 14 ++------ 2 files changed, 4 insertions(+), 46 deletions(-) diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css index 2faab6f77..0b5ee4e24 100644 --- a/documentation-site/src/components/demo-page/DemoPage.module.css +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -381,40 +381,8 @@ text-align: center; } -.fileType_setup { - --file-type-color: #5d6789; -} - -.fileType_component { - --file-type-color: #2a6fa8; -} - -.fileType_system { - --file-type-color: #9a6400; -} - -.fileType_shader { - --file-type-color: #8d3fa0; -} - -:global([data-theme='dark']) .fileType_setup { - --file-type-color: #a3acc9; -} - -:global([data-theme='dark']) .fileType_component { - --file-type-color: #6db3e8; -} - -:global([data-theme='dark']) .fileType_system { - --file-type-color: #ffbc42; -} - -:global([data-theme='dark']) .fileType_shader { - --file-type-color: #d38ae3; -} - i.fileIcon { - color: var(--file-type-color); + color: var(--ifm-color-emphasis-700); } .typeBadge { @@ -422,7 +390,7 @@ i.fileIcon { align-items: center; gap: 0.3rem; padding: 0 0.5rem; - border: 1px solid var(--file-type-color); + border: 1px solid var(--ifm-color-emphasis-400); border-radius: 999px; color: var(--ifm-font-color-base); font-size: 0.75rem; diff --git a/documentation-site/src/components/demo-page/_CodeExplorer.tsx b/documentation-site/src/components/demo-page/_CodeExplorer.tsx index be35f6d16..ba474ba98 100644 --- a/documentation-site/src/components/demo-page/_CodeExplorer.tsx +++ b/documentation-site/src/components/demo-page/_CodeExplorer.tsx @@ -19,12 +19,7 @@ interface CodeExplorerProps { const FileTypeIcon: FC<{ type: DemoFileType }> = ({ type }) => ( ); @@ -148,12 +143,7 @@ export const CodeExplorer: FC = ({
{selectedFile.name} - + {demoFileTypes[selectedType].label} From ff95f9e5157626f6cdb2fccf0565e23ef015bbc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:15:55 +0000 Subject: [PATCH 04/34] docs(demos): show the open file's type as plain text, not a pill Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../src/components/demo-page/DemoPage.module.css | 11 ++++------- .../src/components/demo-page/_CodeExplorer.tsx | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css index 0b5ee4e24..b58471178 100644 --- a/documentation-site/src/components/demo-page/DemoPage.module.css +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -385,18 +385,15 @@ i.fileIcon { color: var(--ifm-color-emphasis-700); } -.typeBadge { +.fileTypeLabel { display: inline-flex; align-items: center; gap: 0.3rem; - padding: 0 0.5rem; - border: 1px solid var(--ifm-color-emphasis-400); - border-radius: 999px; - color: var(--ifm-font-color-base); - font-size: 0.75rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.8rem; } -.typeBadge .fileIcon { +.fileTypeLabel .fileIcon { width: auto; } diff --git a/documentation-site/src/components/demo-page/_CodeExplorer.tsx b/documentation-site/src/components/demo-page/_CodeExplorer.tsx index ba474ba98..14d106d2f 100644 --- a/documentation-site/src/components/demo-page/_CodeExplorer.tsx +++ b/documentation-site/src/components/demo-page/_CodeExplorer.tsx @@ -143,7 +143,7 @@ export const CodeExplorer: FC = ({
{selectedFile.name} - + {demoFileTypes[selectedType].label} From 004178a27f2f6ef7837cc4520f097f275fa7e4a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:17:57 +0000 Subject: [PATCH 05/34] docs(demos): right-align the open file's type in the code explorer Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../src/components/demo-page/DemoPage.module.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation-site/src/components/demo-page/DemoPage.module.css b/documentation-site/src/components/demo-page/DemoPage.module.css index b58471178..25ea42320 100644 --- a/documentation-site/src/components/demo-page/DemoPage.module.css +++ b/documentation-site/src/components/demo-page/DemoPage.module.css @@ -400,7 +400,8 @@ i.fileIcon { .filePaneTitle { display: flex; align-items: center; - gap: 0.6rem; + justify-content: space-between; + gap: 1rem; } .filePane { From b529985a15cb992daaa2005e52b5a9626fd21bf1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:23:14 +0000 Subject: [PATCH 06/34] docs(demos): split DemoPage into reusable components Break the demo page into DemoHeader, DemoGame, DemoPanel, DemoControls, DemoHighlights, DemoLegend and CodeExplorer, take each page's title and summary from the demo catalogue, add a panels slot and mouse/gamepad inputs, and describe the new page in the add-feature-demo skill. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .claude/skills/add-feature-demo/SKILL.md | 138 +++--- AGENTS.md | 6 +- documentation-site/src/components/Demo.tsx | 2 +- .../demo-page/CodeExplorer.module.css | 231 +++++++++ .../{_CodeExplorer.tsx => CodeExplorer.tsx} | 98 ++-- .../demo-page/DemoControls.module.css | 58 +++ .../src/components/demo-page/DemoControls.tsx | 66 +++ .../components/demo-page/DemoGame.module.css | 56 +++ .../src/components/demo-page/DemoGame.tsx | 54 +++ .../demo-page/DemoHeader.module.css | 32 ++ .../src/components/demo-page/DemoHeader.tsx | 46 ++ .../demo-page/DemoHighlights.module.css | 28 ++ .../components/demo-page/DemoHighlights.tsx | 36 ++ .../demo-page/DemoLegend.module.css | 23 + .../src/components/demo-page/DemoLegend.tsx | 24 + .../components/demo-page/DemoPage.module.css | 438 +----------------- .../src/components/demo-page/DemoPage.tsx | 153 ++---- .../components/demo-page/DemoPanel.module.css | 17 + .../src/components/demo-page/DemoPanel.tsx | 25 + .../FullscreenButton.module.css} | 0 .../FullscreenButton.tsx} | 0 .../components/demo-page/_DemoControls.tsx | 32 -- .../src/components/demo-page/index.ts | 11 + .../src/components/demo-page/types.ts | 22 +- documentation-site/src/data/demos.ts | 2 +- .../src/pages/demos/car/index.tsx | 11 +- 26 files changed, 921 insertions(+), 688 deletions(-) create mode 100644 documentation-site/src/components/demo-page/CodeExplorer.module.css rename documentation-site/src/components/demo-page/{_CodeExplorer.tsx => CodeExplorer.tsx} (66%) create mode 100644 documentation-site/src/components/demo-page/DemoControls.module.css create mode 100644 documentation-site/src/components/demo-page/DemoControls.tsx create mode 100644 documentation-site/src/components/demo-page/DemoGame.module.css create mode 100644 documentation-site/src/components/demo-page/DemoGame.tsx create mode 100644 documentation-site/src/components/demo-page/DemoHeader.module.css create mode 100644 documentation-site/src/components/demo-page/DemoHeader.tsx create mode 100644 documentation-site/src/components/demo-page/DemoHighlights.module.css create mode 100644 documentation-site/src/components/demo-page/DemoHighlights.tsx create mode 100644 documentation-site/src/components/demo-page/DemoLegend.module.css create mode 100644 documentation-site/src/components/demo-page/DemoLegend.tsx create mode 100644 documentation-site/src/components/demo-page/DemoPanel.module.css create mode 100644 documentation-site/src/components/demo-page/DemoPanel.tsx rename documentation-site/src/components/{_FullscreenButton.module.css => demo-page/FullscreenButton.module.css} (100%) rename documentation-site/src/components/{_FullscreenButton.tsx => demo-page/FullscreenButton.tsx} (100%) delete mode 100644 documentation-site/src/components/demo-page/_DemoControls.tsx create mode 100644 documentation-site/src/components/demo-page/index.ts diff --git a/.claude/skills/add-feature-demo/SKILL.md b/.claude/skills/add-feature-demo/SKILL.md index 344e9e623..f4ef2978d 100644 --- a/.claude/skills/add-feature-demo/SKILL.md +++ b/.claude/skills/add-feature-demo/SKILL.md @@ -6,8 +6,8 @@ description: Use this skill when adding a new feature to the engine. # Add a Demo `documentation-site/src/pages/demos//` holds interactive, in-browser -demos of engine features, each rendered through -`documentation-site/src/components/Demo.tsx`. Read `AGENTS.md`'s +demos of engine features, each rendered through `DemoPage` +(`documentation-site/src/components/demo-page/`). Read `AGENTS.md`'s "Documentation Site Demos" section first for the `file:..`/`dist` gotcha that governs how demos are verified — it applies to every demo, new or existing. @@ -57,8 +57,8 @@ Follow the existing file split, every demo uses the same shape: `_create-boundaries.ts` from `_spawn-shapes.ts`, not into one monolithic file — each file also becomes its own tab in the demo's code viewer (see step 3). -- `index.tsx` — the page itself, a thin wrapper around `` (see step - 3). +- `index.tsx` — the page itself, a thin wrapper around `` (see + step 3). Demo-only components/systems (input handling for interaction, camera follow, reset-on-key, etc.) still follow the engine's own component/system @@ -71,59 +71,86 @@ instead of `/src` because they're not part of the public API. import React, { JSX } from 'react'; import { createGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import someOtherCode from '!!raw-loader!./_some-other-file'; +import thingSystemCode from '!!raw-loader!./_thing.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function (): JSX.Element { return ( - Demo', - description: 'A demo showcasing .', - }} - header="" - blurb="<1-3 sentences: what the demo shows, what the engine feature - does, and how to interact with it (click/drag/keys) if applicable.>" + Game} - codeFiles={[ - { name: 'game.ts', content: gameCode }, - { name: 'some-other-file.ts', content: someOtherCode }, + controls={[ + { inputs: ['←', 'A'], action: 'Move left' }, + { inputs: [{ device: 'mouse', label: 'Click' }], action: 'Select' }, + ]} + highlights={[ + { + text: '', + file: 'thing.system.ts', + }, + ]} + docLinks={[{ label: '', to: '/docs/docs//' }]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: '', + content: gameCode, + }, + ], + }, + { + title: '', + files: [ + { + name: 'thing.system.ts', + summary: '', + content: thingSystemCode, + }, + ], + }, ]} /> ); } ``` -Each `codeFiles` entry drives one tab in the demo's live code viewer, so -list every demo-source file the reader would need to fully understand the -scene, in the order that makes it easiest to follow (usually `game.ts` -first, since it's the entry point). Import each via `!!raw-loader!` exactly -like `gameCode` — this is what pulls the raw source text in at build time, -not the compiled/imported binding. - -If the demo needs live interaction controls beyond canvas input (a slider, -a toggle button), pass a React node via `interactions` — see -`space-shooter/index.tsx` and `_BloomControls.tsx` for the pattern of a -small controls component alongside the demo files. - -## 4. Wire it into the navbar - -Add an entry to the `Demos` dropdown in -`documentation-site/docusaurus.config.ts` (currently around line 114), -alphabetical-ish grouping isn't strictly enforced but new entries are -generally added near thematically similar ones (physics demos are grouped -together, etc.): - -```typescript -{ - to: 'demos/', - label: '', -}, -``` - -This is the only place demos are registered/linked — there's no separate -index page or sidebar to update. +The page's title and one-line summary come from the demo's entry in +`documentation-site/src/data/demos.ts` (step 4), so the catalogue card and +the page always agree. Write the rest for a reader who has never seen the +feature: + +- `controls`: one row per action, with alternative inputs in the same + row. Check each binding against the demo's input code. Leave it out for + a demo you only watch. +- `highlights`: 3-5 "How it works" points, one short sentence each, each + linked to the file that implements it. Clicking the link opens that file + in the code explorer. +- `docLinks`: the guides under `documentation-site/docs/docs` that cover + the feature (`/docs/docs/`, a folder's `index.md` is + `/docs/docs/`). +- `fileGroups`: every demo-source file a reader needs, grouped by purpose, + with `create-game.ts` alone under "Start here". Name each after its + source file without the leading underscore, and keep the + `.component.ts`/`.system.ts` suffixes: the explorer's type icons and + filter come from them. Import each via `!!raw-loader!` exactly like + `gameCode`, which pulls in the raw source text at build time. + +If the demo needs something else beside the game, such as live settings (a +slider, a toggle) or a legend for colors in the scene, pass it as `panels`, +wrapped in `DemoPanel` (with `DemoLegend` for a legend). See +`ui-anchors/index.tsx` and `easing-functions/index.tsx`. + +## 4. Add it to the demo catalogue + +Add an entry to `documentation-site/src/data/demos.ts` with the demo's +`slug` (its directory name), `title`, a one-sentence `description` (what +you see or do, and which feature it shows; it's the page's summary too) and +the category slugs it belongs to. See AGENTS.md's "Demo Catalogue and +Categories" section. ## 5. Verify @@ -138,23 +165,14 @@ verification checklist: already-built, unchanged API). 2. From `documentation-site/`: `npm run typecheck`, then `npm run build` (`docusaurus build`) — this is what actually catches a broken import - against the published package surface or an MDX/broken-link error from - the navbar entry. + against the published package surface or a broken `docLinks` entry. 3. `npm run start` in `documentation-site/` (or reuse a running dev server), open `demos/` in a browser with a full page reload (fast refresh doesn't guarantee a clean re-init), and confirm it - renders and behaves correctly — click through any interactions the - `blurb` describes. -4. **Also click the entry from the actual "Demos" navbar dropdown** - don't - only visit the URL directly. Adding the array entry in step 4 above is - necessary but not sufficient: the dropdown has grown to ~30 entries and - its `.dropdown__menu` is capped to the viewport height with internal - scrolling (`documentation-site/src/css/custom.css`) specifically so - every entry stays reachable regardless of list length - confirm your new - entry actually scrolls into view and is clickable in a real browser, - since a syntactically-correct array entry can still end up unreachable - if that CSS ever regresses or a future redesign changes the dropdown - markup. + renders and behaves correctly — try every control the page lists and + open each highlight's file link. +4. Open the demo from its card on `/demos` too, to check the catalogue + entry. If this was prompted by a `/src` change (not a brand-new demo for existing functionality), also re-run the full root-level `CLAUDE.md` verification diff --git a/AGENTS.md b/AGENTS.md index c852ea38b..a2e86e34d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -673,7 +673,11 @@ e2e/playwright-report` opens the HTML report, which links each test's `documentation-site/src/pages/demos//` holds interactive, in-browser demos of engine features (`physics`, `ecs`, `particles`, `rendering`, ...), -each rendered through `documentation-site/src/components/Demo.tsx`. +each rendered through `DemoPage` (`documentation-site/src/components/demo-page/`): +the running game with its controls and a short "How it works" list beside +it, and a code explorer for the demo's source below. Its title and summary +come from the demo's entry in `documentation-site/src/data/demos.ts`. The +`add-feature-demo` skill shows how to write a page. **Critical gotcha**: these demos import the engine as a published package (e.g. `import { addRigidBodyComponent } from '@forge-game-engine/forge/physics'`), diff --git a/documentation-site/src/components/Demo.tsx b/documentation-site/src/components/Demo.tsx index 6c5697cef..01b08c02d 100644 --- a/documentation-site/src/components/Demo.tsx +++ b/documentation-site/src/components/Demo.tsx @@ -6,7 +6,7 @@ import { useFullscreen } from '@site/src/hooks/useFullscreen'; import { useDemoBackLink } from '@site/src/hooks/useDemoBackLink'; import styles from './_Demo.module.css'; import { CodeSelector } from './_CodeSelector'; -import { FullscreenButton } from './_FullscreenButton'; +import { FullscreenButton } from './demo-page/FullscreenButton'; interface CodeFile { name: string; diff --git a/documentation-site/src/components/demo-page/CodeExplorer.module.css b/documentation-site/src/components/demo-page/CodeExplorer.module.css new file mode 100644 index 000000000..696889ed5 --- /dev/null +++ b/documentation-site/src/components/demo-page/CodeExplorer.module.css @@ -0,0 +1,231 @@ +.explorer { + display: grid; + grid-template-columns: 300px minmax(0, 1fr); + height: 640px; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--ifm-color-emphasis-300); +} + +.fileBrowser { + display: flex; + flex-direction: column; + min-height: 0; + border-right: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.fileFilters { + display: flex; + flex-direction: column; + gap: 0.6rem; + padding: 0.75rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); +} + +.fileSearch { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.6rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 6px; + background-color: var(--ifm-background-color); + color: var(--ifm-color-emphasis-600); +} + +.fileSearch:focus-within { + border-color: var(--ifm-color-primary); +} + +.fileSearch input { + flex: 1; + min-width: 0; + border: none; + outline: none; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.9rem; +} + +.typeFilter { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.typeFilterButton { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.15rem 0.55rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: 999px; + background: none; + color: var(--ifm-font-color-base); + font-size: 0.78rem; + cursor: pointer; +} + +.typeFilterButton .fileIcon { + width: auto; +} + +.typeFilterButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.typeFilterButtonSelected, +.typeFilterButtonSelected:hover { + border-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.typeFilterCount { + color: var(--ifm-color-emphasis-600); + font-weight: normal; +} + +.fileTree { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 0; +} + +.noFiles { + margin: 0; + padding: 0 1rem; + font-size: 0.9rem; + color: var(--ifm-color-emphasis-600); +} + +.fileGroup + .fileGroup { + margin-top: 0.75rem; +} + +.fileGroupTitle { + padding: 0 1rem 0.25rem; + font-size: 0.72rem; + font-weight: bold; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--ifm-color-emphasis-600); +} + +.fileTree ul { + margin: 0; + padding: 0; + list-style: none; +} + +.fileButton { + display: flex; + align-items: center; + gap: 0.5rem; + width: 100%; + padding: 0.3rem 1rem; + border: none; + border-left: 3px solid transparent; + background: none; + color: var(--ifm-font-color-base); + font-family: var(--ifm-font-family-monospace); + font-size: 0.82rem; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.fileName { + overflow: hidden; + text-overflow: ellipsis; +} + +.fileButton:hover { + background-color: var(--ifm-color-emphasis-200); +} + +.fileButtonSelected, +.fileButtonSelected:hover { + border-left-color: var(--ifm-color-primary); + background-color: var(--ifm-color-emphasis-200); + font-weight: bold; +} + +.fileIcon { + flex-shrink: 0; + width: 1rem; + text-align: center; +} + +i.fileIcon { + color: var(--ifm-color-emphasis-700); +} + +.fileTypeLabel { + display: inline-flex; + align-items: center; + gap: 0.3rem; + color: var(--ifm-color-emphasis-700); + font-size: 0.8rem; +} + +.fileTypeLabel .fileIcon { + width: auto; +} + +.filePaneTitle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.filePane { + display: flex; + flex-direction: column; + min-height: 0; +} + +.filePaneHeader { + display: flex; + flex-direction: column; + gap: 0.1rem; + padding: 0.6rem 1rem; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + background-color: var(--ifm-background-surface-color); +} + +.filePaneName { + font-family: var(--ifm-font-family-monospace); + font-weight: bold; +} + +.filePaneSummary { + font-size: 0.88rem; + color: var(--ifm-color-emphasis-700); +} + +.codeBlock { + flex: 1; + min-height: 0; + margin: 0; + overflow-y: auto; + border-radius: 0; + box-shadow: none; +} + +@media (max-width: 996px) { + .explorer { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); + height: 80vh; + } + + .fileBrowser { + max-height: 40vh; + border-right: none; + border-bottom: 1px solid var(--ifm-color-emphasis-300); + } +} diff --git a/documentation-site/src/components/demo-page/_CodeExplorer.tsx b/documentation-site/src/components/demo-page/CodeExplorer.tsx similarity index 66% rename from documentation-site/src/components/demo-page/_CodeExplorer.tsx rename to documentation-site/src/components/demo-page/CodeExplorer.tsx index 14d106d2f..f0afa474e 100644 --- a/documentation-site/src/components/demo-page/_CodeExplorer.tsx +++ b/documentation-site/src/components/demo-page/CodeExplorer.tsx @@ -2,7 +2,7 @@ import React, { FC, useState } from 'react'; import CodeBlock from '@theme/CodeBlock'; import clsx from 'clsx'; import { cleanCodeSnippet } from '@site/src/utils/clean-code-snippet'; -import styles from './DemoPage.module.css'; +import styles from './CodeExplorer.module.css'; import { DemoFileType, demoFileTypeOrder, @@ -11,6 +11,10 @@ import { } from './file-types'; import { DemoFile, DemoFileGroup } from './types'; +// Below this many files the whole list fits on screen, so search and +// filters would only add clutter. +const minFilesForFilters = 6; + interface CodeExplorerProps { fileGroups: DemoFileGroup[]; selectedFile: DemoFile; @@ -24,6 +28,10 @@ const FileTypeIcon: FC<{ type: DemoFileType }> = ({ type }) => ( > ); +/** + * Browses a demo's source files: a grouped file list with search and a + * type filter, and the selected file's code. + */ export const CodeExplorer: FC = ({ fileGroups, selectedFile, @@ -47,6 +55,7 @@ export const CodeExplorer: FC = ({ .filter((group) => group.files.length > 0); const selectedType = getDemoFileType(selectedFile.name); + const showFilters = allFiles.length >= minFilesForFilters; const filterOptions: { value: DemoFileType | 'all'; label: string }[] = [ { value: 'all', label: 'All' }, @@ -59,49 +68,54 @@ export const CodeExplorer: FC = ({ return (
-
-
- - setQuery(event.target.value)} - /> -
-
- {filterOptions.map(({ value, label }) => { - const count = - value === 'all' - ? allFiles.length - : allFiles.filter( - (file) => getDemoFileType(file.name) === value, - ).length; + {showFilters && ( +
+
+ + setQuery(event.target.value)} + /> +
+
+ {filterOptions.map(({ value, label }) => { + const count = + value === 'all' + ? allFiles.length + : allFiles.filter( + (file) => getDemoFileType(file.name) === value, + ).length; - return ( - - ); - })} + return ( + + ); + })} +
-
+ )}
- } - text="Drag the list or its scrollbar, turn the wheel, or click a level." - /> - - - - - } - text="Move focus through the list; it scrolls to follow." - /> - } - text="Pick the focused level." - /> - - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [ + { device: 'mouse', label: 'Drag' }, + { device: 'mouse', label: 'Wheel' }, + { device: 'mouse', label: 'Drag scrollbar' }, + ], + action: 'Scroll the list', + }, + { inputs: ['↑', '↓'], action: 'Move focus' }, + { + inputs: [{ device: 'mouse', label: 'Click' }, 'Enter', 'Space'], + action: 'Pick a level', + }, + ]} + highlights={[ + { + text: 'createScrollView clips a list of 30 buttons to its viewport with a mask, and lays them out in a column.', + file: 'create-game.ts', + }, + { + text: 'Dragging the list follows the pointer, coasts after a quick release and springs back when pulled past an end.', + file: 'create-game.ts', + }, + { + text: 'A drag that starts on a button scrolls the list instead of clicking the button.', + file: 'create-game.ts', + }, + { + text: 'Moving focus with the arrow keys scrolls the focused button into view.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Scroll views', to: '/docs/docs/ui/scroll-views' }, + { + label: 'Buttons and interaction', + to: '/docs/docs/ui/buttons-and-interaction', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds the scroll view, its scrollbar and 30 level buttons, and wires mouse and keyboard input.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-slider/index.tsx b/documentation-site/src/pages/demos/ui-slider/index.tsx index ad8063ca6..801aad389 100644 --- a/documentation-site/src/pages/demos/ui-slider/index.tsx +++ b/documentation-site/src/pages/demos/ui-slider/index.tsx @@ -2,42 +2,57 @@ import React, { JSX } from 'react'; import { createSliderGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiSlider(): JSX.Element { return ( - - -
- } - text="Drag the handle, or click anywhere on the track." - /> - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Drag the handle' }], + action: 'Change the value', + }, + { + inputs: [{ device: 'mouse', label: 'Click the track' }], + action: 'Jump to a value', + }, + ]} + highlights={[ + { + text: 'createSlider builds a track, a draggable handle and a fill sprite that grows with the value.', + file: 'create-game.ts', + }, + { + text: 'The whole track is the drag surface, so clicking anywhere on it moves the handle there.', + file: 'create-game.ts', + }, + { + text: 'A drag keeps tracking even when the pointer strays above or below the track.', + file: 'create-game.ts', + }, + { + text: 'The value label updates live from the onValueChanged event, and wholeNumbers rounds the value to integers.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Sliders', to: '/docs/docs/ui/controls#sliders' }, + { label: 'Creating a canvas', to: '/docs/docs/ui/creating-a-canvas' }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: 'Builds the slider and a value label that follows it.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-stress-test/index.tsx b/documentation-site/src/pages/demos/ui-stress-test/index.tsx index 928d5d84c..dcd7b8254 100644 --- a/documentation-site/src/pages/demos/ui-stress-test/index.tsx +++ b/documentation-site/src/pages/demos/ui-stress-test/index.tsx @@ -5,35 +5,69 @@ import spawnerComponentCode from '!!raw-loader!./_stress-test-spawner.component' import spawnerSystemCode from '!!raw-loader!./_stress-test-spawner.system'; import fpsMonitorSystemCode from '!!raw-loader!./_fps-monitor.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiStressTest(): JSX.Element { return ( - diff --git a/documentation-site/src/pages/demos/ui-text-input/index.tsx b/documentation-site/src/pages/demos/ui-text-input/index.tsx index 95cdf0596..93bbcb4c2 100644 --- a/documentation-site/src/pages/demos/ui-text-input/index.tsx +++ b/documentation-site/src/pages/demos/ui-text-input/index.tsx @@ -2,53 +2,64 @@ import React, { JSX } from 'react'; import { createTextInputGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; -import { KeyboardKey } from '@site/src/components/_KeyboardKey'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiTextInput(): JSX.Element { return ( - - - -
- } - text="Click a field to type into it." - /> - } - text="Submit the field you're typing in." - /> - } - text="Cancel typing." - /> - - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click a field' }], + action: 'Start typing', + }, + { inputs: ['↑', '↓', '←', '→'], action: 'Move focus' }, + { + inputs: ['Enter'], + action: 'Submit', + detail: 'Outside a field: press the focused field or button', + }, + { inputs: ['Esc'], action: 'Cancel typing' }, + ]} + highlights={[ + { + text: 'createTextInput builds each field, with a placeholder, a caret and a maximum length.', + file: 'create-game.ts', + }, + { + text: "Filters shape what's typed: the pilot name can't start with a space, and the badge keeps only upper-cased letters and digits.", + file: 'create-game.ts', + }, + { + text: "Enter fires the field's onSubmit event and Escape fires onCancel, which update the status line.", + file: 'create-game.ts', + }, + { + text: "Keys typed into a field don't reach the UI's bindings, so they never move focus or press the focused button.", + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Text input', to: '/docs/docs/ui/text-input' }, + { + label: 'Buttons and interaction', + to: '/docs/docs/ui/buttons-and-interaction', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds the two filtered text fields, the log-in button and the status line.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-toggle/index.tsx b/documentation-site/src/pages/demos/ui-toggle/index.tsx index c5adece94..477c60417 100644 --- a/documentation-site/src/pages/demos/ui-toggle/index.tsx +++ b/documentation-site/src/pages/demos/ui-toggle/index.tsx @@ -2,42 +2,53 @@ import React, { JSX } from 'react'; import { createToggleGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiToggle(): JSX.Element { return ( - - -
- } - text="Click a toggle." - /> - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: 'Flip a toggle', + }, + ]} + highlights={[ + { + text: "The 'Mute' toggle has no group, so it flips on and off freely, like a checkbox.", + file: 'create-game.ts', + }, + { + text: "The three 'Difficulty' toggles share a toggle group, which turns them into radio buttons.", + file: 'create-game.ts', + }, + { + text: 'A group keeps exactly one toggle on by default, so turning one on turns the others off.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Toggles', to: '/docs/docs/ui/controls#toggles' }, + { + label: 'Grouping toggles', + to: '/docs/docs/ui/controls#grouping-toggles', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds a lone checkbox toggle and a three-way radio group with captions.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-world-space-canvas/index.tsx b/documentation-site/src/pages/demos/ui-world-space-canvas/index.tsx index af41f0b8d..e4ef9c2a4 100644 --- a/documentation-site/src/pages/demos/ui-world-space-canvas/index.tsx +++ b/documentation-site/src/pages/demos/ui-world-space-canvas/index.tsx @@ -2,20 +2,51 @@ import React, { JSX } from 'react'; import { createWorldSpaceCanvasGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiWorldSpaceCanvas(): JSX.Element { return ( - ); } diff --git a/documentation-site/src/pages/demos/visibility/index.tsx b/documentation-site/src/pages/demos/visibility/index.tsx index d92621a32..f002a7b83 100644 --- a/documentation-site/src/pages/demos/visibility/index.tsx +++ b/documentation-site/src/pages/demos/visibility/index.tsx @@ -4,55 +4,79 @@ import gameCode from '!!raw-loader!./_create-game'; import menuCode from '!!raw-loader!./_create-menu'; import beaconCode from '!!raw-loader!./_create-beacon'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function Visibility(): JSX.Element { return ( - - - -
- } - text="Click the toggles to hide and show parts of the scene." - /> - - -
- } - text="Arrow keys move focus; hiding the focused button clears focus." - /> - - } - codeFiles={[ - { name: 'game.ts', content: gameCode }, - { name: 'menu.ts', content: menuCode }, - { name: 'beacon.ts', content: beaconCode }, + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: 'Flip a toggle', + }, + { + inputs: ['↑', '↓', '←', '→'], + action: 'Move focus', + detail: 'Hiding the focused button clears focus', + }, + { inputs: ['Enter', 'Space'], action: 'Press the focused element' }, + ]} + highlights={[ + { + text: "Hiding 'Load game' takes it out of the menu's layout group, so the buttons below move up and the panel shrinks.", + file: 'create-menu.ts', + }, + { + text: 'Hiding the menu hides the panel and everything under it, and its buttons can no longer be hovered, clicked or focused.', + file: 'create-menu.ts', + }, + { + text: "Fading the menu sets a canvas group's alpha instead, so it stays laid out, drawn and clickable.", + file: 'create-menu.ts', + }, + { + text: 'Hiding the beacon hides its lamp and stops its spark emitter, while sparks already in the air fade out.', + file: 'create-beacon.ts', + }, + ]} + docLinks={[ + { label: 'Visibility', to: '/docs/docs/rendering/visibility' }, + { + label: 'Hiding, fading and tooltips', + to: '/docs/docs/ui/canvas-groups-and-tooltips', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds the menu, the beacon and the toggles that hide and fade them.', + content: gameCode, + }, + ], + }, + { + title: 'Building the scene', + files: [ + { + name: 'create-menu.ts', + summary: + 'A fitted button menu with a visibility component and a canvas group.', + content: menuCode, + }, + { + name: 'create-beacon.ts', + summary: + 'A beacon with a child lamp and a spark emitter, hidden as one.', + content: beaconCode, + }, + ], + }, ]} /> ); diff --git a/documentation-site/src/pages/demos/wrecking-ball/index.tsx b/documentation-site/src/pages/demos/wrecking-ball/index.tsx index 7653f24e3..c688a322f 100644 --- a/documentation-site/src/pages/demos/wrecking-ball/index.tsx +++ b/documentation-site/src/pages/demos/wrecking-ball/index.tsx @@ -2,28 +2,76 @@ import React, { JSX } from 'react'; import { createWreckingBallGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; import createWreckingBallCode from '!!raw-loader!./_create-wrecking-ball'; +import armComponentCode from '!!raw-loader!./_arm.component'; +import armSystemCode from '!!raw-loader!./_arm.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function WreckingBall(): JSX.Element { return ( - From 42cd3c715a52b985845109ecf7f3d997a12be4fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:24:56 +0000 Subject: [PATCH 08/34] docs(demos): move the remaining UI demos to DemoPage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../src/pages/demos/ui-anchors/index.tsx | 92 +++++++----- .../src/pages/demos/ui-button/index.tsx | 97 ++++++------- .../src/pages/demos/ui-canvas-group/index.tsx | 84 ++++++----- .../src/pages/demos/ui-dropdown/index.tsx | 82 ++++++----- .../src/pages/demos/ui-main-menu/index.tsx | 135 +++++++++++------- .../pages/demos/ui-nested-resize/index.tsx | 114 +++++++++++++-- .../src/pages/demos/ui-progress-bar/index.tsx | 64 +++++++-- 7 files changed, 437 insertions(+), 231 deletions(-) diff --git a/documentation-site/src/pages/demos/ui-anchors/index.tsx b/documentation-site/src/pages/demos/ui-anchors/index.tsx index dce53b345..c24fd0d23 100644 --- a/documentation-site/src/pages/demos/ui-anchors/index.tsx +++ b/documentation-site/src/pages/demos/ui-anchors/index.tsx @@ -4,8 +4,7 @@ import gameCode from '!!raw-loader!./_create-game'; import createAnchorPlaygroundCode from '!!raw-loader!./_create-anchor-playground'; import playgroundControlsCode from '!!raw-loader!./_PlaygroundControls'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; +import { DemoPage, DemoPanel } from '@site/src/components/demo-page'; import { AnchorPlayground, anchorPlaygroundDefaults, @@ -17,17 +16,6 @@ import { } from './_create-anchor-playground'; import { PlaygroundControls } from './_PlaygroundControls'; -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; - export default function UiAnchors(): JSX.Element { const playgroundRef = useRef(null); const [presetName, setPresetName] = useState( @@ -113,17 +101,11 @@ export default function UiAnchors(): JSX.Element { const { isStretchX, isStretchY } = getAnchorStretchAxes(presetName); return ( - + panels={ + - - -
- } - text="Toggle fullscreen to see the orange playground panel (and every reference panel) hold its anchor at a different aspect ratio." - /> - + } - codeFiles={[ - { name: 'game.ts', content: gameCode }, + highlights={[ + { + text: 'Each panel has an anchor that pins it to a corner or stretches it along an edge of the canvas.', + file: 'create-game.ts', + }, + { + text: 'The orange panel takes its anchor, position and size straight from the playground controls, written into its live rect transform.', + file: 'create-anchor-playground.ts', + }, + { + text: 'On a point axis the slider sets a fixed size; on a stretched axis it sets a margin from the anchored span.', + file: 'create-anchor-playground.ts', + }, + { + text: 'Layout is resolved every frame, so in fullscreen every panel still sits where its anchor says, at any aspect ratio.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Anchors and layout', to: '/docs/docs/ui/anchors-and-layout' }, + { label: 'Responsive UI', to: '/docs/docs/ui/responsive-ui' }, + { label: 'Creating a canvas', to: '/docs/docs/ui/creating-a-canvas' }, + ]} + fileGroups={[ { - name: 'create-anchor-playground.ts', - content: createAnchorPlaygroundCode, + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds the canvas, the five reference panels and the orange playground panel.', + content: gameCode, + }, + ], }, { - name: 'PlaygroundControls.tsx', - content: playgroundControlsCode, + title: 'Playground', + files: [ + { + name: 'create-anchor-playground.ts', + summary: + "The anchor presets and functions that write the controls into the panel's rect transform.", + content: createAnchorPlaygroundCode, + }, + { + name: 'PlaygroundControls.tsx', + summary: + 'The React preset picker and position and size sliders beside the game.', + content: playgroundControlsCode, + }, + ], }, ]} /> diff --git a/documentation-site/src/pages/demos/ui-button/index.tsx b/documentation-site/src/pages/demos/ui-button/index.tsx index abf8a40ea..e346392e5 100644 --- a/documentation-site/src/pages/demos/ui-button/index.tsx +++ b/documentation-site/src/pages/demos/ui-button/index.tsx @@ -2,58 +2,59 @@ import React, { JSX } from 'react'; import { createButtonGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; -import { KeyboardKey } from '@site/src/components/_KeyboardKey'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiButton(): JSX.Element { return ( - - - -
- } - text="Click a button." - /> - - - - - } - text="Move focus between buttons." - /> - } - text="Activate the focused button." - /> - - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: 'Press a button', + }, + { inputs: ['↑', '↓'], action: 'Move focus' }, + { inputs: ['Enter', 'Space'], action: 'Press the focused button' }, + ]} + highlights={[ + { + text: 'Each button comes from one createButton call: a sliced panel sprite, a label and an interactable component.', + file: 'create-game.ts', + }, + { + text: 'A click and Enter or Space on the focused button raise the same onInvoke event, which updates the status text.', + file: 'create-game.ts', + }, + { + text: 'The canvas takes a navigate and a submit input action, so the arrow keys move focus between buttons.', + file: 'create-game.ts', + }, + { + text: 'Hovering a button focuses it too, and its color eases between normal, hover and pressed tints.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { + label: 'Buttons and interaction', + to: '/docs/docs/ui/buttons-and-interaction', + }, + { label: 'Creating a canvas', to: '/docs/docs/ui/creating-a-canvas' }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Wires mouse and keyboard input, then builds three buttons and a status label.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-canvas-group/index.tsx b/documentation-site/src/pages/demos/ui-canvas-group/index.tsx index 5b63f3409..43985099f 100644 --- a/documentation-site/src/pages/demos/ui-canvas-group/index.tsx +++ b/documentation-site/src/pages/demos/ui-canvas-group/index.tsx @@ -2,42 +2,62 @@ import React, { JSX } from 'react'; import { createCanvasGroupGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiCanvasGroup(): JSX.Element { return ( - - - - } - text="Click 'Disable modal' to fade the whole card." - /> - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: "Toggle 'Disable modal'", + }, + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: 'Press Confirm', + detail: 'Does nothing while the modal is disabled', + }, + ]} + highlights={[ + { + text: 'One canvas group sits on the modal panel, and the toggle changes only that component.', + file: 'create-game.ts', + }, + { + text: 'The fade and disable reach every descendant: the nested card, its labels and the Confirm button two or three levels down.', + file: 'create-game.ts', + }, + { + text: 'While disabled, Confirm really ignores clicks, because the group turns off interactable and blocksRaycasts, not just alpha.', + file: 'create-game.ts', + }, + { + text: 'The toggle lives outside the group, so it stays opaque and clickable the whole time.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { + label: 'Hiding, fading and tooltips', + to: '/docs/docs/ui/canvas-groups-and-tooltips', + }, + { label: 'Controls', to: '/docs/docs/ui/controls' }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Builds the modal with its canvas group, the nested card and the toggle that disables it.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-dropdown/index.tsx b/documentation-site/src/pages/demos/ui-dropdown/index.tsx index 501cbd892..f78ecc5bf 100644 --- a/documentation-site/src/pages/demos/ui-dropdown/index.tsx +++ b/documentation-site/src/pages/demos/ui-dropdown/index.tsx @@ -2,42 +2,60 @@ import React, { JSX } from 'react'; import { createDropdownGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiDropdown(): JSX.Element { return ( - - - - } - text="Click the dropdown to open it, then pick an option." - /> - } - codeFiles={[{ name: 'game.ts', content: gameCode }]} + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click the header' }], + action: 'Open or close the list', + }, + { + inputs: [{ device: 'mouse', label: 'Click an option' }], + action: 'Select it', + }, + ]} + highlights={[ + { + text: 'One createDropdown call builds a header button showing the selected option, plus one hidden button per option below it.', + file: 'create-game.ts', + }, + { + text: 'Clicking the header shows or hides the list, and the chevron on its right edge flips to match.', + file: 'create-game.ts', + }, + { + text: "Picking an option updates the header's label, raises onValueChanged and closes the list.", + file: 'create-game.ts', + }, + { + text: 'Clicking outside the open list leaves it open; only the header or an option closes it.', + }, + ]} + docLinks={[ + { label: 'Controls', to: '/docs/docs/ui/controls' }, + { + label: 'Buttons and interaction', + to: '/docs/docs/ui/buttons-and-interaction', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Sets up mouse input and a UI canvas, then creates a four-option dropdown.', + content: gameCode, + }, + ], + }, + ]} /> ); } diff --git a/documentation-site/src/pages/demos/ui-main-menu/index.tsx b/documentation-site/src/pages/demos/ui-main-menu/index.tsx index ce3a45bca..84a509ab1 100644 --- a/documentation-site/src/pages/demos/ui-main-menu/index.tsx +++ b/documentation-site/src/pages/demos/ui-main-menu/index.tsx @@ -6,63 +6,92 @@ import missionBriefCode from '!!raw-loader!./_create-mission-brief'; import flagshipPanelCode from '!!raw-loader!./_create-flagship-panel'; import paletteCode from '!!raw-loader!./_palette'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; -import { KeyboardKey } from '@site/src/components/_KeyboardKey'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, -}; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiMainMenu(): JSX.Element { return ( - - - - - } - text="Click a menu row or the Deploy button." - /> - - - - - } - text="Move focus between menu rows." - /> - } - text="Invoke the focused row or button." - /> - - } - codeFiles={[ - { name: 'game.ts', content: gameCode }, - { name: 'create-main-menu.ts', content: mainMenuCode }, - { name: 'create-mission-brief.ts', content: missionBriefCode }, - { name: 'create-flagship-panel.ts', content: flagshipPanelCode }, - { name: 'palette.ts', content: paletteCode }, + controls={[ + { + inputs: [{ device: 'mouse', label: 'Click' }], + action: 'Choose a menu row or Deploy', + }, + { inputs: ['↑', '↓'], action: 'Move focus' }, + { inputs: ['Enter', 'Space'], action: 'Choose the focused item' }, + ]} + highlights={[ + { + text: 'Each menu row is a panel given the same interactable and color transition components a button uses, so it can hold an accent, an index and a title.', + file: 'create-main-menu.ts', + }, + { + text: 'A vertical layout group inside a content size fitter stacks the six rows, so no row needs layout math.', + file: 'create-main-menu.ts', + }, + { + text: 'Rows are transparent until hovered or focused; the first row starts focused, which is why it shows blue at rest.', + file: 'create-game.ts', + }, + { + text: 'The ship readout combines a progress bar for fleet strength with a Deploy button.', + file: 'create-flagship-panel.ts', + }, + { + text: 'The canvas uses the fitReferenceResolution scale mode, so the edge-to-edge layout never crops at any window shape.', + file: 'create-game.ts', + }, + ]} + docLinks={[ + { label: 'Layout groups', to: '/docs/docs/ui/layout-groups' }, + { + label: 'Buttons and interaction', + to: '/docs/docs/ui/buttons-and-interaction', + }, + { label: 'Responsive UI', to: '/docs/docs/ui/responsive-ui' }, + { label: 'Controls', to: '/docs/docs/ui/controls' }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: + 'Sets up input and the canvas, then builds the menu, brief and ship readout.', + content: gameCode, + }, + ], + }, + { + title: 'Building the screen', + files: [ + { + name: 'create-main-menu.ts', + summary: + 'Builds the left nav panel with its title, six numbered rows and footer.', + content: mainMenuCode, + }, + { + name: 'create-mission-brief.ts', + summary: + 'Lays out the backdrop circle, mission title and wrapped blurb.', + content: missionBriefCode, + }, + { + name: 'create-flagship-panel.ts', + summary: + 'Builds the ship placeholder, fleet strength meter and Deploy button.', + content: flagshipPanelCode, + }, + { + name: 'palette.ts', + summary: 'The shared colors every element on the screen uses.', + content: paletteCode, + }, + ], + }, ]} /> ); diff --git a/documentation-site/src/pages/demos/ui-nested-resize/index.tsx b/documentation-site/src/pages/demos/ui-nested-resize/index.tsx index 9abc74d5f..5bde9e055 100644 --- a/documentation-site/src/pages/demos/ui-nested-resize/index.tsx +++ b/documentation-site/src/pages/demos/ui-nested-resize/index.tsx @@ -1,31 +1,115 @@ import React, { JSX } from 'react'; import { createNestedResizeGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; +import createWindowFrameCode from '!!raw-loader!./_create-window-frame'; +import createTitleBarCode from '!!raw-loader!./_create-title-bar'; +import createOptionsContentCode from '!!raw-loader!./_create-options-content'; +import createCornerDecorationsCode from '!!raw-loader!./_create-corner-decorations'; +import loadDemoSpritesCode from '!!raw-loader!./_load-demo-sprites'; import liveMotionComponentCode from '!!raw-loader!./_live-motion.component'; import liveMotionSystemCode from '!!raw-loader!./_live-motion.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiNestedResize(): JSX.Element { return ( - diff --git a/documentation-site/src/pages/demos/ui-progress-bar/index.tsx b/documentation-site/src/pages/demos/ui-progress-bar/index.tsx index 753332682..7b7776fec 100644 --- a/documentation-site/src/pages/demos/ui-progress-bar/index.tsx +++ b/documentation-site/src/pages/demos/ui-progress-bar/index.tsx @@ -4,23 +4,61 @@ import gameCode from '!!raw-loader!./_create-game'; import pulseComponentCode from '!!raw-loader!./_pulse.component'; import pulseSystemCode from '!!raw-loader!./_pulse.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function UiProgressBar(): JSX.Element { return ( - ); From dc3fca4781236e4c5c9e6dc67aecda12fd4a5fde Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:25:20 +0000 Subject: [PATCH 09/34] docs(demos): fix FullscreenButton style import and move rendering demos to DemoPage Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TJaAau36yMnVzEdEhG6VpB --- .../components/demo-page/FullscreenButton.tsx | 2 +- .../pages/demos/easing-functions/index.tsx | 119 ++++++---- .../src/pages/demos/erosion-burn/index.tsx | 78 +++++-- .../src/pages/demos/masks/index.tsx | 97 ++++++-- .../src/pages/demos/nine-slice/index.tsx | 125 ++++++---- .../src/pages/demos/particles/index.tsx | 73 ++++-- .../src/pages/demos/stress-test/index.tsx | 81 +++++-- .../demos/text/_PlaygroundControls.module.css | 5 - .../src/pages/demos/text/index.tsx | 213 +++++++++++++----- .../pages/demos/texture-filtering/index.tsx | 56 +++-- 10 files changed, 609 insertions(+), 240 deletions(-) diff --git a/documentation-site/src/components/demo-page/FullscreenButton.tsx b/documentation-site/src/components/demo-page/FullscreenButton.tsx index 679b8bead..ac0931fcf 100644 --- a/documentation-site/src/components/demo-page/FullscreenButton.tsx +++ b/documentation-site/src/components/demo-page/FullscreenButton.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import clsx from 'clsx'; -import styles from './_FullscreenButton.module.css'; +import styles from './FullscreenButton.module.css'; interface FullscreenButtonProps { isFullscreen: boolean; diff --git a/documentation-site/src/pages/demos/easing-functions/index.tsx b/documentation-site/src/pages/demos/easing-functions/index.tsx index 3c70b3ede..f6f0bf183 100644 --- a/documentation-site/src/pages/demos/easing-functions/index.tsx +++ b/documentation-site/src/pages/demos/easing-functions/index.tsx @@ -5,59 +5,100 @@ import createEasingRowsCode from '!!raw-loader!./_create-easing-rows'; import easingRowComponentCode from '!!raw-loader!./_easing-row.component'; import easingRowSystemCode from '!!raw-loader!./_easing-row.system'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; +import { + DemoLegend, + DemoPage, + DemoPanel, +} from '@site/src/components/demo-page'; import { easingRowConfigs } from './_create-easing-rows'; export default function EasingFunctions(): JSX.Element { return ( - + ({ + marker: ( + + ), + label: name, + }))} + /> + + } + highlights={[ + { + text: 'Each lane sweeps a ball back and forth with a different easing function, so the curves can be compared side by side.', + file: 'create-easing-rows.ts', + }, { - name: 'game.ts', - content: gameCode, + text: 'A system turns time into a 0 to 1 ping-pong phase, eases it, and lerps the ball between the lane ends.', + file: 'easing-row.system.ts', }, { - name: 'create-easing-rows.ts', - content: createEasingRowsCode, + text: 'easeInBack, easeInOutBack and easeInOutElastic return values outside 0 to 1, so their balls briefly overshoot the lane.', + file: 'create-easing-rows.ts', }, + ]} + docLinks={[ { - name: 'easing-row.component.ts', - content: easingRowComponentCode, + label: 'Property animations', + to: '/docs/docs/animations/property-animations', }, { - name: 'easing-row.system.ts', - content: easingRowSystemCode, + label: 'Interpolation and smoothing', + to: '/docs/docs/math/interpolation-and-smoothing', + }, + ]} + fileGroups={[ + { + title: 'Start here', + files: [ + { + name: 'create-game.ts', + summary: 'Builds the easing lanes and registers the systems.', + content: gameCode, + }, + ], + }, + { + title: 'Building the scene', + files: [ + { + name: 'create-easing-rows.ts', + summary: + 'Lists the easing functions and builds a lane and ball for each.', + content: createEasingRowsCode, + }, + ], + }, + { + title: 'Easing', + files: [ + { + name: 'easing-row.component.ts', + summary: "A ball's easing function and the range it sweeps.", + content: easingRowComponentCode, + }, + { + name: 'easing-row.system.ts', + summary: + 'Moves each ball along its lane using its easing function.', + content: easingRowSystemCode, + }, + ], }, ]} - interactions={ - <> - {easingRowConfigs.map(({ name, color }) => ( - - } - text={name} - /> - ))} - - } /> ); } diff --git a/documentation-site/src/pages/demos/erosion-burn/index.tsx b/documentation-site/src/pages/demos/erosion-burn/index.tsx index 32eac1b0e..281c0930e 100644 --- a/documentation-site/src/pages/demos/erosion-burn/index.tsx +++ b/documentation-site/src/pages/demos/erosion-burn/index.tsx @@ -6,39 +6,77 @@ import erosionComponentCode from '!!raw-loader!./_erosion.component'; import erosionSystemCode from '!!raw-loader!./_erosion.system'; import erosionShaderCode from '!!raw-loader!./_erosion.shader'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function ErosionBurn(): JSX.Element { return ( - diff --git a/documentation-site/src/pages/demos/masks/index.tsx b/documentation-site/src/pages/demos/masks/index.tsx index 946c6ccf5..9dcd7f5be 100644 --- a/documentation-site/src/pages/demos/masks/index.tsx +++ b/documentation-site/src/pages/demos/masks/index.tsx @@ -7,26 +7,91 @@ import maskPulseSystemCode from '!!raw-loader!./_mask-pulse.system'; import scrollComponentCode from '!!raw-loader!./_scroll.component'; import scrollSystemCode from '!!raw-loader!./_scroll.system'; -import { Demo } from '@site/src/components/Demo'; +import { DemoPage } from '@site/src/components/demo-page'; export default function Masks(): JSX.Element { return ( - ); diff --git a/documentation-site/src/pages/demos/nine-slice/index.tsx b/documentation-site/src/pages/demos/nine-slice/index.tsx index 0783ef7df..ba6dd6173 100644 --- a/documentation-site/src/pages/demos/nine-slice/index.tsx +++ b/documentation-site/src/pages/demos/nine-slice/index.tsx @@ -5,64 +5,99 @@ import createPanelsCode from '!!raw-loader!./_create-panels'; import panelComponentCode from '!!raw-loader!./_panel.component'; import panelSystemCode from '!!raw-loader!./_panel.system'; -import { Demo } from '@site/src/components/Demo'; -import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; - -const badgeStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - width: 20, - height: 20, - borderRadius: '50%', - backgroundColor: 'var(--ifm-color-emphasis-300)', - fontSize: 12, - fontWeight: 700, -}; +import { + DemoLegend, + DemoPage, + DemoPanel, +} from '@site/src/components/demo-page'; export default function NineSlice(): JSX.Element { return ( - - 1} - text="Naive stretch (no slicing)" - /> - 2} - text="Nine-slice: stretch" + panels={ + +