From 54b496eb72184a5c8311d2e028894cff46238338 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:20:54 +0000 Subject: [PATCH 1/5] feat(ui): implement Phase 4 layout groups of the UI system design Adds LayoutElementEcsComponent, HorizontalLayoutGroupEcsComponent/ VerticalLayoutGroupEcsComponent, GridLayoutGroupEcsComponent, ContentSizeFitterEcsComponent, and AspectRatioFitterEcsComponent, arranged by createUiLayoutGroupEcsSystem (a two-pass, bottom-up-measure/top-down- arrange system) and createUiAspectRatioFitterEcsSystem, both registered automatically by createUiCanvas before createUiLayoutEcsSystem. Extends the UI demo with a "Difficulty" panel showcasing a horizontal layout group. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K9ju4fbCZYfAQmDhSBhRmH --- AGENTS.md | 2 +- CHANGELOG.md | 1 + documentation-site/docs/docs/ui/index.md | 97 ++- .../src/pages/demos/ui/_create-game.ts | 93 ++- .../src/pages/demos/ui/index.tsx | 2 +- .../aspect-ratio-fitter-component.test.ts | 32 + .../aspect-ratio-fitter-component.ts | 68 ++ .../content-size-fitter-component.test.ts | 32 + .../content-size-fitter-component.ts | 62 ++ src/ui/components/index.ts | 4 + .../layout-element-component.test.ts | 47 ++ src/ui/components/layout-element-component.ts | 95 +++ .../components/layout-group-component.test.ts | 107 +++ src/ui/components/layout-group-component.ts | 261 +++++++ src/ui/systems/index.ts | 2 + .../ui-aspect-ratio-fitter-system.test.ts | 122 +++ .../systems/ui-aspect-ratio-fitter-system.ts | 82 ++ src/ui/systems/ui-layout-group-system.test.ts | 311 ++++++++ src/ui/systems/ui-layout-group-system.ts | 721 ++++++++++++++++++ src/ui/types/index.ts | 1 + src/ui/types/ui-alignment.ts | 29 + src/ui/utilities/create-ui-canvas.ts | 27 +- 22 files changed, 2184 insertions(+), 14 deletions(-) create mode 100644 src/ui/components/aspect-ratio-fitter-component.test.ts create mode 100644 src/ui/components/aspect-ratio-fitter-component.ts create mode 100644 src/ui/components/content-size-fitter-component.test.ts create mode 100644 src/ui/components/content-size-fitter-component.ts create mode 100644 src/ui/components/layout-element-component.test.ts create mode 100644 src/ui/components/layout-element-component.ts create mode 100644 src/ui/components/layout-group-component.test.ts create mode 100644 src/ui/components/layout-group-component.ts create mode 100644 src/ui/systems/ui-aspect-ratio-fitter-system.test.ts create mode 100644 src/ui/systems/ui-aspect-ratio-fitter-system.ts create mode 100644 src/ui/systems/ui-layout-group-system.test.ts create mode 100644 src/ui/systems/ui-layout-group-system.ts create mode 100644 src/ui/types/ui-alignment.ts diff --git a/AGENTS.md b/AGENTS.md index 72400b9c..73f346cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,7 @@ Forge is a browser-based, code-only game engine built with TypeScript. It provid /rendering # Rendering system /text # MSDF font atlas loading and text rendering /timer # Timer utilities - /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns) + /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, layout groups, content size/aspect ratio fitters) /utilities # General utilities index.ts # Main exports diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b375c15..bffd1466 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **text:** Add exported `textHorizontalAlignments`/`TextHorizontalAlign` and `textVerticalAlignments`/`TextVerticalAlign` maps for `TextEcsComponent.horizontalAlign`/`verticalAlign`, matching the existing `uiScaleModes`/`mouseButtons`/`keyCodes` const-map convention, so callers reference `textHorizontalAlignments.center` instead of the raw string literal `'center'` - **ui:** Add controls: `UiToggleEcsComponent`/`UiToggleGroupEcsComponent`/`createUiToggleEcsSystem`/`createToggle` (checkboxes, and mutually-exclusive radio groups via a shared `UiToggleGroupEcsComponent`), `UiSliderEcsComponent`/`createUiSliderEcsSystem`/`createSlider` (a click-and-drag track with a handle and optional fill, `minValue`/`maxValue`/`wholeNumbers`), `UiProgressBarEcsComponent`/`createUiProgressBarEcsSystem`/`createProgressBar` (a read-only linear fill indicator driven by `value`, with no interaction dependency so a `value` write is reflected the same frame), and `UiDropdownEcsComponent`/`createDropdown` (a header showing the selected option plus a click-to-open list of option rows, each an ordinary `createButton`). See the new UI doc's "Controls" section and the updated demo - **ui:** Add `UiAnchor.stretchHorizontalLeft`/`stretchTopLeft`, left-pivoted variants of `stretchHorizontal`/`stretchTop` for a `TextEcsComponent` label that needs to stay centered via `horizontalAlign`/`maxWidth` (a center-pivoted anchor's local x = 0 sits at the box's middle, not its left edge, which is what that alignment box is actually measured from). `createUiLayoutEcsSystem` now also keeps a stretch-x-anchored (`anchorMin.x !== anchorMax.x`) text entity's `maxWidth` in sync with its resolved rect's width every frame, so a label anchored this way centers correctly with no caller-side measurement, even against a dynamically-sized parent (e.g. a title in a full-width top bar) or text that changes later. See the UI doc's updated "Labels" section +- **ui:** Add layout groups: `LayoutElementEcsComponent`/`addLayoutElementComponent` (min/preferred/flexible size overrides, plus `ignoreLayout`), `HorizontalLayoutGroupEcsComponent`/`VerticalLayoutGroupEcsComponent` (`addHorizontalLayoutGroupComponent`/`addVerticalLayoutGroupComponent`) and `GridLayoutGroupEcsComponent`/`addGridLayoutGroupComponent`, all arranged by the new `createUiLayoutGroupEcsSystem` - a two-pass, bottom-up-measure/top-down-arrange system that resizes and positions a group's direct children (nesting freely, including a group measuring another group's own content), plus `ContentSizeFitterEcsComponent`/`addContentSizeFitterComponent` (shrink-wraps an entity to its own measured content) and `AspectRatioFitterEcsComponent`/`addAspectRatioFitterComponent`/`createUiAspectRatioFitterEcsSystem` (`widthControlsHeight`/`heightControlsWidth`/`fitInParent`/`envelopeParent`). `createUiCanvas` registers both new systems automatically, before `createUiLayoutEcsSystem`. See the new UI doc's "Layout groups" section #### Changed diff --git a/documentation-site/docs/docs/ui/index.md b/documentation-site/docs/docs/ui/index.md index ceb177d2..78b7b130 100644 --- a/documentation-site/docs/docs/ui/index.md +++ b/documentation-site/docs/docs/ui/index.md @@ -14,9 +14,10 @@ functions the same way any other composite entity in Forge is. :::info Current scope Layout (anchors, canvases, panels, labels), interaction (buttons, -hover/press/drag, gamepad/keyboard focus navigation, color transitions), and -controls (toggles, sliders, progress bars, dropdowns) are implemented. Scroll -views, text input, rect clipping, and layout groups aren't yet. +hover/press/drag, gamepad/keyboard focus navigation, color transitions), +controls (toggles, sliders, progress bars, dropdowns), and layout groups +(horizontal/vertical/grid, content size fitting, aspect ratio fitting) are +implemented. Scroll views, text input, and rect clipping aren't yet. ::: ## Quick start @@ -458,6 +459,96 @@ doesn't close it - only clicking the header again or selecting an option does; register your own listener (e.g. gated on `dropdown.isOpen`) if your game needs that. +## Layout groups + +Every element seen so far is positioned manually - an explicit anchor and +`anchoredPosition`/`sizeDelta`. A layout group instead arranges its own +direct children automatically, recomputing every frame just like +`createUiLayoutEcsSystem` itself does: + +```ts +import { + addVerticalLayoutGroupComponent, + createButton, + createPanel, + uiAlignments, + UiAnchor, +} from '@forge-game-engine/forge/ui'; + +const menu = createPanel(world, canvas, { + anchor: UiAnchor.center, + sizeDelta: { x: 320, y: 400 }, + sprite: panelSprite, +}); + +addVerticalLayoutGroupComponent(world, menu, { + padding: { left: 24, right: 24, top: 24, bottom: 24 }, + spacing: 16, + childAlignment: uiAlignments.topCenter, +}); + +// createUiLayoutGroupEcsSystem (registered automatically by createUiCanvas) +// resizes and stacks every direct child added below - no anchor/sizeDelta +// of its own needed. +createButton(world, menu, { sprite: buttonSprite, label: 'Play', fontAtlas }); +createButton(world, menu, { sprite: buttonSprite, label: 'Options', fontAtlas }); +createButton(world, menu, { sprite: buttonSprite, label: 'Quit', fontAtlas }); +``` + +[`addHorizontalLayoutGroupComponent`](/Forge/docs/api/functions/addHorizontalLayoutGroupComponent)/ +[`addVerticalLayoutGroupComponent`](/Forge/docs/api/functions/addVerticalLayoutGroupComponent) +arrange direct children left-to-right/top-to-bottom, resizing each one (per +`childControlWidth`/`childControlHeight`) to its measured preferred size - +its own `RectTransformEcsComponent.sizeDelta`, unless overridden by a +[`LayoutElementEcsComponent`](/Forge/docs/api/interfaces/LayoutElementEcsComponent) +(`minWidth`/`minHeight`/`preferredWidth`/`preferredHeight`/`flexibleWidth`/ +`flexibleHeight`) - plus, by default (`childForceExpandWidth`/ +`childForceExpandHeight`), stretching every child to fill the whole cross +axis and distributing any leftover main-axis space, weighted by +`flexibleWidth`/`flexibleHeight` (or evenly, with none set). `childAlignment` +(see [`uiAlignments`](/Forge/docs/api/variables/uiAlignments), named the same +way as `UiAnchor`'s nine point presets) places the child block within any +leftover main-axis space, and aligns each child individually within the +cross axis. A child with `LayoutElementEcsComponent.ignoreLayout: true` is +skipped entirely - useful for a decorative element (a background flourish, a +badge) placed inside an otherwise-arranged panel. + +[`addGridLayoutGroupComponent`](/Forge/docs/api/functions/addGridLayoutGroupComponent) +arranges direct children into fixed-size `cellSize` cells instead of +measuring them - `constraint` picks whether the column count is derived from +the content box's width (`flexible`, the default) or held fixed +(`fixedColumnCount`/`fixedRowCount`), and `startCorner`/`startAxis` control +placement order. + +Layout groups nest: a `VerticalLayoutGroupEcsComponent`'s own measured +content size (used when a parent group, or a `ContentSizeFitterEcsComponent`, +asks) comes from recursively measuring its own children, so a horizontal row +of buttons can itself be one "row" inside an outer vertical group. + +[`addContentSizeFitterComponent`](/Forge/docs/api/functions/addContentSizeFitterComponent) +resizes its own entity's `sizeDelta` to match its measured content on each +axis (`unconstrained` leaves that axis alone; `minSize`/`preferredSize` fit +to it) - pair it with a layout group on the same entity to make a panel +shrink-wrap its arranged children, rather than the fixed size `createPanel` +was given. + +[`addAspectRatioFitterComponent`](/Forge/docs/api/functions/addAspectRatioFitterComponent) +keeps an entity's `sizeDelta` at a constant width-to-height ratio - +`widthControlsHeight`/`heightControlsWidth` derive one axis from the other; +`fitInParent`/`envelopeParent` derive both from the parent's own resolved +rect, useful for a thumbnail or minimap that shouldn't stretch with its +container. + +Every layout group/fitter runs in `createUiLayoutGroupEcsSystem`/ +`createUiAspectRatioFitterEcsSystem`, registered automatically by +`createUiCanvas` *before* `createUiLayoutEcsSystem` - both read +`RectTransformEcsComponent.rect` as it stood at the end of the previous +frame (the same rect `createUiLayoutEcsSystem` is about to recompute this +tick), so a group whose own size just changed (a fresh entity, a nested +group, a content size fitter reacting to a resized child) arranges its +children against a one-frame-stale box. Like the rest of this module, this +converges within a frame or two rather than being tracked with dirty state. + ## Known limitations - **No scroll views or text input yet.** Both are blocked on rect clipping diff --git a/documentation-site/src/pages/demos/ui/_create-game.ts b/documentation-site/src/pages/demos/ui/_create-game.ts index 79158709..b4cd650b 100644 --- a/documentation-site/src/pages/demos/ui/_create-game.ts +++ b/documentation-site/src/pages/demos/ui/_create-game.ts @@ -1,4 +1,5 @@ import { + addParentComponent, addPositionComponent, createTransformEcsSystem, Time, @@ -37,6 +38,8 @@ import { textVerticalAlignments, } from '@forge-game-engine/forge/text'; import { + addHorizontalLayoutGroupComponent, + addRectTransformComponent, createButton, createDropdown, createLabel, @@ -45,6 +48,7 @@ import { createSlider, createToggle, createUiCanvas, + uiAlignments, UiAnchor, UiProgressBarEcsComponent, } from '@forge-game-engine/forge/ui'; @@ -341,14 +345,97 @@ async function createSettingsPanel( return healthBar.progressBar; } +/** + * Builds a "Difficulty" panel showcasing `addHorizontalLayoutGroupComponent` + * (Phase 4 of the UI design): unlike every other control in this demo, the + * three buttons below get no `anchoredPosition`/`sizeDelta` of their own at + * all - `createUiLayoutGroupEcsSystem` spaces and evenly resizes them every + * frame purely from the row entity's `HorizontalLayoutGroupEcsComponent`. + */ +function createLayoutGroupPanel( + world: EcsWorld, + canvas: number, + fontAtlas: FontAtlas, + panelSprite: SpriteEcsComponent, +): void { + const panel = createPanel(world, canvas, { + // Stacked below the score panel, in the same left column - unlike the + // bottom-center cluster (Play button, click counter), this spot's + // horizontal clearance barely changes as the canvas's aspect ratio + // narrows (scaleWithScreenSize keeps height pinned; only width + // shrinks), since nothing else anchors to the top-left. + anchor: UiAnchor.topLeft, + anchoredPosition: { x: 20, y: -250 }, + sizeDelta: { x: 300, y: 90 }, + sprite: panelSprite, + }); + + createLabel(world, panel, { + text: 'Difficulty', + fontAtlas, + size: 20, + anchor: UiAnchor.stretchTopLeft, + // See the "Settings" label's own comment above for why this is needed. + sizeDelta: { x: 0, y: 0 }, + anchoredPosition: { x: 0, y: -16 }, + horizontalAlign: textHorizontalAlignments.center, + verticalAlign: textVerticalAlignments.middle, + color: textColor, + category: renderLayers.ui, + }); + + // A plain RectTransform - no sprite, so it draws nothing itself - purely + // to give the HorizontalLayoutGroupEcsComponent below a content box (the + // panel's own width, minus a small margin) to arrange the three buttons + // within. + const row = world.createEntity(); + + addPositionComponent(world, row); + addParentComponent(world, row, { parent: panel }); + addRectTransformComponent(world, row, { + ...UiAnchor.stretchBottom, + sizeDelta: { x: -20, y: 36 }, + anchoredPosition: { x: 0, y: 8 }, + }); + addHorizontalLayoutGroupComponent(world, row, { + spacing: 8, + childAlignment: uiAlignments.center, + }); + + const buttonTransition = { + normalColor: Color.white, + hoverColor: new Color(0.85, 0.85, 0.85, 1), + pressedColor: new Color(0.65, 0.65, 0.65, 1), + disabledColor: new Color(0.5, 0.5, 0.5, 0.6), + }; + + for (const label of ['Easy', 'Medium', 'Hard']) { + createButton(world, row, { + sprite: panelSprite, + label, + fontAtlas, + labelSize: 16, + labelColor: textColor, + labelCategory: renderLayers.ui, + // Deliberately narrower than the row's own content box - the + // HorizontalLayoutGroupEcsComponent's default childForceExpandWidth + // stretches all three evenly to fill the remaining space, which a + // sizeDelta already matching the box exactly wouldn't demonstrate. + sizeDelta: { x: 60, y: 32 }, + transition: buttonTransition, + }); + } +} + /** * Builds the UI interaction demo: a "game world" (a plain tinted backdrop, * drawn by its own camera/culling mask) with a HUD overlaid on top of it * through a second, dedicated UI camera - a full-width top bar, a - * corner-anchored score panel, and a hoverable, clickable, keyboard/gamepad- + * corner-anchored score panel, a hoverable, clickable, keyboard/gamepad- * focus-navigable `Play` button (see `createButton`) that increments a * click counter on `onInvoke`, whichever path raised it (pointer or - * `submitInput`). + * `submitInput`), a "Settings" panel showcasing the Phase 3 controls, and a + * "Difficulty" panel showcasing `addHorizontalLayoutGroupComponent`. * @param fontAtlasUrl - The URL of the font atlas JSON to load. * @returns The created game. */ @@ -461,6 +548,8 @@ export const createUiDemoGame = async (fontAtlasUrl: string): Promise => { panelSprite, ); + createLayoutGroupPanel(world, canvas, fontAtlas, panelSprite); + let clickCount = 0; const clickLabelWidth = 400; diff --git a/documentation-site/src/pages/demos/ui/index.tsx b/documentation-site/src/pages/demos/ui/index.tsx index 4e24a477..209b0ffb 100644 --- a/documentation-site/src/pages/demos/ui/index.tsx +++ b/documentation-site/src/pages/demos/ui/index.tsx @@ -34,7 +34,7 @@ export default function Ui(): JSX.Element { 'A HUD built with the ui module: a full-width top bar, a corner-anchored score panel, and a hoverable, clickable, keyboard/gamepad-focus-navigable Play button, layered over a game world through a dedicated UI camera.', }} header="UI" - blurb="A dedicated, static UI camera (createUiCanvas) composites a HUD over the tinted 'game world' backdrop drawn by an ordinary world camera - the two are isolated from each other by culling mask, so the HUD is never drawn twice and never shows up in the world. The top bar and score panel resolve their layout fresh every frame from the canvas's current aspect ratio, so toggling fullscreen keeps both exactly where they should be. The Play button (createButton) is fully interactive: click it, or focus-navigate to it with the arrow keys and press Enter/Space - either path raises the same onInvoke, which increments the click counter below it. Its color eases between normal/hover/pressed tints via createUiTransitionEcsSystem, and hovering it also focuses it, so the highlight follows the mouse the same way it follows the keyboard." + blurb="A dedicated, static UI camera (createUiCanvas) composites a HUD over the tinted 'game world' backdrop drawn by an ordinary world camera - the two are isolated from each other by culling mask, so the HUD is never drawn twice and never shows up in the world. The top bar and score panel resolve their layout fresh every frame from the canvas's current aspect ratio, so toggling fullscreen keeps both exactly where they should be. The Play button (createButton) is fully interactive: click it, or focus-navigate to it with the arrow keys and press Enter/Space - either path raises the same onInvoke, which increments the click counter below it. Its color eases between normal/hover/pressed tints via createUiTransitionEcsSystem, and hovering it also focuses it, so the highlight follows the mouse the same way it follows the keyboard. The 'Difficulty' panel below the score panel shows a HorizontalLayoutGroupEcsComponent spacing and evenly resizing three buttons automatically, with no anchoredPosition/sizeDelta bookkeeping of their own." createGame={createGame} interactions={ <> diff --git a/src/ui/components/aspect-ratio-fitter-component.test.ts b/src/ui/components/aspect-ratio-fitter-component.test.ts new file mode 100644 index 00000000..9acc143a --- /dev/null +++ b/src/ui/components/aspect-ratio-fitter-component.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + addAspectRatioFitterComponent, + aspectRatioFitterId, +} from './aspect-ratio-fitter-component.js'; +import { EcsWorld } from '../../ecs/index.js'; + +describe('addAspectRatioFitterComponent', () => { + it('defaults aspectMode and aspectRatio', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addAspectRatioFitterComponent(world, entity); + + expect(component.aspectMode).toBe('widthControlsHeight'); + expect(component.aspectRatio).toBe(1); + expect(world.getComponent(entity, aspectRatioFitterId)).toBe(component); + }); + + it('accepts overrides', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addAspectRatioFitterComponent(world, entity, { + aspectMode: 'fitInParent', + aspectRatio: 16 / 9, + }); + + expect(component.aspectMode).toBe('fitInParent'); + expect(component.aspectRatio).toBeCloseTo(16 / 9); + }); +}); diff --git a/src/ui/components/aspect-ratio-fitter-component.ts b/src/ui/components/aspect-ratio-fitter-component.ts new file mode 100644 index 00000000..4e1a73e5 --- /dev/null +++ b/src/ui/components/aspect-ratio-fitter-component.ts @@ -0,0 +1,68 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; + +/** + * How `AspectRatioFitterEcsComponent` keeps `aspectRatio`: + * - `widthControlsHeight` - `sizeDelta.y` is derived from `sizeDelta.x`. + * - `heightControlsWidth` - `sizeDelta.x` is derived from `sizeDelta.y`. + * - `fitInParent` - `sizeDelta` is the largest size, matching `aspectRatio`, + * that fits entirely within the parent's rect. + * - `envelopeParent` - `sizeDelta` is the smallest size, matching + * `aspectRatio`, that fully covers the parent's rect. + */ +export type UiAspectRatioFitMode = + | 'widthControlsHeight' + | 'heightControlsWidth' + | 'fitInParent' + | 'envelopeParent'; + +/** + * Fields of {@link AspectRatioFitterEcsComponent} with a sensible default; + * callers may omit these. + */ +export interface AspectRatioFitterDefaultedOptions { + /** How the aspect ratio is enforced. */ + aspectMode: UiAspectRatioFitMode; + + /** The width-to-height ratio to maintain, e.g. `16 / 9`. */ + aspectRatio: number; +} + +export type AspectRatioFitterEcsComponent = AspectRatioFitterDefaultedOptions; + +export const aspectRatioFitterId = + createComponentId('aspectRatioFitter'); + +const defaultAspectRatioFitterOptions: AspectRatioFitterDefaultedOptions = { + aspectMode: 'widthControlsHeight', + aspectRatio: 1, +}; + +/** + * Attaches an {@link AspectRatioFitterEcsComponent} to `entity`, so + * `createUiAspectRatioFitterEcsSystem` keeps its + * `RectTransformEcsComponent.sizeDelta` at a constant `aspectRatio` every + * frame - useful for a portrait/thumbnail image or a minimap whose + * container might otherwise stretch it. `fitInParent`/`envelopeParent` read + * the parent's own resolved `rect` (one frame stale, like every other + * cross-entity read in this module - see `createUiLayoutEcsSystem`'s own + * doc comment) - `entity` needs a `ParentEcsComponent` for those two modes. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. Assumes a + * point-anchored `RectTransformEcsComponent` (`sizeDelta` is a literal + * size, not a stretch margin). + * @param options - Options for configuring the fitter. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addAspectRatioFitterComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): AspectRatioFitterEcsComponent { + const component: AspectRatioFitterEcsComponent = { + ...defaultAspectRatioFitterOptions, + ...options, + }; + + return world.addComponent(entity, aspectRatioFitterId, component); +} diff --git a/src/ui/components/content-size-fitter-component.test.ts b/src/ui/components/content-size-fitter-component.test.ts new file mode 100644 index 00000000..62d6cc40 --- /dev/null +++ b/src/ui/components/content-size-fitter-component.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + addContentSizeFitterComponent, + contentSizeFitterId, +} from './content-size-fitter-component.js'; +import { EcsWorld } from '../../ecs/index.js'; + +describe('addContentSizeFitterComponent', () => { + it('defaults both fit modes to unconstrained', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addContentSizeFitterComponent(world, entity); + + expect(component.horizontalFit).toBe('unconstrained'); + expect(component.verticalFit).toBe('unconstrained'); + expect(world.getComponent(entity, contentSizeFitterId)).toBe(component); + }); + + it('accepts overrides', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addContentSizeFitterComponent(world, entity, { + horizontalFit: 'preferredSize', + verticalFit: 'minSize', + }); + + expect(component.horizontalFit).toBe('preferredSize'); + expect(component.verticalFit).toBe('minSize'); + }); +}); diff --git a/src/ui/components/content-size-fitter-component.ts b/src/ui/components/content-size-fitter-component.ts new file mode 100644 index 00000000..cc0f3c66 --- /dev/null +++ b/src/ui/components/content-size-fitter-component.ts @@ -0,0 +1,62 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; + +/** + * How `ContentSizeFitterEcsComponent` sizes one axis of its own + * `RectTransformEcsComponent.sizeDelta`. + */ +export type UiContentSizeFitMode = + 'unconstrained' | 'minSize' | 'preferredSize'; + +/** + * Fields of {@link ContentSizeFitterEcsComponent} with a sensible default; + * callers may omit these. + */ +export interface ContentSizeFitterDefaultedOptions { + /** How `sizeDelta.x` is fit. `unconstrained` (the default) leaves it alone. */ + horizontalFit: UiContentSizeFitMode; + + /** How `sizeDelta.y` is fit. `unconstrained` (the default) leaves it alone. */ + verticalFit: UiContentSizeFitMode; +} + +export type ContentSizeFitterEcsComponent = ContentSizeFitterDefaultedOptions; + +export const contentSizeFitterId = + createComponentId('contentSizeFitter'); + +const defaultContentSizeFitterOptions: ContentSizeFitterDefaultedOptions = { + horizontalFit: 'unconstrained', + verticalFit: 'unconstrained', +}; + +/** + * Attaches a {@link ContentSizeFitterEcsComponent} to `entity`, resizing its + * `RectTransformEcsComponent.sizeDelta` every frame (via + * `createUiLayoutGroupEcsSystem`, alongside its layout-group handling) to + * match `entity`'s own measured content size on each configured axis - the + * same min/preferred size a `HorizontalLayoutGroupEcsComponent`/ + * `VerticalLayoutGroupEcsComponent`/`GridLayoutGroupEcsComponent` on + * `entity` would report to a parent group, or `entity`'s own + * `LayoutElementEcsComponent` overrides. With neither, there's no content to + * measure and this is a no-op - pair it with one of those, most commonly a + * layout group, to make a panel shrink-wrap its arranged children. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. Assumes a + * point-anchored `RectTransformEcsComponent` (`sizeDelta` is a literal + * size, not a stretch margin). + * @param options - Options for configuring the fitter. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addContentSizeFitterComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): ContentSizeFitterEcsComponent { + const component: ContentSizeFitterEcsComponent = { + ...defaultContentSizeFitterOptions, + ...options, + }; + + return world.addComponent(entity, contentSizeFitterId, component); +} diff --git a/src/ui/components/index.ts b/src/ui/components/index.ts index cb0232e1..5d88a882 100644 --- a/src/ui/components/index.ts +++ b/src/ui/components/index.ts @@ -1,4 +1,8 @@ +export * from './aspect-ratio-fitter-component.js'; export * from './canvas-component.js'; +export * from './content-size-fitter-component.js'; +export * from './layout-element-component.js'; +export * from './layout-group-component.js'; export * from './rect-transform-component.js'; export * from './ui-color-transition-component.js'; export * from './ui-dropdown-component.js'; diff --git a/src/ui/components/layout-element-component.test.ts b/src/ui/components/layout-element-component.test.ts new file mode 100644 index 00000000..3dc1b96c --- /dev/null +++ b/src/ui/components/layout-element-component.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + addLayoutElementComponent, + layoutElementId, +} from './layout-element-component.js'; +import { EcsWorld } from '../../ecs/index.js'; + +describe('addLayoutElementComponent', () => { + it('defaults ignoreLayout to false and leaves size fields undefined', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addLayoutElementComponent(world, entity); + + expect(component.ignoreLayout).toBe(false); + expect(component.minWidth).toBeUndefined(); + expect(component.minHeight).toBeUndefined(); + expect(component.preferredWidth).toBeUndefined(); + expect(component.preferredHeight).toBeUndefined(); + expect(component.flexibleWidth).toBeUndefined(); + expect(component.flexibleHeight).toBeUndefined(); + expect(world.getComponent(entity, layoutElementId)).toBe(component); + }); + + it('accepts overrides', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addLayoutElementComponent(world, entity, { + ignoreLayout: true, + minWidth: 10, + minHeight: 20, + preferredWidth: 100, + preferredHeight: 50, + flexibleWidth: 1, + flexibleHeight: 2, + }); + + expect(component.ignoreLayout).toBe(true); + expect(component.minWidth).toBe(10); + expect(component.minHeight).toBe(20); + expect(component.preferredWidth).toBe(100); + expect(component.preferredHeight).toBe(50); + expect(component.flexibleWidth).toBe(1); + expect(component.flexibleHeight).toBe(2); + }); +}); diff --git a/src/ui/components/layout-element-component.ts b/src/ui/components/layout-element-component.ts new file mode 100644 index 00000000..21441036 --- /dev/null +++ b/src/ui/components/layout-element-component.ts @@ -0,0 +1,95 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; + +/** + * Fields of {@link LayoutElementEcsComponent} with a sensible default; + * callers may omit these. + */ +export interface LayoutElementDefaultedOptions { + /** + * Excludes this entity from its parent's layout group entirely - the + * group neither measures it (its size doesn't count toward the group's + * own preferred/min size) nor arranges it (its `RectTransformEcsComponent` + * is left untouched). Useful for a decorative child (a background flourish, + * a badge overlay) placed inside a panel a `HorizontalLayoutGroupEcsComponent`/ + * `VerticalLayoutGroupEcsComponent`/`GridLayoutGroupEcsComponent` also + * arranges. Defaults to `false`. + */ + ignoreLayout: boolean; +} + +/** + * ECS-style component interface overriding a UI element's measured size for + * `createUiLayoutGroupEcsSystem` and `ContentSizeFitterEcsComponent`. Every + * field but `ignoreLayout` is optional - omitted, a group falls back to this + * element's current `RectTransformEcsComponent.sizeDelta` as its preferred + * size, with a min of `0` and a flexible weight of `0` (fixed size, taking + * no share of any leftover space in a force-expanded group). Add this + * component only to override that default on a per-field basis; a plain + * `RectTransformEcsComponent` with no `LayoutElementEcsComponent` at all is + * still a perfectly valid, fixed-size layout group child. + */ +export interface LayoutElementEcsComponent extends LayoutElementDefaultedOptions { + /** Overrides the smallest width a group will ever shrink this element to. */ + minWidth?: number; + + /** Overrides the smallest height a group will ever shrink this element to. */ + minHeight?: number; + + /** + * Overrides this element's natural width, before any group distributes + * leftover space. + */ + preferredWidth?: number; + + /** + * Overrides this element's natural height, before any group distributes + * leftover space. + */ + preferredHeight?: number; + + /** + * This element's share of a force-expanding group's leftover horizontal + * space, relative to its siblings' own `flexibleWidth` - e.g. `2` takes + * twice the leftover space of a sibling with `1`. `0` (the default) takes + * none. + */ + flexibleWidth?: number; + + /** + * This element's share of a force-expanding group's leftover vertical + * space, relative to its siblings' own `flexibleHeight`. `0` (the default) + * takes none. + */ + flexibleHeight?: number; +} + +export const layoutElementId = + createComponentId('layoutElement'); + +const defaultLayoutElementOptions: LayoutElementDefaultedOptions = { + ignoreLayout: false, +}; + +/** + * Attaches a {@link LayoutElementEcsComponent} to `entity`, overriding how + * `createUiLayoutGroupEcsSystem` measures and (if `childControlWidth`/ + * `childControlHeight` is enabled on the parent group) resizes it. Every + * field is optional - pass only the ones you need to override. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - Options for configuring the layout element. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addLayoutElementComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): LayoutElementEcsComponent { + const component: LayoutElementEcsComponent = { + ...defaultLayoutElementOptions, + ...options, + }; + + return world.addComponent(entity, layoutElementId, component); +} diff --git a/src/ui/components/layout-group-component.test.ts b/src/ui/components/layout-group-component.test.ts new file mode 100644 index 00000000..65d18063 --- /dev/null +++ b/src/ui/components/layout-group-component.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { + addGridLayoutGroupComponent, + addHorizontalLayoutGroupComponent, + addVerticalLayoutGroupComponent, + gridLayoutGroupId, + uiAxisLayoutGroupId, +} from './layout-group-component.js'; +import { uiAlignments } from '../types/ui-alignment.js'; +import { EcsWorld } from '../../ecs/index.js'; + +describe('addHorizontalLayoutGroupComponent', () => { + it('defaults its fields and sets direction to horizontal', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addHorizontalLayoutGroupComponent(world, entity); + + expect(component.direction).toBe('horizontal'); + expect(component.padding).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); + expect(component.spacing).toBe(0); + expect(component.childAlignment).toEqual(uiAlignments.topLeft); + expect(component.childControlWidth).toBe(true); + expect(component.childControlHeight).toBe(true); + expect(component.childForceExpandWidth).toBe(true); + expect(component.childForceExpandHeight).toBe(true); + expect(world.getComponent(entity, uiAxisLayoutGroupId)).toBe(component); + }); + + it('accepts overrides without mutating the shared alignment preset', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addHorizontalLayoutGroupComponent(world, entity, { + spacing: 16, + childAlignment: uiAlignments.center, + childControlWidth: false, + }); + + component.childAlignment.x = 0.9; + + expect(component.spacing).toBe(16); + expect(component.childControlWidth).toBe(false); + expect(uiAlignments.center).toEqual({ x: 0.5, y: 0.5 }); + }); +}); + +describe('addVerticalLayoutGroupComponent', () => { + it('sets direction to vertical', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addVerticalLayoutGroupComponent(world, entity); + + expect(component.direction).toBe('vertical'); + expect(world.getComponent(entity, uiAxisLayoutGroupId)).toBe(component); + }); + + it("doesn't share a padding object between two entities", () => { + const world = new EcsWorld(); + const a = addVerticalLayoutGroupComponent(world, world.createEntity()); + const b = addVerticalLayoutGroupComponent(world, world.createEntity()); + + a.padding.left = 42; + + expect(b.padding.left).toBe(0); + }); +}); + +describe('addGridLayoutGroupComponent', () => { + it('defaults its fields', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + const component = addGridLayoutGroupComponent(world, entity); + + expect(component.padding).toEqual({ left: 0, right: 0, top: 0, bottom: 0 }); + expect(component.cellSize).toEqual({ x: 100, y: 100 }); + expect(component.spacing).toEqual({ x: 0, y: 0 }); + expect(component.childAlignment).toEqual(uiAlignments.topLeft); + expect(component.startCorner).toBe('upperLeft'); + expect(component.startAxis).toBe('horizontal'); + expect(component.constraint).toBe('flexible'); + expect(component.constraintCount).toBe(1); + expect(world.getComponent(entity, gridLayoutGroupId)).toBe(component); + }); + + it('accepts overrides and clones cellSize/spacing', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + const cellSize = { x: 64, y: 32 }; + + const component = addGridLayoutGroupComponent(world, entity, { + cellSize, + spacing: { x: 4, y: 8 }, + constraint: 'fixedColumnCount', + constraintCount: 3, + }); + + component.cellSize.x = 999; + + expect(cellSize.x).toBe(64); + expect(component.constraint).toBe('fixedColumnCount'); + expect(component.constraintCount).toBe(3); + expect(component.spacing).toEqual({ x: 4, y: 8 }); + }); +}); diff --git a/src/ui/components/layout-group-component.ts b/src/ui/components/layout-group-component.ts new file mode 100644 index 00000000..53c332e2 --- /dev/null +++ b/src/ui/components/layout-group-component.ts @@ -0,0 +1,261 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { Vec2, Vector2 } from '../../math/index.js'; +import { UiAlignment, uiAlignments } from '../types/ui-alignment.js'; + +/** + * Inset, in reference pixels, between a layout group's own resolved rect and + * the content box its children are measured/arranged within. + */ +export interface UiLayoutGroupPadding { + left: number; + right: number; + top: number; + bottom: number; +} + +const zeroPadding: UiLayoutGroupPadding = { + left: 0, + right: 0, + top: 0, + bottom: 0, +}; + +/** + * Clones a padding value. `zeroPadding` and every `UiAlignment` preset are + * shared, module-level objects - cloning keeps one entity's group from + * mutating another's (or a shared preset's) padding/alignment in place. + */ +function clonePadding(padding: UiLayoutGroupPadding): UiLayoutGroupPadding { + return { ...padding }; +} + +/** + * Fields shared by `HorizontalLayoutGroupEcsComponent` and + * `VerticalLayoutGroupEcsComponent`, with a sensible default; callers may + * omit these. + */ +export interface UiAxisLayoutGroupDefaultedOptions { + /** Inset between this group's rect and the content box its children fill. */ + padding: UiLayoutGroupPadding; + + /** Gap, in reference pixels, between adjacent children along the main axis. */ + spacing: number; + + /** + * Where children (as a block, along the main axis; individually, along + * the cross axis) sit within any leftover space after their own sizes and + * spacing are subtracted from the content box. See `uiAlignments` for + * presets. + */ + childAlignment: UiAlignment; + + /** + * Whether this group resizes each child's width - to its measured + * preferred size, plus a share of any leftover horizontal space if + * `childForceExpandWidth` is also set. `false` leaves a child's own + * width untouched; the group still positions it. + */ + childControlWidth: boolean; + + /** The height equivalent of `childControlWidth`. */ + childControlHeight: boolean; + + /** + * With `childControlWidth` also set: on the main axis (a horizontal + * group's width), distributes any leftover space - content box width + * minus the sum of every child's own preferred width and inter-child + * spacing - across children, weighted by each child's + * `LayoutElementEcsComponent.flexibleWidth` (or evenly, if none of them + * set one). On the cross axis (a vertical group's width), simply + * stretches every child to the content box's full width, ignoring its + * own preferred width entirely. + */ + childForceExpandWidth: boolean; + + /** The height equivalent of `childForceExpandWidth` (main axis for a vertical group, cross axis for a horizontal one). */ + childForceExpandHeight: boolean; +} + +/** + * ECS-style component interface for a horizontal or vertical layout group - + * `direction` is set by `addHorizontalLayoutGroupComponent`/ + * `addVerticalLayoutGroupComponent` and picks which axis + * `createUiLayoutGroupEcsSystem` treats as the "main" (arranged, spaced) + * axis versus the "cross" (aligned) axis. + */ +export interface UiAxisLayoutGroupEcsComponent extends UiAxisLayoutGroupDefaultedOptions { + readonly direction: 'horizontal' | 'vertical'; +} + +export const uiAxisLayoutGroupId = + createComponentId('uiAxisLayoutGroup'); + +const defaultUiAxisLayoutGroupOptions: UiAxisLayoutGroupDefaultedOptions = { + padding: zeroPadding, + spacing: 0, + childAlignment: uiAlignments.topLeft, + childControlWidth: true, + childControlHeight: true, + childForceExpandWidth: true, + childForceExpandHeight: true, +}; + +/** + * Attaches a `HorizontalLayoutGroupEcsComponent` (a `UiAxisLayoutGroupEcsComponent` + * arranging children left-to-right) to `entity`. `createUiLayoutGroupEcsSystem` + * arranges every direct child that has a `RectTransformEcsComponent` and + * whose `LayoutElementEcsComponent.ignoreLayout` (if present) isn't `true`, + * along the entity's own content box (its resolved rect, inset by + * `padding`) - each child's height is the group's cross axis, sized/aligned + * per `childControlHeight`/`childAlignment.y`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. Its own + * `RectTransformEcsComponent` supplies the group's content box - add one + * first if `entity` doesn't already have one (e.g. via `createPanel`). + * @param options - Options for configuring the group. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addHorizontalLayoutGroupComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): UiAxisLayoutGroupEcsComponent { + const merged = { ...defaultUiAxisLayoutGroupOptions, ...options }; + const component: UiAxisLayoutGroupEcsComponent = { + ...merged, + padding: clonePadding(merged.padding), + childAlignment: Vec2.clone(merged.childAlignment), + direction: 'horizontal', + }; + + return world.addComponent(entity, uiAxisLayoutGroupId, component); +} + +/** + * Attaches a `VerticalLayoutGroupEcsComponent` (a `UiAxisLayoutGroupEcsComponent` + * arranging children top-to-bottom) to `entity`. Otherwise identical to + * {@link addHorizontalLayoutGroupComponent}, with the main/cross axes + * swapped - width is the cross axis, sized/aligned per + * `childControlWidth`/`childAlignment.x`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. Its own + * `RectTransformEcsComponent` supplies the group's content box - add one + * first if `entity` doesn't already have one (e.g. via `createPanel`). + * @param options - Options for configuring the group. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addVerticalLayoutGroupComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): UiAxisLayoutGroupEcsComponent { + const merged = { ...defaultUiAxisLayoutGroupOptions, ...options }; + const component: UiAxisLayoutGroupEcsComponent = { + ...merged, + padding: clonePadding(merged.padding), + childAlignment: Vec2.clone(merged.childAlignment), + direction: 'vertical', + }; + + return world.addComponent(entity, uiAxisLayoutGroupId, component); +} + +/** + * How a `GridLayoutGroupEcsComponent` decides its column/row count. + * `flexible` fits as many columns as the content box's width allows; + * `fixedColumnCount`/`fixedRowCount` hold one axis at `constraintCount` and + * derive the other from the child count. + */ +export type UiGridLayoutGroupConstraint = + 'flexible' | 'fixedColumnCount' | 'fixedRowCount'; + +/** Which corner a `GridLayoutGroupEcsComponent` starts placing cells from. */ +export type UiGridLayoutGroupCorner = + 'upperLeft' | 'upperRight' | 'lowerLeft' | 'lowerRight'; + +/** Which axis a `GridLayoutGroupEcsComponent` fills first before wrapping. */ +export type UiGridLayoutGroupAxis = 'horizontal' | 'vertical'; + +/** + * Fields of {@link GridLayoutGroupEcsComponent} with a sensible default; + * callers may omit these. + */ +export interface GridLayoutGroupDefaultedOptions { + /** Inset between this group's rect and the content box its cells fill. */ + padding: UiLayoutGroupPadding; + + /** + * Every cell's fixed size - unlike an axis layout group, a grid never + * measures its children; each simply occupies one `cellSize`-sized cell. + */ + cellSize: Vector2; + + /** Gap, in reference pixels, between adjacent cells on each axis. */ + spacing: Vector2; + + /** Where the whole grid block sits within any leftover content-box space. */ + childAlignment: UiAlignment; + + /** Which corner cell index `0` starts from. */ + startCorner: UiGridLayoutGroupCorner; + + /** Which axis is filled first (columns, then wrapping to a new row, or vice versa). */ + startAxis: UiGridLayoutGroupAxis; + + /** How the column/row count is decided. */ + constraint: UiGridLayoutGroupConstraint; + + /** + * The fixed column (or row) count when `constraint` is + * `fixedColumnCount`/`fixedRowCount`. Unused for `flexible`. + */ + constraintCount: number; +} + +export type GridLayoutGroupEcsComponent = GridLayoutGroupDefaultedOptions; + +export const gridLayoutGroupId = + createComponentId('gridLayoutGroup'); + +const defaultGridLayoutGroupOptions: GridLayoutGroupDefaultedOptions = { + padding: zeroPadding, + cellSize: { x: 100, y: 100 }, + spacing: Vec2.zero, + childAlignment: uiAlignments.topLeft, + startCorner: 'upperLeft', + startAxis: 'horizontal', + constraint: 'flexible', + constraintCount: 1, +}; + +/** + * Attaches a {@link GridLayoutGroupEcsComponent} to `entity`, arranging every + * direct child that has a `RectTransformEcsComponent` and whose + * `LayoutElementEcsComponent.ignoreLayout` (if present) isn't `true` into a + * fixed-size cell grid within the entity's own content box (its resolved + * rect, inset by `padding`). Unlike the axis groups, cell size never comes + * from a child's own measured size - every cell is exactly `cellSize`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. Its own + * `RectTransformEcsComponent` supplies the group's content box - add one + * first if `entity` doesn't already have one (e.g. via `createPanel`). + * @param options - Options for configuring the grid. + * @returns The attached component, for further tuning or runtime changes. + */ +export function addGridLayoutGroupComponent( + world: EcsWorld, + entity: number, + options: Partial = {}, +): GridLayoutGroupEcsComponent { + const merged = { ...defaultGridLayoutGroupOptions, ...options }; + const component: GridLayoutGroupEcsComponent = { + ...merged, + padding: clonePadding(merged.padding), + cellSize: Vec2.clone(merged.cellSize), + spacing: Vec2.clone(merged.spacing), + childAlignment: Vec2.clone(merged.childAlignment), + }; + + return world.addComponent(entity, gridLayoutGroupId, component); +} diff --git a/src/ui/systems/index.ts b/src/ui/systems/index.ts index fec800cf..7bbb2b64 100644 --- a/src/ui/systems/index.ts +++ b/src/ui/systems/index.ts @@ -1,4 +1,6 @@ +export * from './ui-aspect-ratio-fitter-system.js'; export * from './ui-interaction-system.js'; +export * from './ui-layout-group-system.js'; export * from './ui-layout-system.js'; export * from './ui-navigation-system.js'; export * from './ui-progress-bar-system.js'; diff --git a/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts b/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts new file mode 100644 index 00000000..28c1cc0f --- /dev/null +++ b/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { createUiAspectRatioFitterEcsSystem } from './ui-aspect-ratio-fitter-system.js'; +import { addParentComponent } from '../../common/index.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { addAspectRatioFitterComponent } from '../components/aspect-ratio-fitter-component.js'; +import { + addRectTransformComponent, + rectTransformId, +} from '../components/rect-transform-component.js'; + +describe('createUiAspectRatioFitterEcsSystem', () => { + it('derives height from width for widthControlsHeight', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addRectTransformComponent(world, entity, { sizeDelta: { x: 200, y: 999 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'widthControlsHeight', + aspectRatio: 2, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 200, + y: 100, + }); + }); + + it('derives width from height for heightControlsWidth', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addRectTransformComponent(world, entity, { sizeDelta: { x: 999, y: 50 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'heightControlsWidth', + aspectRatio: 2, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 100, + y: 50, + }); + }); + + it('fits inside a wider parent by constraining to its height, for fitInParent', () => { + const world = new EcsWorld(); + const parent = world.createEntity(); + + addRectTransformComponent(world, parent, { + rect: { min: { x: 0, y: 0 }, max: { x: 400, y: 100 } }, + }); + + const entity = world.createEntity(); + + addParentComponent(world, entity, { parent }); + addRectTransformComponent(world, entity, { sizeDelta: { x: 999, y: 999 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'fitInParent', + aspectRatio: 1, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + // parent aspect (4) > target aspect (1) -> height-bound: 100x100. + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 100, + y: 100, + }); + }); + + it('covers a wider parent by constraining to its width, for envelopeParent', () => { + const world = new EcsWorld(); + const parent = world.createEntity(); + + addRectTransformComponent(world, parent, { + rect: { min: { x: 0, y: 0 }, max: { x: 400, y: 100 } }, + }); + + const entity = world.createEntity(); + + addParentComponent(world, entity, { parent }); + addRectTransformComponent(world, entity, { sizeDelta: { x: 999, y: 999 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'envelopeParent', + aspectRatio: 1, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + // parent aspect (4) > target aspect (1) -> width-bound: 400x400. + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 400, + y: 400, + }); + }); + + it('is a no-op for fitInParent/envelopeParent with no ParentEcsComponent', () => { + const world = new EcsWorld(); + const entity = world.createEntity(); + + addRectTransformComponent(world, entity, { sizeDelta: { x: 50, y: 60 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'fitInParent', + aspectRatio: 1, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 50, + y: 60, + }); + }); +}); diff --git a/src/ui/systems/ui-aspect-ratio-fitter-system.ts b/src/ui/systems/ui-aspect-ratio-fitter-system.ts new file mode 100644 index 00000000..96b8f4aa --- /dev/null +++ b/src/ui/systems/ui-aspect-ratio-fitter-system.ts @@ -0,0 +1,82 @@ +import { ParentEcsComponent, parentId } from '../../common/index.js'; +import { EcsSystem } from '../../ecs/ecs-system.js'; +import { Rects } from '../../math/index.js'; +import { + AspectRatioFitterEcsComponent, + aspectRatioFitterId, +} from '../components/aspect-ratio-fitter-component.js'; +import { + RectTransformEcsComponent, + rectTransformId, +} from '../components/rect-transform-component.js'; + +/** + * Creates a system that keeps every `AspectRatioFitterEcsComponent`'s + * `RectTransformEcsComponent.sizeDelta` at its configured `aspectRatio` - + * `widthControlsHeight`/`heightControlsWidth` derive one axis from the + * other; `fitInParent`/`envelopeParent` derive both axes from the parent's + * own resolved `rect` (one frame stale, like every other cross-entity read + * in this module - see `createUiLayoutEcsSystem`'s own doc comment), and + * are a no-op for an entity with no `ParentEcsComponent`. + * + * Must be registered before `createUiLayoutEcsSystem`. + * @returns The UI aspect ratio fitter ECS system. + */ +export const createUiAspectRatioFitterEcsSystem = (): EcsSystem< + [AspectRatioFitterEcsComponent, RectTransformEcsComponent] +> => ({ + name: 'uiAspectRatioFitter', + query: [aspectRatioFitterId, rectTransformId], + update: (world, { entities, components: [fitters, rectTransforms] }) => { + for (let i = 0; i < entities.length; i++) { + const fitter = fitters[i]; + const rectTransform = rectTransforms[i]; + const { aspectMode, aspectRatio } = fitter; + + if (aspectMode === 'widthControlsHeight') { + rectTransform.sizeDelta.y = rectTransform.sizeDelta.x / aspectRatio; + + continue; + } + + if (aspectMode === 'heightControlsWidth') { + rectTransform.sizeDelta.x = rectTransform.sizeDelta.y * aspectRatio; + + continue; + } + + const parentComponent = world.getComponent( + entities[i], + parentId, + ); + + if (!parentComponent) { + continue; + } + + const parentRectTransform = world.getComponent( + parentComponent.parent, + rectTransformId, + ); + + if (!parentRectTransform) { + continue; + } + + const parentSize = Rects.size(parentRectTransform.rect); + const parentAspectRatio = parentSize.x / parentSize.y; + const isHeightBound = + aspectMode === 'fitInParent' + ? parentAspectRatio > aspectRatio + : parentAspectRatio < aspectRatio; + + if (isHeightBound) { + rectTransform.sizeDelta.y = parentSize.y; + rectTransform.sizeDelta.x = parentSize.y * aspectRatio; + } else { + rectTransform.sizeDelta.x = parentSize.x; + rectTransform.sizeDelta.y = parentSize.x / aspectRatio; + } + } + }, +}); diff --git a/src/ui/systems/ui-layout-group-system.test.ts b/src/ui/systems/ui-layout-group-system.test.ts new file mode 100644 index 00000000..34a3b35b --- /dev/null +++ b/src/ui/systems/ui-layout-group-system.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest'; +import { createUiLayoutGroupEcsSystem } from './ui-layout-group-system.js'; +import { addParentComponent } from '../../common/index.js'; +import { EcsWorld } from '../../ecs/index.js'; +import { addContentSizeFitterComponent } from '../components/content-size-fitter-component.js'; +import { addLayoutElementComponent } from '../components/layout-element-component.js'; +import { + addGridLayoutGroupComponent, + addHorizontalLayoutGroupComponent, + addVerticalLayoutGroupComponent, +} from '../components/layout-group-component.js'; +import { + addRectTransformComponent, + rectTransformId, +} from '../components/rect-transform-component.js'; +import { uiAlignments } from '../types/ui-alignment.js'; + +function createGroupEntity( + world: EcsWorld, + width: number, + height: number, +): number { + const entity = world.createEntity(); + + addRectTransformComponent(world, entity, { + rect: { min: { x: 0, y: 0 }, max: { x: width, y: height } }, + }); + + return entity; +} + +function createChild( + world: EcsWorld, + parent: number, + sizeDelta: { x: number; y: number }, +): number { + const entity = world.createEntity(); + + addParentComponent(world, entity, { parent }); + addRectTransformComponent(world, entity, { sizeDelta }); + + return entity; +} + +describe('createUiLayoutGroupEcsSystem', () => { + describe('horizontal layout groups', () => { + it('force-expands children along the main axis, and fills the cross axis, by default', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 300, 100); + + addHorizontalLayoutGroupComponent(world, group); + + const a = createChild(world, group, { x: 50, y: 50 }); + const b = createChild(world, group, { x: 50, y: 50 }); + const c = createChild(world, group, { x: 50, y: 50 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + for (const [entity, expectedX] of [ + [a, 0], + [b, 100], + [c, 200], + ] as const) { + const rect = world.getComponent(entity, rectTransformId)!; + + expect(rect.anchorMin).toEqual({ x: 0, y: 0 }); + expect(rect.anchorMax).toEqual({ x: 0, y: 0 }); + expect(rect.pivot).toEqual({ x: 0, y: 0 }); + // Main axis (width): 150 preferred total, 150 leftover split evenly + // -> 100 each. Cross axis (height): force-expand fills the full box. + expect(rect.sizeDelta).toEqual({ x: 100, y: 100 }); + expect(rect.anchoredPosition).toEqual({ x: expectedX, y: 0 }); + } + }); + + it("leaves a child's own size alone when childControlWidth/Height are false", () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 300, 100); + + addHorizontalLayoutGroupComponent(world, group, { + childControlWidth: false, + childForceExpandWidth: false, + childControlHeight: false, + }); + + const a = createChild(world, group, { x: 40, y: 20 }); + const b = createChild(world, group, { x: 60, y: 30 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const rectA = world.getComponent(a, rectTransformId)!; + const rectB = world.getComponent(b, rectTransformId)!; + + expect(rectA.sizeDelta).toEqual({ x: 40, y: 20 }); + expect(rectA.anchoredPosition.x).toBe(0); + expect(rectB.sizeDelta).toEqual({ x: 60, y: 30 }); + expect(rectB.anchoredPosition.x).toBe(40); + }); + + it('skips a child with LayoutElementEcsComponent.ignoreLayout', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 300, 100); + + addHorizontalLayoutGroupComponent(world, group); + + const a = createChild(world, group, { x: 50, y: 50 }); + const ignored = createChild(world, group, { x: 50, y: 50 }); + + addLayoutElementComponent(world, ignored, { ignoreLayout: true }); + + const ignoredRectBefore = { + ...world.getComponent(ignored, rectTransformId)!, + }; + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const rectA = world.getComponent(a, rectTransformId)!; + + // Only one arrangeable child, so it takes the entire content box. + expect(rectA.sizeDelta.x).toBe(300); + + const ignoredRectAfter = world.getComponent(ignored, rectTransformId)!; + + expect(ignoredRectAfter.sizeDelta).toEqual(ignoredRectBefore.sizeDelta); + expect(ignoredRectAfter.anchorMin).toEqual(ignoredRectBefore.anchorMin); + }); + }); + + describe('vertical layout groups', () => { + it('stacks children top-to-bottom, force-expanding the main axis (height)', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 60); + + addVerticalLayoutGroupComponent(world, group, { spacing: 0 }); + + const a = createChild(world, group, { x: 40, y: 20 }); + const b = createChild(world, group, { x: 40, y: 40 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const rectA = world.getComponent(a, rectTransformId)!; + const rectB = world.getComponent(b, rectTransformId)!; + + // Preferred total = 60, box = 60, no leftover: heights stay 20/40, + // `a` (first child) at the top. + expect(rectA.sizeDelta.y).toBeCloseTo(20); + expect(rectA.anchoredPosition.y).toBeCloseTo(40); + expect(rectB.sizeDelta.y).toBeCloseTo(40); + expect(rectB.anchoredPosition.y).toBeCloseTo(0); + + // Cross axis (width) force-expands to the full box width by default. + expect(rectA.sizeDelta.x).toBeCloseTo(100); + expect(rectB.sizeDelta.x).toBeCloseTo(100); + }); + + it('aligns the block within leftover main-axis space per childAlignment', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addVerticalLayoutGroupComponent(world, group, { + childControlHeight: false, + childForceExpandHeight: false, + childAlignment: uiAlignments.bottomCenter, + }); + + const a = createChild(world, group, { x: 40, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const rectA = world.getComponent(a, rectTransformId)!; + + // Bottom-aligned: leftover (80) all goes below, so the single 20-tall + // child's bottom edge sits at the box's own bottom (y = 0). + expect(rectA.anchoredPosition.y).toBeCloseTo(0); + }); + }); + + describe('grid layout groups', () => { + it('places cells row-major from the upper-left by default', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group, { + cellSize: { x: 50, y: 50 }, + constraint: 'fixedColumnCount', + constraintCount: 2, + }); + + const cells = [0, 1, 2].map(() => + createChild(world, group, { x: 50, y: 50 }), + ); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const positions = cells.map( + (cell) => world.getComponent(cell, rectTransformId)!.anchoredPosition, + ); + + expect(positions[0]).toEqual({ x: 0, y: 50 }); + expect(positions[1]).toEqual({ x: 50, y: 50 }); + expect(positions[2]).toEqual({ x: 0, y: 0 }); + + for (const cell of cells) { + expect(world.getComponent(cell, rectTransformId)!.sizeDelta).toEqual({ + x: 50, + y: 50, + }); + } + }); + }); + + describe('ContentSizeFitterEcsComponent', () => { + it("fits a group's own sizeDelta to its measured preferred content size", () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, group, { spacing: 10 }); + addContentSizeFitterComponent(world, group, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + createChild(world, group, { x: 40, y: 20 }); + createChild(world, group, { x: 60, y: 30 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const groupRect = world.getComponent(group, rectTransformId)!; + + // width = max(40, 60); height = 20 + 30 + spacing(10) + expect(groupRect.sizeDelta).toEqual({ x: 60, y: 60 }); + }); + + it('is a no-op with nothing to measure', () => { + const world = new EcsWorld(); + const entity = createGroupEntity(world, 200, 80); + + addContentSizeFitterComponent(world, entity, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // Falls back to the entity's own sizeDelta, which is untouched by + // this system with no layout group present - a harmless self-assignment. + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 100, + y: 100, + }); + }); + }); + + describe('nested groups', () => { + it("measures a nested group's own content size when arranging the outer group", () => { + const world = new EcsWorld(); + const outer = createGroupEntity(world, 100, 100); + + addVerticalLayoutGroupComponent(world, outer, { spacing: 0 }); + + const inner = createChild(world, outer, { x: 100, y: 100 }); + + addHorizontalLayoutGroupComponent(world, inner, { + childControlWidth: false, + childForceExpandWidth: false, + }); + + const innerChildA = createChild(world, inner, { x: 30, y: 20 }); + const innerChildB = createChild(world, inner, { x: 20, y: 15 }); + + const sibling = createChild(world, outer, { x: 50, y: 60 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // inner is a horizontal group; its own measured width is the sum of + // its children's widths (30 + 20 = 50), and its own measured height + // is the max of its children's heights (max(20, 15) = 20) - not + // inner's stale sizeDelta ({100, 100}) from before this ran. + // outer force-expands its own children's widths to fill the full + // box width (100) regardless, but the *height* distribution below + // depends on that measured 20. + const innerChildARect = world.getComponent(innerChildA, rectTransformId)!; + const innerChildBRect = world.getComponent(innerChildB, rectTransformId)!; + + expect(innerChildARect.anchoredPosition.x).toBe(0); + expect(innerChildBRect.anchoredPosition.x).toBe(30); + + // outer (vertical): main axis = height. inner's preferred height (20) + // + sibling's preferred height (60) = 80; leftover = 20, split evenly + // (10 each, neither sets a flexible weight): inner -> 30, sibling -> 70. + const innerFinalRect = world.getComponent(inner, rectTransformId)!; + const siblingRect = world.getComponent(sibling, rectTransformId)!; + + expect(innerFinalRect.sizeDelta.y).toBeCloseTo(30); + expect(siblingRect.sizeDelta.y).toBeCloseTo(70); + + // outer's cross axis (width) force-expands both children to fill 100. + expect(innerFinalRect.sizeDelta.x).toBeCloseTo(100); + expect(siblingRect.sizeDelta.x).toBeCloseTo(100); + }); + }); +}); diff --git a/src/ui/systems/ui-layout-group-system.ts b/src/ui/systems/ui-layout-group-system.ts new file mode 100644 index 00000000..3dbd70cc --- /dev/null +++ b/src/ui/systems/ui-layout-group-system.ts @@ -0,0 +1,721 @@ +import { ParentEcsComponent, parentId } from '../../common/index.js'; +import { EcsSystem } from '../../ecs/ecs-system.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { Rects } from '../../math/index.js'; +import { + ContentSizeFitterEcsComponent, + contentSizeFitterId, +} from '../components/content-size-fitter-component.js'; +import { + LayoutElementEcsComponent, + layoutElementId, +} from '../components/layout-element-component.js'; +import { + GridLayoutGroupEcsComponent, + gridLayoutGroupId, + UiAxisLayoutGroupEcsComponent, + uiAxisLayoutGroupId, + UiLayoutGroupPadding, +} from '../components/layout-group-component.js'; +import { + RectTransformEcsComponent, + rectTransformId, +} from '../components/rect-transform-component.js'; + +interface AxisMeasure { + min: number; + preferred: number; + flexible: number; +} + +interface Measured { + width: AxisMeasure; + height: AxisMeasure; +} + +type Measure = (entity: number) => Measured; + +function isIgnored(world: EcsWorld, entity: number): boolean { + return ( + world.getComponent(entity, layoutElementId) + ?.ignoreLayout === true + ); +} + +/** A group's (or fitter's) direct children, in query order, minus any `ignoreLayout` ones. */ +function arrangeableChildrenOf( + world: EcsWorld, + childrenByParent: Map, + entity: number, +): number[] { + return (childrenByParent.get(entity) ?? []).filter( + (child) => !isIgnored(world, child), + ); +} + +/** + * Decides a grid's column/row count from its `constraint`. `innerWidth` + * (the content box width available to fit columns into) is only relevant + * to the `flexible` constraint; when it's not yet known (measuring a grid + * as a nested layout element, rather than arranging its actual cells), a + * single row of `childCount` columns stands in - a fallback that's only + * ever used as one group's contribution to a parent's own measured size, + * never to actually place cells. + */ +function gridDimensions( + grid: GridLayoutGroupEcsComponent, + childCount: number, + innerWidth: number | undefined, +): { columns: number; rows: number } { + if (grid.constraint === 'fixedColumnCount') { + const columns = Math.max(1, grid.constraintCount); + + return { columns, rows: Math.ceil(childCount / columns) }; + } + + if (grid.constraint === 'fixedRowCount') { + const rows = Math.max(1, grid.constraintCount); + + return { columns: Math.ceil(childCount / rows), rows }; + } + + if (innerWidth === undefined) { + return { columns: childCount, rows: 1 }; + } + + const columns = Math.max( + 1, + Math.floor( + (innerWidth + grid.spacing.x) / (grid.cellSize.x + grid.spacing.x), + ), + ); + + return { columns, rows: Math.ceil(childCount / columns) }; +} + +/** A horizontal/vertical group's own content size: children summed along the main axis, maxed along the cross axis, plus padding. */ +function measureAxisGroupContent( + world: EcsWorld, + entity: number, + group: UiAxisLayoutGroupEcsComponent, + childrenByParent: Map, + measure: Measure, +): Measured { + const { padding } = group; + const isHorizontal = group.direction === 'horizontal'; + const mainPadding = isHorizontal + ? padding.left + padding.right + : padding.top + padding.bottom; + const crossPadding = isHorizontal + ? padding.top + padding.bottom + : padding.left + padding.right; + + const children = arrangeableChildrenOf(world, childrenByParent, entity); + + if (children.length === 0) { + const mainAxis: AxisMeasure = { + min: mainPadding, + preferred: mainPadding, + flexible: 0, + }; + const crossAxis: AxisMeasure = { + min: crossPadding, + preferred: crossPadding, + flexible: 0, + }; + + return isHorizontal + ? { width: mainAxis, height: crossAxis } + : { width: crossAxis, height: mainAxis }; + } + + const measures = children.map(measure); + const totalSpacing = group.spacing * (children.length - 1); + + const mainOf = (measured: Measured): AxisMeasure => + isHorizontal ? measured.width : measured.height; + const crossOf = (measured: Measured): AxisMeasure => + isHorizontal ? measured.height : measured.width; + + const mainAxis: AxisMeasure = { + min: + measures.reduce((sum, m) => sum + mainOf(m).min, 0) + + totalSpacing + + mainPadding, + preferred: + measures.reduce((sum, m) => sum + mainOf(m).preferred, 0) + + totalSpacing + + mainPadding, + flexible: 0, + }; + + const crossAxis: AxisMeasure = { + min: Math.max(...measures.map((m) => crossOf(m).min)) + crossPadding, + preferred: + Math.max(...measures.map((m) => crossOf(m).preferred)) + crossPadding, + flexible: 0, + }; + + return isHorizontal + ? { width: mainAxis, height: crossAxis } + : { width: crossAxis, height: mainAxis }; +} + +/** A grid's own content size: its column/row count (see `gridDimensions`) times `cellSize`, plus spacing and padding. */ +function measureGridContent( + grid: GridLayoutGroupEcsComponent, + childCount: number, +): Measured { + if (childCount === 0) { + const width: AxisMeasure = { + min: grid.padding.left + grid.padding.right, + preferred: grid.padding.left + grid.padding.right, + flexible: 0, + }; + const height: AxisMeasure = { + min: grid.padding.top + grid.padding.bottom, + preferred: grid.padding.top + grid.padding.bottom, + flexible: 0, + }; + + return { width, height }; + } + + const { columns, rows } = gridDimensions(grid, childCount, undefined); + const width = + columns * grid.cellSize.x + + (columns - 1) * grid.spacing.x + + grid.padding.left + + grid.padding.right; + const height = + rows * grid.cellSize.y + + (rows - 1) * grid.spacing.y + + grid.padding.top + + grid.padding.bottom; + + return { + width: { min: width, preferred: width, flexible: 0 }, + height: { min: height, preferred: height, flexible: 0 }, + }; +} + +/** + * Builds a memoized `measure` function reporting an entity's own + * min/preferred/flexible size on each axis: a `HorizontalLayoutGroupEcsComponent`/ + * `VerticalLayoutGroupEcsComponent`/`GridLayoutGroupEcsComponent`'s own + * content size (recursing into its own children, bottom-up), or - with none + * of those - `RectTransformEcsComponent.sizeDelta` as the preferred size (a + * min of `0`, a flexible weight of `0`). A `LayoutElementEcsComponent` + * overrides individual fields on top of either source. Results are cached + * per entity for the lifetime of one `update` call, so a group nested + * inside another is only measured once regardless of how many ancestors + * ask. + */ +function createMeasure( + world: EcsWorld, + childrenByParent: Map, +): Measure { + const cache = new Map(); + + const measure: Measure = (entity) => { + const cached = cache.get(entity); + + if (cached) { + return cached; + } + + const rectTransform = world.getComponent( + entity, + rectTransformId, + )!; + const layoutElement = world.getComponent( + entity, + layoutElementId, + ); + const axisGroup = world.getComponent( + entity, + uiAxisLayoutGroupId, + ); + const gridGroup = world.getComponent( + entity, + gridLayoutGroupId, + ); + + let base: Measured; + + if (axisGroup) { + base = measureAxisGroupContent( + world, + entity, + axisGroup, + childrenByParent, + measure, + ); + } else if (gridGroup) { + base = measureGridContent( + gridGroup, + arrangeableChildrenOf(world, childrenByParent, entity).length, + ); + } else { + base = { + width: { min: 0, preferred: rectTransform.sizeDelta.x, flexible: 0 }, + height: { min: 0, preferred: rectTransform.sizeDelta.y, flexible: 0 }, + }; + } + + const result: Measured = { + width: { + min: layoutElement?.minWidth ?? base.width.min, + preferred: layoutElement?.preferredWidth ?? base.width.preferred, + flexible: layoutElement?.flexibleWidth ?? base.width.flexible, + }, + height: { + min: layoutElement?.minHeight ?? base.height.min, + preferred: layoutElement?.preferredHeight ?? base.height.preferred, + flexible: layoutElement?.flexibleHeight ?? base.height.flexible, + }, + }; + + cache.set(entity, result); + + return result; + }; + + return measure; +} + +/** A child's starting main-axis size: its measured preferred size when controlled, otherwise its own current size on that axis. */ +function initialMainSize( + mainControl: boolean, + isHorizontal: boolean, + mainMeasure: AxisMeasure, + childRect: RectTransformEcsComponent, +): number { + if (!mainControl) { + return isHorizontal ? childRect.sizeDelta.x : childRect.sizeDelta.y; + } + + return mainMeasure.preferred; +} + +/** + * Distributes `extraSpace` (the content box's main axis, minus every + * child's own summed preferred size and spacing) across `mainSizes` in + * place, weighted by each child's `flexible` value from `mainMeasures` (or + * evenly, if none of them set one). + */ +function distributeExtraSpace( + mainMeasures: readonly AxisMeasure[], + mainSizes: number[], + extraSpace: number, +): void { + if (extraSpace <= 0) { + return; + } + + const totalFlexible = mainMeasures.reduce((sum, m) => sum + m.flexible, 0); + const weights = mainMeasures.map((m) => (totalFlexible > 0 ? m.flexible : 1)); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + + if (totalWeight <= 0) { + return; + } + + for (let i = 0; i < mainSizes.length; i++) { + mainSizes[i] += (extraSpace * weights[i]) / totalWeight; + } +} + +/** A child's cross-axis size: force-expanded to fill the whole cross axis, its own measured preferred size, or its own current size, per the group's control/force-expand flags. */ +function crossSizeOf( + crossControl: boolean, + crossForceExpand: boolean, + innerCross: number, + crossMeasure: AxisMeasure, + isHorizontal: boolean, + childRect: RectTransformEcsComponent, +): number { + if (!crossControl) { + return isHorizontal ? childRect.sizeDelta.y : childRect.sizeDelta.x; + } + + if (crossForceExpand) { + return innerCross; + } + + return crossMeasure.preferred; +} + +/** Options for {@link placeChild}, grouped to stay within this codebase's parameter-count limit. */ +interface PlaceChildOptions { + childRect: RectTransformEcsComponent; + isHorizontal: boolean; + mainControl: boolean; + crossControl: boolean; + mainSize: number; + crossSize: number; + blockStart: number; + cursor: number; + padding: UiLayoutGroupPadding; + crossOffset: number; +} + +/** + * Writes one arranged child's final anchor/pivot, size, and position. Every + * arranged child is forced to a bottom-left point anchor/pivot (`anchorMin = + * anchorMax = pivot = {0, 0}`) regardless of its previous anchoring, since + * the group positions it via `anchoredPosition` measured from its own + * content box's bottom-left corner. + */ +function placeChild(options: PlaceChildOptions): void { + const { + childRect, + isHorizontal, + mainControl, + crossControl, + mainSize, + crossSize, + blockStart, + cursor, + padding, + crossOffset, + } = options; + + childRect.anchorMin = { x: 0, y: 0 }; + childRect.anchorMax = { x: 0, y: 0 }; + childRect.pivot = { x: 0, y: 0 }; + + if (mainControl) { + if (isHorizontal) { + childRect.sizeDelta.x = mainSize; + } else { + childRect.sizeDelta.y = mainSize; + } + } + + if (crossControl) { + if (isHorizontal) { + childRect.sizeDelta.y = crossSize; + } else { + childRect.sizeDelta.x = crossSize; + } + } + + childRect.anchoredPosition = isHorizontal + ? { x: blockStart + cursor, y: padding.bottom + crossOffset } + : { x: padding.left + crossOffset, y: blockStart - cursor - mainSize }; +} + +/** + * Arranges one `HorizontalLayoutGroupEcsComponent`/`VerticalLayoutGroupEcsComponent`'s + * direct children along its main axis (spaced, and - with `childControlWidth`/ + * `childControlHeight` and `childForceExpandWidth`/`childForceExpandHeight` - + * resized to fill any leftover space), and aligned individually within its + * cross axis, per `group.childAlignment`. See `placeChild` for the final + * anchor/pivot every arranged child is forced to. + */ +function arrangeAxisGroup( + world: EcsWorld, + entity: number, + group: UiAxisLayoutGroupEcsComponent, + childrenByParent: Map, + measure: Measure, +): void { + const rectTransform = world.getComponent( + entity, + rectTransformId, + )!; + const rectSize = Rects.size(rectTransform.rect); + const { padding, spacing, childAlignment } = group; + const isHorizontal = group.direction === 'horizontal'; + + const children = arrangeableChildrenOf(world, childrenByParent, entity); + + if (children.length === 0) { + return; + } + + const innerWidth = rectSize.x - padding.left - padding.right; + const innerHeight = rectSize.y - padding.top - padding.bottom; + const innerMain = isHorizontal ? innerWidth : innerHeight; + const innerCross = isHorizontal ? innerHeight : innerWidth; + + const mainControl = isHorizontal + ? group.childControlWidth + : group.childControlHeight; + const crossControl = isHorizontal + ? group.childControlHeight + : group.childControlWidth; + const mainForceExpand = isHorizontal + ? group.childForceExpandWidth + : group.childForceExpandHeight; + // Unlike the main axis (where force-expand distributes only the leftover + // space beyond every child's own summed preferred size), the cross axis + // has one child per "row", so its own force-expand flag - named for the + // literal width/height it controls, not main/cross - simply stretches + // every child to fill the whole cross axis outright. + const crossForceExpand = isHorizontal + ? group.childForceExpandHeight + : group.childForceExpandWidth; + + const childRects = children.map((child) => + world.getComponent(child, rectTransformId)!, + ); + const measures = children.map(measure); + const mainMeasures = measures.map((m) => (isHorizontal ? m.width : m.height)); + const crossMeasures = measures.map((m) => + isHorizontal ? m.height : m.width, + ); + + const childMainSizes = children.map((_, i) => + initialMainSize(mainControl, isHorizontal, mainMeasures[i], childRects[i]), + ); + + const totalSpacing = spacing * (children.length - 1); + const totalPreferredMain = + childMainSizes.reduce((a, b) => a + b, 0) + totalSpacing; + const extraSpace = Math.max(0, innerMain - totalPreferredMain); + + if (mainForceExpand && mainControl) { + distributeExtraSpace(mainMeasures, childMainSizes, extraSpace); + } + + const contentMain = childMainSizes.reduce((a, b) => a + b, 0) + totalSpacing; + const leftoverMain = Math.max(0, innerMain - contentMain); + const mainAlignment = isHorizontal ? childAlignment.x : childAlignment.y; + const crossAlignment = isHorizontal ? childAlignment.y : childAlignment.x; + + const blockStart = isHorizontal + ? padding.left + leftoverMain * mainAlignment + : padding.bottom + leftoverMain * mainAlignment + contentMain; + + let cursor = 0; + + for (let i = 0; i < children.length; i++) { + const mainSize = childMainSizes[i]; + const crossSize = crossSizeOf( + crossControl, + crossForceExpand, + innerCross, + crossMeasures[i], + isHorizontal, + childRects[i], + ); + const crossOffset = (innerCross - crossSize) * crossAlignment; + + placeChild({ + childRect: childRects[i], + isHorizontal, + mainControl, + crossControl, + mainSize, + crossSize, + blockStart, + cursor, + padding, + crossOffset, + }); + + cursor += mainSize + spacing; + } +} + +/** + * Arranges one `GridLayoutGroupEcsComponent`'s direct children into fixed + * `cellSize` cells, per `constraint`/`startAxis`/`startCorner`, with the + * whole grid block aligned within any leftover content-box space per + * `childAlignment`. Unlike an axis group, cell size never comes from a + * child's own measured size. + */ +function arrangeGrid( + world: EcsWorld, + entity: number, + grid: GridLayoutGroupEcsComponent, + childrenByParent: Map, +): void { + const rectTransform = world.getComponent( + entity, + rectTransformId, + )!; + const rectSize = Rects.size(rectTransform.rect); + const { padding, cellSize, spacing, childAlignment } = grid; + + const children = arrangeableChildrenOf(world, childrenByParent, entity); + + if (children.length === 0) { + return; + } + + const innerWidth = rectSize.x - padding.left - padding.right; + const innerHeight = rectSize.y - padding.top - padding.bottom; + + const { columns, rows } = gridDimensions(grid, children.length, innerWidth); + + const gridContentWidth = columns * cellSize.x + (columns - 1) * spacing.x; + const gridContentHeight = rows * cellSize.y + (rows - 1) * spacing.y; + + const leftoverX = Math.max(0, innerWidth - gridContentWidth); + const leftoverY = Math.max(0, innerHeight - gridContentHeight); + + const contentLeft = padding.left + leftoverX * childAlignment.x; + const contentBottom = padding.bottom + leftoverY * childAlignment.y; + + const flipColumn = + grid.startCorner === 'upperRight' || grid.startCorner === 'lowerRight'; + const flipRow = + grid.startCorner === 'lowerLeft' || grid.startCorner === 'lowerRight'; + + for (let i = 0; i < children.length; i++) { + let row: number; + let column: number; + + if (grid.startAxis === 'horizontal') { + column = i % columns; + row = Math.floor(i / columns); + } else { + row = i % rows; + column = Math.floor(i / rows); + } + + const actualColumn = flipColumn ? columns - 1 - column : column; + const actualRow = flipRow ? rows - 1 - row : row; + + const childRect = world.getComponent( + children[i], + rectTransformId, + )!; + + childRect.anchorMin = { x: 0, y: 0 }; + childRect.anchorMax = { x: 0, y: 0 }; + childRect.pivot = { x: 0, y: 0 }; + childRect.sizeDelta = { x: cellSize.x, y: cellSize.y }; + + const cellLeft = contentLeft + actualColumn * (cellSize.x + spacing.x); + const rowFromTop = actualRow * (cellSize.y + spacing.y); + const cellBottom = + contentBottom + gridContentHeight - rowFromTop - cellSize.y; + + childRect.anchoredPosition = { x: cellLeft, y: cellBottom }; + } +} + +/** Resizes a `ContentSizeFitterEcsComponent`'s own entity to its measured content size, per axis. */ +function applyContentSizeFitter( + world: EcsWorld, + entity: number, + fitter: ContentSizeFitterEcsComponent, + measure: Measure, +): void { + if ( + fitter.horizontalFit === 'unconstrained' && + fitter.verticalFit === 'unconstrained' + ) { + return; + } + + const rectTransform = world.getComponent( + entity, + rectTransformId, + )!; + const measured = measure(entity); + + if (fitter.horizontalFit === 'minSize') { + rectTransform.sizeDelta.x = measured.width.min; + } else if (fitter.horizontalFit === 'preferredSize') { + rectTransform.sizeDelta.x = measured.width.preferred; + } + + if (fitter.verticalFit === 'minSize') { + rectTransform.sizeDelta.y = measured.height.min; + } else if (fitter.verticalFit === 'preferredSize') { + rectTransform.sizeDelta.y = measured.height.preferred; + } +} + +/** + * Creates a system that arranges every `HorizontalLayoutGroupEcsComponent`/ + * `VerticalLayoutGroupEcsComponent`/`GridLayoutGroupEcsComponent`'s direct + * children, and resizes every `ContentSizeFitterEcsComponent`'s own entity + * to its measured content size - both against `RectTransformEcsComponent.rect` + * as it stood at the *end of the previous frame*, since this system must run + * before `createUiLayoutEcsSystem` (the one that resolves `rect` for this + * frame) so the `sizeDelta`/`anchoredPosition` it writes are resolved into + * an up-to-date rect the same tick. This means a group whose own size just + * changed (a fresh entity, a `ContentSizeFitterEcsComponent` reacting to a + * child that changed size, a group nested inside another) arranges its + * children against a one-frame-stale box; like the rest of this module's + * full-recompute-every-frame approach (see `createUiLayoutEcsSystem`'s own + * doc comment), this converges within a frame or two rather than being + * tracked with dirty state. + * + * Must be registered before `createUiLayoutEcsSystem`. + * @returns The UI layout group ECS system. + */ +export const createUiLayoutGroupEcsSystem = (): EcsSystem< + [RectTransformEcsComponent] +> => ({ + name: 'uiLayoutGroup', + query: [rectTransformId], + update: (world, { entities }) => { + const rectTransformEntities = new Set(entities); + const childrenByParent = new Map(); + + for (const entity of entities) { + const parentComponent = world.getComponent( + entity, + parentId, + ); + + if ( + !parentComponent || + !rectTransformEntities.has(parentComponent.parent) + ) { + continue; + } + + let children = childrenByParent.get(parentComponent.parent); + + if (!children) { + children = []; + childrenByParent.set(parentComponent.parent, children); + } + + children.push(entity); + } + + const measure = createMeasure(world, childrenByParent); + + for (const entity of entities) { + const axisGroup = world.getComponent( + entity, + uiAxisLayoutGroupId, + ); + + if (axisGroup) { + arrangeAxisGroup(world, entity, axisGroup, childrenByParent, measure); + + continue; + } + + const gridGroup = world.getComponent( + entity, + gridLayoutGroupId, + ); + + if (gridGroup) { + arrangeGrid(world, entity, gridGroup, childrenByParent); + } + } + + for (const entity of entities) { + const fitter = world.getComponent( + entity, + contentSizeFitterId, + ); + + if (fitter) { + applyContentSizeFitter(world, entity, fitter, measure); + } + } + }, +}); diff --git a/src/ui/types/index.ts b/src/ui/types/index.ts index 24d87af7..4e555c1f 100644 --- a/src/ui/types/index.ts +++ b/src/ui/types/index.ts @@ -1,3 +1,4 @@ +export * from './ui-alignment.js'; export * from './ui-anchor.js'; export * from './ui-interaction-visual-state.js'; export * from './ui-navigation-direction.js'; diff --git a/src/ui/types/ui-alignment.ts b/src/ui/types/ui-alignment.ts new file mode 100644 index 00000000..4c22622a --- /dev/null +++ b/src/ui/types/ui-alignment.ts @@ -0,0 +1,29 @@ +import { Vector2 } from '../../math/index.js'; + +/** + * A normalized alignment fraction, in the same `(0, 0)` = bottom-left, + * `(1, 1)` = top-right convention as `RectTransformEcsComponent.pivot` - + * where a layout group places its children within any leftover space on + * each axis, after the children's own sizes (and spacing) are subtracted + * from the group's content box. + */ +export type UiAlignment = Vector2; + +/** + * Common `childAlignment` presets for `HorizontalLayoutGroupEcsComponent`/ + * `VerticalLayoutGroupEcsComponent`/`GridLayoutGroupEcsComponent`, named the + * same way as `UiAnchor`'s nine point presets. These are shared, module-level + * objects - safe to reference directly, since nothing in this module mutates + * a `childAlignment` value after reading it. + */ +export const uiAlignments: Readonly> = { + topLeft: { x: 0, y: 1 }, + topCenter: { x: 0.5, y: 1 }, + topRight: { x: 1, y: 1 }, + middleLeft: { x: 0, y: 0.5 }, + center: { x: 0.5, y: 0.5 }, + middleRight: { x: 1, y: 0.5 }, + bottomLeft: { x: 0, y: 0 }, + bottomCenter: { x: 0.5, y: 0 }, + bottomRight: { x: 1, y: 0 }, +}; diff --git a/src/ui/utilities/create-ui-canvas.ts b/src/ui/utilities/create-ui-canvas.ts index 2cf37372..5de954f2 100644 --- a/src/ui/utilities/create-ui-canvas.ts +++ b/src/ui/utilities/create-ui-canvas.ts @@ -14,7 +14,9 @@ import { CanvasEcsComponent, } from '../components/canvas-component.js'; import { addRectTransformComponent } from '../components/rect-transform-component.js'; +import { createUiAspectRatioFitterEcsSystem } from '../systems/ui-aspect-ratio-fitter-system.js'; import { createUiLayoutEcsSystem } from '../systems/ui-layout-system.js'; +import { createUiLayoutGroupEcsSystem } from '../systems/ui-layout-group-system.js'; import { createUiInteractionEcsSystem } from '../systems/ui-interaction-system.js'; import { createUiNavigationEcsSystem } from '../systems/ui-navigation-system.js'; import { createUiProgressBarEcsSystem } from '../systems/ui-progress-bar-system.js'; @@ -27,10 +29,11 @@ import { UiScaleMode } from '../types/ui-scale-mode.js'; /** * Worlds that already have `createUiLayoutEcsSystem` (and - * `createUiProgressBarEcsSystem`, which must run before it) registered, so - * calling `createUiCanvas` more than once for the same `EcsWorld` (multiple - * canvases sharing one game) doesn't register either a second time - * redundantly resolving every canvas again. + * `createUiProgressBarEcsSystem`/`createUiAspectRatioFitterEcsSystem`/ + * `createUiLayoutGroupEcsSystem`, which must all run before it) registered, + * so calling `createUiCanvas` more than once for the same `EcsWorld` + * (multiple canvases sharing one game) doesn't register any of them a + * second time redundantly resolving every canvas again. */ const worldsWithUiLayoutSystem = new WeakSet(); @@ -208,7 +211,8 @@ const defaultCreateUiCanvasOptions = { * transparent clear color, its own off-screen `RenderTarget`, and a culling * mask isolating it from the world so a world camera whose own `cullingMask` * still matches everything doesn't draw UI content a second time. Also - * registers `createUiLayoutEcsSystem`, `createUiProgressBarEcsSystem`, + * registers `createUiLayoutEcsSystem`, `createUiLayoutGroupEcsSystem`, + * `createUiAspectRatioFitterEcsSystem`, `createUiProgressBarEcsSystem`, * `createUiNavigationEcsSystem`, `createUiTransitionEcsSystem`, and * `createUiToggleEcsSystem` with `world` (each at most once, regardless of * how many canvases are created) - plus `createUiRaycastEcsSystem`/ @@ -289,12 +293,21 @@ export function createUiCanvas( // toggle/slider, which need this tick's interaction-pipeline state and // so can only run after it - registering this before layout lets a // `value` write and the fill visual it produces land in the very same - // frame. + // frame. The aspect ratio fitter and layout group systems likewise have + // no interaction dependency, and must run before layout so the + // sizeDelta/anchoredPosition they compute get resolved into a rect the + // same tick, rather than lagging a frame behind. const progressBar = createUiProgressBarEcsSystem(); + const aspectRatioFitter = createUiAspectRatioFitterEcsSystem(); + const layoutGroup = createUiLayoutGroupEcsSystem(); world.addSystem(progressBar); + world.addSystem(aspectRatioFitter); + world.addSystem(layoutGroup, { + after: [progressBar, aspectRatioFitter], + }); world.addSystem(createUiLayoutEcsSystem(renderContext), { - after: [progressBar], + after: [layoutGroup], }); worldsWithUiLayoutSystem.add(world); } From 25c65188d82925d882a0575cda561d12c367a256 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:39:00 +0000 Subject: [PATCH 2/5] test(ui): raise layout-group/aspect-ratio-fitter coverage to 100% Codecov flagged low patch coverage on the two new systems (82% patch coverage vs. 91% target). Adds tests for the previously-uncovered branches (fixedRowCount/startAxis/startCorner grid variants, flexible-weight main- axis distribution, cross-axis control-off sizing, empty-group measurement via ContentSizeFitterEcsComponent, mixed content-size-fit modes, and a missing-parent-rect no-op for the aspect ratio fitter), and removes one genuinely unreachable branch in distributeExtraSpace found along the way. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K9ju4fbCZYfAQmDhSBhRmH --- .../ui-aspect-ratio-fitter-system.test.ts | 21 + src/ui/systems/ui-layout-group-system.test.ts | 379 ++++++++++++++++++ src/ui/systems/ui-layout-group-system.ts | 7 +- 3 files changed, 403 insertions(+), 4 deletions(-) diff --git a/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts b/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts index 28c1cc0f..8733ebd2 100644 --- a/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts +++ b/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts @@ -119,4 +119,25 @@ describe('createUiAspectRatioFitterEcsSystem', () => { y: 60, }); }); + + it('is a no-op for fitInParent/envelopeParent when the parent has no RectTransformEcsComponent', () => { + const world = new EcsWorld(); + const parent = world.createEntity(); + const entity = world.createEntity(); + + addParentComponent(world, entity, { parent }); + addRectTransformComponent(world, entity, { sizeDelta: { x: 50, y: 60 } }); + addAspectRatioFitterComponent(world, entity, { + aspectMode: 'fitInParent', + aspectRatio: 1, + }); + + world.addSystem(createUiAspectRatioFitterEcsSystem()); + world.update(); + + expect(world.getComponent(entity, rectTransformId)!.sizeDelta).toEqual({ + x: 50, + y: 60, + }); + }); }); diff --git a/src/ui/systems/ui-layout-group-system.test.ts b/src/ui/systems/ui-layout-group-system.test.ts index 34a3b35b..64f210b3 100644 --- a/src/ui/systems/ui-layout-group-system.test.ts +++ b/src/ui/systems/ui-layout-group-system.test.ts @@ -127,6 +127,31 @@ describe('createUiLayoutGroupEcsSystem', () => { expect(ignoredRectAfter.sizeDelta).toEqual(ignoredRectBefore.sizeDelta); expect(ignoredRectAfter.anchorMin).toEqual(ignoredRectBefore.anchorMin); }); + + it("distributes leftover main-axis space by each child's own flexible weight", () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 300, 100); + + addHorizontalLayoutGroupComponent(world, group); + + const a = createChild(world, group, { x: 50, y: 50 }); + const b = createChild(world, group, { x: 50, y: 50 }); + + addLayoutElementComponent(world, a, { flexibleWidth: 1 }); + addLayoutElementComponent(world, b, { flexibleWidth: 3 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // Preferred total = 100, leftover = 200, split 1:3 -> a gets 50, b + // gets 150, on top of their own preferred 50 each. + expect(world.getComponent(a, rectTransformId)!.sizeDelta.x).toBeCloseTo( + 100, + ); + expect(world.getComponent(b, rectTransformId)!.sizeDelta.x).toBeCloseTo( + 200, + ); + }); }); describe('vertical layout groups', () => { @@ -178,6 +203,53 @@ describe('createUiLayoutGroupEcsSystem', () => { // child's bottom edge sits at the box's own bottom (y = 0). expect(rectA.anchoredPosition.y).toBeCloseTo(0); }); + + it('is a no-op with no children', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addVerticalLayoutGroupComponent(world, group); + + world.addSystem(createUiLayoutGroupEcsSystem()); + + expect(() => world.update()).not.toThrow(); + }); + + it("sizes the cross axis to each child's own preferred size when childForceExpandWidth is off", () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addVerticalLayoutGroupComponent(world, group, { + childForceExpandWidth: false, + }); + + const a = createChild(world, group, { x: 40, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // Cross axis (width) is controlled (childControlWidth stays the + // default true) but not force-expanded, so it's sized to the + // child's own measured preferred width (40) rather than filling + // the box (100). + expect(world.getComponent(a, rectTransformId)!.sizeDelta.x).toBe(40); + }); + + it("leaves a child's own width alone on the cross axis when childControlWidth is false", () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addVerticalLayoutGroupComponent(world, group, { + childControlWidth: false, + }); + + const a = createChild(world, group, { x: 33, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + expect(world.getComponent(a, rectTransformId)!.sizeDelta.x).toBe(33); + }); }); describe('grid layout groups', () => { @@ -213,6 +285,128 @@ describe('createUiLayoutGroupEcsSystem', () => { }); } }); + + it('derives column count from a fixed row count', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group, { + cellSize: { x: 50, y: 50 }, + constraint: 'fixedRowCount', + constraintCount: 2, + }); + + const cells = [0, 1, 2].map(() => + createChild(world, group, { x: 50, y: 50 }), + ); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const positions = cells.map( + (cell) => world.getComponent(cell, rectTransformId)!.anchoredPosition, + ); + + // rows: 2 (fixed) -> columns: ceil(3 / 2) = 2, same layout as the + // fixedColumnCount test above (columns also happens to be 2 there). + expect(positions[0]).toEqual({ x: 0, y: 50 }); + expect(positions[1]).toEqual({ x: 50, y: 50 }); + expect(positions[2]).toEqual({ x: 0, y: 0 }); + }); + + it('fills columns first, then wraps to a new row, for startAxis: vertical', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group, { + cellSize: { x: 50, y: 50 }, + constraint: 'fixedColumnCount', + constraintCount: 2, + startAxis: 'vertical', + }); + + const cells = [0, 1, 2].map(() => + createChild(world, group, { x: 50, y: 50 }), + ); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const positions = cells.map( + (cell) => world.getComponent(cell, rectTransformId)!.anchoredPosition, + ); + + // 2 columns, so 2 rows needed for 3 cells; startAxis: 'vertical' fills + // the first column top-to-bottom before wrapping to the next column. + expect(positions[0]).toEqual({ x: 0, y: 50 }); + expect(positions[1]).toEqual({ x: 0, y: 0 }); + expect(positions[2]).toEqual({ x: 50, y: 50 }); + }); + + it('flips both column and row placement, for startCorner: lowerRight', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group, { + cellSize: { x: 50, y: 50 }, + constraint: 'fixedColumnCount', + constraintCount: 2, + startCorner: 'lowerRight', + }); + + const cells = [0, 1, 2].map(() => + createChild(world, group, { x: 50, y: 50 }), + ); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const positions = cells.map( + (cell) => world.getComponent(cell, rectTransformId)!.anchoredPosition, + ); + + expect(positions[0]).toEqual({ x: 50, y: 0 }); + expect(positions[1]).toEqual({ x: 0, y: 0 }); + expect(positions[2]).toEqual({ x: 50, y: 50 }); + }); + + it('is a no-op with no children', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group); + + world.addSystem(createUiLayoutGroupEcsSystem()); + + expect(() => world.update()).not.toThrow(); + }); + + it('fits as many columns as the content box allows, for the default flexible constraint', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 100, 100); + + addGridLayoutGroupComponent(world, group, { + cellSize: { x: 30, y: 30 }, + spacing: { x: 10, y: 10 }, + }); + + const cells = [0, 1, 2, 3].map(() => + createChild(world, group, { x: 30, y: 30 }), + ); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // columns = floor((100 + 10) / (30 + 10)) = 2, so 2 rows for 4 cells. + const positions = cells.map( + (cell) => world.getComponent(cell, rectTransformId)!.anchoredPosition, + ); + + expect(positions[0].x).toBeCloseTo(0); + expect(positions[1].x).toBeCloseTo(40); + expect(positions[2].x).toBeCloseTo(0); + expect(positions[3].x).toBeCloseTo(40); + }); }); describe('ContentSizeFitterEcsComponent', () => { @@ -257,6 +451,191 @@ describe('createUiLayoutGroupEcsSystem', () => { y: 100, }); }); + + it('leaves sizeDelta alone entirely when both fit modes default to unconstrained', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, group); + addContentSizeFitterComponent(world, group); + + createChild(world, group, { x: 40, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + expect(world.getComponent(group, rectTransformId)!.sizeDelta).toEqual({ + x: 100, + y: 100, + }); + }); + + it('fits to the measured minimum size for minSize', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, group); + addContentSizeFitterComponent(world, group, { + horizontalFit: 'minSize', + verticalFit: 'minSize', + }); + + const child = createChild(world, group, { x: 40, y: 20 }); + + addLayoutElementComponent(world, child, { minWidth: 10, minHeight: 5 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + expect(world.getComponent(group, rectTransformId)!.sizeDelta).toEqual({ + x: 10, + y: 5, + }); + }); + + it('fits only the axis with a non-unconstrained mode, leaving the other alone', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, group); + addContentSizeFitterComponent(world, group, { + horizontalFit: 'preferredSize', + verticalFit: 'unconstrained', + }); + + createChild(world, group, { x: 40, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const groupRect = world.getComponent(group, rectTransformId)!; + + expect(groupRect.sizeDelta.x).toBe(40); + // Untouched - the fitter's own starting sizeDelta, not the group's + // measured height. + expect(groupRect.sizeDelta.y).toBe(100); + }); + + it('fits only the vertical axis, leaving an unconstrained horizontal axis alone', () => { + const world = new EcsWorld(); + const group = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, group); + addContentSizeFitterComponent(world, group, { + horizontalFit: 'unconstrained', + verticalFit: 'preferredSize', + }); + + createChild(world, group, { x: 40, y: 20 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + const groupRect = world.getComponent(group, rectTransformId)!; + + // Untouched - the fitter's own starting sizeDelta, not the group's + // measured width. + expect(groupRect.sizeDelta.x).toBe(100); + expect(groupRect.sizeDelta.y).toBe(20); + }); + + it('measures an empty layout group as just its own padding', () => { + const world = new EcsWorld(); + const padding = { left: 4, right: 6, top: 8, bottom: 10 }; + + const verticalGroup = createGroupEntity(world, 500, 500); + + addVerticalLayoutGroupComponent(world, verticalGroup, { padding }); + addContentSizeFitterComponent(world, verticalGroup, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + const horizontalGroup = createGroupEntity(world, 500, 500); + + addHorizontalLayoutGroupComponent(world, horizontalGroup, { padding }); + addContentSizeFitterComponent(world, horizontalGroup, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // Cross/main axis are swapped between the two directions, but an + // empty group's own size is always just its padding, either way. + expect( + world.getComponent(verticalGroup, rectTransformId)!.sizeDelta, + ).toEqual({ x: 10, y: 18 }); + expect( + world.getComponent(horizontalGroup, rectTransformId)!.sizeDelta, + ).toEqual({ x: 10, y: 18 }); + }); + + it("measures a grid group's own content size, including when it has no children", () => { + const world = new EcsWorld(); + const gridWithChildren = createGroupEntity(world, 500, 500); + + addGridLayoutGroupComponent(world, gridWithChildren, { + cellSize: { x: 40, y: 30 }, + constraint: 'fixedColumnCount', + constraintCount: 2, + }); + addContentSizeFitterComponent(world, gridWithChildren, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + createChild(world, gridWithChildren, { x: 1, y: 1 }); + createChild(world, gridWithChildren, { x: 1, y: 1 }); + createChild(world, gridWithChildren, { x: 1, y: 1 }); + + const emptyGrid = createGroupEntity(world, 500, 500); + + addGridLayoutGroupComponent(world, emptyGrid); + addContentSizeFitterComponent(world, emptyGrid, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // 2 columns, 2 rows (ceil(3 / 2)) of 40x30 cells, no spacing/padding. + expect( + world.getComponent(gridWithChildren, rectTransformId)!.sizeDelta, + ).toEqual({ x: 80, y: 60 }); + expect(world.getComponent(emptyGrid, rectTransformId)!.sizeDelta).toEqual( + { x: 0, y: 0 }, + ); + }); + + it("measures a grid group's content size as a single row when its constraint is flexible (no known content box width to fit columns into)", () => { + const world = new EcsWorld(); + const grid = createGroupEntity(world, 500, 500); + + addGridLayoutGroupComponent(world, grid, { + cellSize: { x: 20, y: 15 }, + spacing: { x: 5, y: 5 }, + }); + addContentSizeFitterComponent(world, grid, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + createChild(world, grid, { x: 1, y: 1 }); + createChild(world, grid, { x: 1, y: 1 }); + createChild(world, grid, { x: 1, y: 1 }); + + world.addSystem(createUiLayoutGroupEcsSystem()); + world.update(); + + // 3 columns (one per child), 1 row: width = 3*20 + 2*5 = 70, height = 15. + expect(world.getComponent(grid, rectTransformId)!.sizeDelta).toEqual({ + x: 70, + y: 15, + }); + }); }); describe('nested groups', () => { diff --git a/src/ui/systems/ui-layout-group-system.ts b/src/ui/systems/ui-layout-group-system.ts index 3dbd70cc..0cef5595 100644 --- a/src/ui/systems/ui-layout-group-system.ts +++ b/src/ui/systems/ui-layout-group-system.ts @@ -315,12 +315,11 @@ function distributeExtraSpace( const totalFlexible = mainMeasures.reduce((sum, m) => sum + m.flexible, 0); const weights = mainMeasures.map((m) => (totalFlexible > 0 ? m.flexible : 1)); + // `weights` always has at least one entry here (arrangeAxisGroup already + // returned early for zero children) and every entry is either a positive + // flexible value or 1, so this sum is always positive. const totalWeight = weights.reduce((sum, w) => sum + w, 0); - if (totalWeight <= 0) { - return; - } - for (let i = 0; i < mainSizes.length; i++) { mainSizes[i] += (extraSpace * weights[i]) / totalWeight; } From 2665f954eea497a7b384015c29476a7adb60f3e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 20:44:16 +0000 Subject: [PATCH 3/5] fix(ui): enlarge the demo's Difficulty panel to match the rest of the HUD The panel, title, and buttons were sized well below every other element in the demo (a reviewer's "looks really bad" on the PR) - too small and cramped compared to the Score/Settings panels. Enlarges the panel, title, and button text/padding to match, while keeping enough horizontal clearance from the Settings panel down to a realistic minimum demo-box width (verified at 1280px - the existing Score/Settings panels already overlap below ~1100px regardless of this panel, a pre-existing limit of the demo box's own fixed-aspect layout, not something this change need fix). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K9ju4fbCZYfAQmDhSBhRmH --- .../src/pages/demos/ui/_create-game.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/documentation-site/src/pages/demos/ui/_create-game.ts b/documentation-site/src/pages/demos/ui/_create-game.ts index b4cd650b..1ceb2c2b 100644 --- a/documentation-site/src/pages/demos/ui/_create-game.ts +++ b/documentation-site/src/pages/demos/ui/_create-game.ts @@ -365,19 +365,19 @@ function createLayoutGroupPanel( // narrows (scaleWithScreenSize keeps height pinned; only width // shrinks), since nothing else anchors to the top-left. anchor: UiAnchor.topLeft, - anchoredPosition: { x: 20, y: -250 }, - sizeDelta: { x: 300, y: 90 }, + anchoredPosition: { x: 20, y: -260 }, + sizeDelta: { x: 320, y: 160 }, sprite: panelSprite, }); createLabel(world, panel, { text: 'Difficulty', fontAtlas, - size: 20, + size: 28, anchor: UiAnchor.stretchTopLeft, // See the "Settings" label's own comment above for why this is needed. sizeDelta: { x: 0, y: 0 }, - anchoredPosition: { x: 0, y: -16 }, + anchoredPosition: { x: 0, y: -28 }, horizontalAlign: textHorizontalAlignments.center, verticalAlign: textVerticalAlignments.middle, color: textColor, @@ -394,11 +394,11 @@ function createLayoutGroupPanel( addParentComponent(world, row, { parent: panel }); addRectTransformComponent(world, row, { ...UiAnchor.stretchBottom, - sizeDelta: { x: -20, y: 36 }, - anchoredPosition: { x: 0, y: 8 }, + sizeDelta: { x: -40, y: 64 }, + anchoredPosition: { x: 0, y: 24 }, }); addHorizontalLayoutGroupComponent(world, row, { - spacing: 8, + spacing: 16, childAlignment: uiAlignments.center, }); @@ -414,14 +414,14 @@ function createLayoutGroupPanel( sprite: panelSprite, label, fontAtlas, - labelSize: 16, + labelSize: 22, labelColor: textColor, labelCategory: renderLayers.ui, // Deliberately narrower than the row's own content box - the // HorizontalLayoutGroupEcsComponent's default childForceExpandWidth // stretches all three evenly to fill the remaining space, which a // sizeDelta already matching the box exactly wouldn't demonstrate. - sizeDelta: { x: 60, y: 32 }, + sizeDelta: { x: 64, y: 56 }, transition: buttonTransition, }); } From 81c560fb9203b5cbc568ac7abdbe092b253b9b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:16:51 +0000 Subject: [PATCH 4/5] fix(ui): fix layout-group + ContentSizeFitter oscillation; split demo Splits the layout-groups showcase out of the monolithic UI demo into its own dedicated demos/layout-groups page (a Menu, Toolbar, and Inventory panel), per review feedback that a single demo shouldn't try to cover every UI feature. The existing UI demo is reverted to its pre-Phase-4 state; splitting its own controls (button/toggle/dropdown/etc.) into focused demos is left as a follow-up, out of scope for this PR. While building the new demo, found and fixed a real bug this surfaced: a HorizontalLayoutGroupEcsComponent/VerticalLayoutGroupEcsComponent's cross-axis force-expand set a child's sizeDelta to the group's own (one-frame-stale) inner size with no floor. On a fresh entity that starts at Rects.zero, or after any transient undersized frame, this wrote a negative sizeDelta into the child; a ContentSizeFitterEcsComponent on the same group then measured that corrupted size and fed it back into the group's own size next frame - a permanent oscillation between the corrupted and correct size rather than a one-frame hiccup. Fixed by flooring cross-axis force-expand at the child's own measured preferred size (matching the "grow, never shrink" invariant the main axis already enforces), plus a pre-measure pass so arrangement's mutations can never invalidate a measurement another entity still needs later in the same tick. Added a regression test exercising the exact repro. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K9ju4fbCZYfAQmDhSBhRmH --- documentation-site/docusaurus.config.ts | 4 + .../pages/demos/layout-groups/_create-game.ts | 135 ++++++++++++++++++ .../layout-groups/_create-inventory-grid.ts | 94 ++++++++++++ .../pages/demos/layout-groups/_create-menu.ts | 95 ++++++++++++ .../demos/layout-groups/_create-toolbar.ts | 95 ++++++++++++ .../src/pages/demos/layout-groups/index.tsx | 37 +++++ .../src/pages/demos/ui/_create-game.ts | 93 +----------- .../src/pages/demos/ui/index.tsx | 2 +- src/ui/systems/ui-layout-group-system.test.ts | 66 ++++++++- src/ui/systems/ui-layout-group-system.ts | 40 +++++- 10 files changed, 566 insertions(+), 95 deletions(-) create mode 100644 documentation-site/src/pages/demos/layout-groups/_create-game.ts create mode 100644 documentation-site/src/pages/demos/layout-groups/_create-inventory-grid.ts create mode 100644 documentation-site/src/pages/demos/layout-groups/_create-menu.ts create mode 100644 documentation-site/src/pages/demos/layout-groups/_create-toolbar.ts create mode 100644 documentation-site/src/pages/demos/layout-groups/index.tsx diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index cbefc57f..c00f54f1 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -203,6 +203,10 @@ const config: Config = { to: 'demos/ui', label: 'UI', }, + { + to: 'demos/layout-groups', + label: 'Layout Groups', + }, ], }, { diff --git a/documentation-site/src/pages/demos/layout-groups/_create-game.ts b/documentation-site/src/pages/demos/layout-groups/_create-game.ts new file mode 100644 index 00000000..252cf10c --- /dev/null +++ b/documentation-site/src/pages/demos/layout-groups/_create-game.ts @@ -0,0 +1,135 @@ +import { + addPositionComponent, + createTransformEcsSystem, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlasCache, +} from '@forge-game-engine/forge/text'; +import { createUiCanvas } from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { createInventoryGrid } from './_create-inventory-grid'; +import { createMenu } from './_create-menu'; +import { createToolbar } from './_create-toolbar'; + +// Forge doesn't ship a reserved "UI" render category - each game picks its +// own bit and reuses it for the UI canvas's cullingMask and every UI +// visual's own category, so it's this demo's choice, not the engine's, +// which bit separates the world camera from the UI camera. +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +/** + * Builds the layout groups demo: three independent panels - a "Menu" + * (`VerticalLayoutGroupEcsComponent` + `ContentSizeFitterEcsComponent`), a + * "Toolbar" (`HorizontalLayoutGroupEcsComponent`), and an "Inventory" + * (`GridLayoutGroupEcsComponent`) - each arranging its own children with no + * manual `anchoredPosition`/`sizeDelta` bookkeeping. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createLayoutGroupsGame = async ( + fontAtlasUrl: string, +): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + }); + + const panelImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/kenney_fantasy-ui-borders/PNG/Double/Panel/panel-030.png'), + ); + const panelSprite = createImageSprite(panelImage, renderContext, { + layer: renderLayers.ui, + slices: { + left: 26, + right: 26, + top: 26, + bottom: 26, + nativeWidth: 96, + nativeHeight: 96, + }, + }); + + createMenu(world, canvas, fontAtlas, panelSprite, renderLayers.ui); + await createToolbar( + world, + renderContext, + canvas, + fontAtlas, + panelSprite, + renderLayers.ui, + ); + await createInventoryGrid( + world, + renderContext, + canvas, + fontAtlas, + panelSprite, + renderLayers.ui, + ); + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/layout-groups/_create-inventory-grid.ts b/documentation-site/src/pages/demos/layout-groups/_create-inventory-grid.ts new file mode 100644 index 00000000..9ba71b7a --- /dev/null +++ b/documentation-site/src/pages/demos/layout-groups/_create-inventory-grid.ts @@ -0,0 +1,94 @@ +import { addParentComponent, addPositionComponent } from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + Color, + createImageSprite, + RenderContext, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { + FontAtlas, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + addGridLayoutGroupComponent, + addRectTransformComponent, + createLabel, + createPanel, + uiAlignments, + UiAnchor, +} from '@forge-game-engine/forge/ui'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const cellCount = 8; + +/** + * Builds an "Inventory" panel: a `GridLayoutGroupEcsComponent` placing 8 + * fixed-size cells into a 4-column grid - unlike the axis groups in + * `_create-menu.ts`/`_create-toolbar.ts`, a grid's cell size never comes + * from a child's own measured size, only from `cellSize` itself. + * @param world - The ECS world to create the inventory entities in. + * @param renderContext - The render context the cell sprites are built against. + * @param canvas - The canvas entity to parent the inventory panel to. + * @param fontAtlas - The font atlas the title label is drawn from. + * @param panelSprite - The nine-sliced sprite the panel is drawn with. + * @param uiCategory - The render category the canvas's camera culls to. + */ +export async function createInventoryGrid( + world: EcsWorld, + renderContext: RenderContext, + canvas: number, + fontAtlas: FontAtlas, + panelSprite: SpriteEcsComponent, + uiCategory: number, +): Promise { + createLabel(world, canvas, { + text: 'Inventory', + fontAtlas, + size: 24, + anchor: UiAnchor.bottomLeft, + anchoredPosition: { x: 60, y: 272 }, + sizeDelta: { x: 400, y: 32 }, + horizontalAlign: textHorizontalAlignments.left, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: uiCategory, + }); + + const panel = createPanel(world, canvas, { + anchor: UiAnchor.bottomLeft, + anchoredPosition: { x: 60, y: 60 }, + sizeDelta: { x: 400, y: 200 }, + sprite: panelSprite, + }); + + addGridLayoutGroupComponent(world, panel, { + padding: { left: 20, right: 20, top: 20, bottom: 20 }, + cellSize: { x: 70, y: 70 }, + spacing: { x: 12, y: 12 }, + constraint: 'fixedColumnCount', + constraintCount: 4, + childAlignment: uiAlignments.center, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + + for (let i = 0; i < cellCount; i++) { + const cell = world.createEntity(); + + addPositionComponent(world, cell); + addParentComponent(world, cell, { parent: panel }); + addRectTransformComponent(world, cell); + + const sprite = createImageSprite(whiteImage, renderContext, { + layer: uiCategory, + }); + sprite.tintColor = new Color(0.55, 0.55, 0.6, 1); + + addSpriteComponent(world, cell, sprite); + } +} diff --git a/documentation-site/src/pages/demos/layout-groups/_create-menu.ts b/documentation-site/src/pages/demos/layout-groups/_create-menu.ts new file mode 100644 index 00000000..9abcdf9d --- /dev/null +++ b/documentation-site/src/pages/demos/layout-groups/_create-menu.ts @@ -0,0 +1,95 @@ +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Color, SpriteEcsComponent } from '@forge-game-engine/forge/rendering'; +import { + FontAtlas, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + addContentSizeFitterComponent, + addLayoutElementComponent, + addVerticalLayoutGroupComponent, + createButton, + createLabel, + createPanel, + uiAlignments, + UiAnchor, +} from '@forge-game-engine/forge/ui'; + +const textColor = new Color(0.12, 0.12, 0.16, 1); + +/** + * Builds a "Menu" panel: a `VerticalLayoutGroupEcsComponent` stacking three + * buttons, with a `ContentSizeFitterEcsComponent` on the panel itself so it + * shrink-wraps to exactly fit them - resize a button (or add a fourth) and + * the panel grows with it, with no `sizeDelta` of its own to keep in sync. + * `padding.top` reserves room for the title label, which is excluded from + * the group's own arrangement (and from the size the fitter measures) via + * `LayoutElementEcsComponent.ignoreLayout` - it's positioned by its own + * anchor instead, the same way a decorative element would be. + * @param world - The ECS world to create the menu entities in. + * @param canvas - The canvas entity to parent the menu panel to. + * @param fontAtlas - The font atlas the title/button labels are drawn from. + * @param panelSprite - The nine-sliced sprite the panel and buttons share. + * @param uiCategory - The render category the canvas's camera culls to. + */ +export function createMenu( + world: EcsWorld, + canvas: number, + fontAtlas: FontAtlas, + panelSprite: SpriteEcsComponent, + uiCategory: number, +): void { + const panel = createPanel(world, canvas, { + anchor: UiAnchor.topLeft, + anchoredPosition: { x: 60, y: -60 }, + sprite: panelSprite, + }); + + addVerticalLayoutGroupComponent(world, panel, { + padding: { left: 24, right: 24, top: 64, bottom: 24 }, + spacing: 16, + childAlignment: uiAlignments.center, + }); + addContentSizeFitterComponent(world, panel, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + const title = createLabel(world, panel, { + text: 'Menu', + fontAtlas, + size: 28, + anchor: UiAnchor.stretchTopLeft, + // See createButton's own labels below for why this is needed - a + // stretch anchor's default sizeDelta is a margin, not a literal size. + sizeDelta: { x: 0, y: 0 }, + anchoredPosition: { x: 0, y: -20 }, + horizontalAlign: textHorizontalAlignments.center, + verticalAlign: textVerticalAlignments.middle, + color: textColor, + category: uiCategory, + }); + + addLayoutElementComponent(world, title, { ignoreLayout: true }); + + const buttonTransition = { + normalColor: Color.white, + hoverColor: new Color(0.85, 0.85, 0.85, 1), + pressedColor: new Color(0.65, 0.65, 0.65, 1), + disabledColor: new Color(0.5, 0.5, 0.5, 0.6), + }; + + for (const label of ['Continue', 'Options', 'Quit']) { + createButton(world, panel, { + sprite: panelSprite, + label, + fontAtlas, + labelSize: 22, + labelColor: textColor, + labelCategory: uiCategory, + sizeDelta: { x: 220, y: 56 }, + transition: buttonTransition, + }); + } +} diff --git a/documentation-site/src/pages/demos/layout-groups/_create-toolbar.ts b/documentation-site/src/pages/demos/layout-groups/_create-toolbar.ts new file mode 100644 index 00000000..78364844 --- /dev/null +++ b/documentation-site/src/pages/demos/layout-groups/_create-toolbar.ts @@ -0,0 +1,95 @@ +import { addParentComponent, addPositionComponent } from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + Color, + createImageSprite, + RenderContext, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { + FontAtlas, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + addHorizontalLayoutGroupComponent, + addRectTransformComponent, + createLabel, + createPanel, + UiAnchor, +} from '@forge-game-engine/forge/ui'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const iconColors = [ + new Color(0.85, 0.35, 0.35, 1), + new Color(0.35, 0.65, 0.85, 1), + new Color(0.45, 0.75, 0.45, 1), + new Color(0.85, 0.65, 0.3, 1), +]; + +/** + * Builds a "Toolbar" panel: a `HorizontalLayoutGroupEcsComponent` spacing + * and evenly resizing a row of plain colored icons, with no + * `anchoredPosition`/`sizeDelta` of their own - reordering, adding, or + * removing an icon needs no other change, since the group recomputes the + * row every frame. + * @param world - The ECS world to create the toolbar entities in. + * @param renderContext - The render context the icon sprites are built against. + * @param canvas - The canvas entity to parent the toolbar panel to. + * @param fontAtlas - The font atlas the title label is drawn from. + * @param panelSprite - The nine-sliced sprite the panel is drawn with. + * @param uiCategory - The render category the canvas's camera culls to. + */ +export async function createToolbar( + world: EcsWorld, + renderContext: RenderContext, + canvas: number, + fontAtlas: FontAtlas, + panelSprite: SpriteEcsComponent, + uiCategory: number, +): Promise { + createLabel(world, canvas, { + text: 'Toolbar', + fontAtlas, + size: 24, + anchor: UiAnchor.topRight, + anchoredPosition: { x: -60, y: -50 }, + sizeDelta: { x: 400, y: 32 }, + horizontalAlign: textHorizontalAlignments.right, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: uiCategory, + }); + + const panel = createPanel(world, canvas, { + anchor: UiAnchor.topRight, + anchoredPosition: { x: -60, y: -90 }, + sizeDelta: { x: 400, y: 110 }, + sprite: panelSprite, + }); + + addHorizontalLayoutGroupComponent(world, panel, { + padding: { left: 20, right: 20, top: 20, bottom: 20 }, + spacing: 16, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + + for (const tintColor of iconColors) { + const icon = world.createEntity(); + + addPositionComponent(world, icon); + addParentComponent(world, icon, { parent: panel }); + addRectTransformComponent(world, icon, { sizeDelta: { x: 70, y: 70 } }); + + const sprite = createImageSprite(whiteImage, renderContext, { + layer: uiCategory, + }); + sprite.tintColor = tintColor; + + addSpriteComponent(world, icon, sprite); + } +} diff --git a/documentation-site/src/pages/demos/layout-groups/index.tsx b/documentation-site/src/pages/demos/layout-groups/index.tsx new file mode 100644 index 00000000..d5b7a6af --- /dev/null +++ b/documentation-site/src/pages/demos/layout-groups/index.tsx @@ -0,0 +1,37 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { createLayoutGroupsGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import menuCode from '!!raw-loader!./_create-menu'; +import toolbarCode from '!!raw-loader!./_create-toolbar'; +import inventoryGridCode from '!!raw-loader!./_create-inventory-grid'; + +import { Demo } from '@site/src/components/Demo'; + +export default function LayoutGroups(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createLayoutGroupsGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + ); +} diff --git a/documentation-site/src/pages/demos/ui/_create-game.ts b/documentation-site/src/pages/demos/ui/_create-game.ts index 1ceb2c2b..79158709 100644 --- a/documentation-site/src/pages/demos/ui/_create-game.ts +++ b/documentation-site/src/pages/demos/ui/_create-game.ts @@ -1,5 +1,4 @@ import { - addParentComponent, addPositionComponent, createTransformEcsSystem, Time, @@ -38,8 +37,6 @@ import { textVerticalAlignments, } from '@forge-game-engine/forge/text'; import { - addHorizontalLayoutGroupComponent, - addRectTransformComponent, createButton, createDropdown, createLabel, @@ -48,7 +45,6 @@ import { createSlider, createToggle, createUiCanvas, - uiAlignments, UiAnchor, UiProgressBarEcsComponent, } from '@forge-game-engine/forge/ui'; @@ -345,97 +341,14 @@ async function createSettingsPanel( return healthBar.progressBar; } -/** - * Builds a "Difficulty" panel showcasing `addHorizontalLayoutGroupComponent` - * (Phase 4 of the UI design): unlike every other control in this demo, the - * three buttons below get no `anchoredPosition`/`sizeDelta` of their own at - * all - `createUiLayoutGroupEcsSystem` spaces and evenly resizes them every - * frame purely from the row entity's `HorizontalLayoutGroupEcsComponent`. - */ -function createLayoutGroupPanel( - world: EcsWorld, - canvas: number, - fontAtlas: FontAtlas, - panelSprite: SpriteEcsComponent, -): void { - const panel = createPanel(world, canvas, { - // Stacked below the score panel, in the same left column - unlike the - // bottom-center cluster (Play button, click counter), this spot's - // horizontal clearance barely changes as the canvas's aspect ratio - // narrows (scaleWithScreenSize keeps height pinned; only width - // shrinks), since nothing else anchors to the top-left. - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 20, y: -260 }, - sizeDelta: { x: 320, y: 160 }, - sprite: panelSprite, - }); - - createLabel(world, panel, { - text: 'Difficulty', - fontAtlas, - size: 28, - anchor: UiAnchor.stretchTopLeft, - // See the "Settings" label's own comment above for why this is needed. - sizeDelta: { x: 0, y: 0 }, - anchoredPosition: { x: 0, y: -28 }, - horizontalAlign: textHorizontalAlignments.center, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - - // A plain RectTransform - no sprite, so it draws nothing itself - purely - // to give the HorizontalLayoutGroupEcsComponent below a content box (the - // panel's own width, minus a small margin) to arrange the three buttons - // within. - const row = world.createEntity(); - - addPositionComponent(world, row); - addParentComponent(world, row, { parent: panel }); - addRectTransformComponent(world, row, { - ...UiAnchor.stretchBottom, - sizeDelta: { x: -40, y: 64 }, - anchoredPosition: { x: 0, y: 24 }, - }); - addHorizontalLayoutGroupComponent(world, row, { - spacing: 16, - childAlignment: uiAlignments.center, - }); - - const buttonTransition = { - normalColor: Color.white, - hoverColor: new Color(0.85, 0.85, 0.85, 1), - pressedColor: new Color(0.65, 0.65, 0.65, 1), - disabledColor: new Color(0.5, 0.5, 0.5, 0.6), - }; - - for (const label of ['Easy', 'Medium', 'Hard']) { - createButton(world, row, { - sprite: panelSprite, - label, - fontAtlas, - labelSize: 22, - labelColor: textColor, - labelCategory: renderLayers.ui, - // Deliberately narrower than the row's own content box - the - // HorizontalLayoutGroupEcsComponent's default childForceExpandWidth - // stretches all three evenly to fill the remaining space, which a - // sizeDelta already matching the box exactly wouldn't demonstrate. - sizeDelta: { x: 64, y: 56 }, - transition: buttonTransition, - }); - } -} - /** * Builds the UI interaction demo: a "game world" (a plain tinted backdrop, * drawn by its own camera/culling mask) with a HUD overlaid on top of it * through a second, dedicated UI camera - a full-width top bar, a - * corner-anchored score panel, a hoverable, clickable, keyboard/gamepad- + * corner-anchored score panel, and a hoverable, clickable, keyboard/gamepad- * focus-navigable `Play` button (see `createButton`) that increments a * click counter on `onInvoke`, whichever path raised it (pointer or - * `submitInput`), a "Settings" panel showcasing the Phase 3 controls, and a - * "Difficulty" panel showcasing `addHorizontalLayoutGroupComponent`. + * `submitInput`). * @param fontAtlasUrl - The URL of the font atlas JSON to load. * @returns The created game. */ @@ -548,8 +461,6 @@ export const createUiDemoGame = async (fontAtlasUrl: string): Promise => { panelSprite, ); - createLayoutGroupPanel(world, canvas, fontAtlas, panelSprite); - let clickCount = 0; const clickLabelWidth = 400; diff --git a/documentation-site/src/pages/demos/ui/index.tsx b/documentation-site/src/pages/demos/ui/index.tsx index 209b0ffb..4e24a477 100644 --- a/documentation-site/src/pages/demos/ui/index.tsx +++ b/documentation-site/src/pages/demos/ui/index.tsx @@ -34,7 +34,7 @@ export default function Ui(): JSX.Element { 'A HUD built with the ui module: a full-width top bar, a corner-anchored score panel, and a hoverable, clickable, keyboard/gamepad-focus-navigable Play button, layered over a game world through a dedicated UI camera.', }} header="UI" - blurb="A dedicated, static UI camera (createUiCanvas) composites a HUD over the tinted 'game world' backdrop drawn by an ordinary world camera - the two are isolated from each other by culling mask, so the HUD is never drawn twice and never shows up in the world. The top bar and score panel resolve their layout fresh every frame from the canvas's current aspect ratio, so toggling fullscreen keeps both exactly where they should be. The Play button (createButton) is fully interactive: click it, or focus-navigate to it with the arrow keys and press Enter/Space - either path raises the same onInvoke, which increments the click counter below it. Its color eases between normal/hover/pressed tints via createUiTransitionEcsSystem, and hovering it also focuses it, so the highlight follows the mouse the same way it follows the keyboard. The 'Difficulty' panel below the score panel shows a HorizontalLayoutGroupEcsComponent spacing and evenly resizing three buttons automatically, with no anchoredPosition/sizeDelta bookkeeping of their own." + blurb="A dedicated, static UI camera (createUiCanvas) composites a HUD over the tinted 'game world' backdrop drawn by an ordinary world camera - the two are isolated from each other by culling mask, so the HUD is never drawn twice and never shows up in the world. The top bar and score panel resolve their layout fresh every frame from the canvas's current aspect ratio, so toggling fullscreen keeps both exactly where they should be. The Play button (createButton) is fully interactive: click it, or focus-navigate to it with the arrow keys and press Enter/Space - either path raises the same onInvoke, which increments the click counter below it. Its color eases between normal/hover/pressed tints via createUiTransitionEcsSystem, and hovering it also focuses it, so the highlight follows the mouse the same way it follows the keyboard." createGame={createGame} interactions={ <> diff --git a/src/ui/systems/ui-layout-group-system.test.ts b/src/ui/systems/ui-layout-group-system.test.ts index 64f210b3..9d7671f7 100644 --- a/src/ui/systems/ui-layout-group-system.test.ts +++ b/src/ui/systems/ui-layout-group-system.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest'; import { createUiLayoutGroupEcsSystem } from './ui-layout-group-system.js'; -import { addParentComponent } from '../../common/index.js'; +import { createUiLayoutEcsSystem } from './ui-layout-system.js'; +import { + addParentComponent, + addPositionComponent, +} from '../../common/index.js'; import { EcsWorld } from '../../ecs/index.js'; +import { RenderContext } from '../../rendering/index.js'; import { addContentSizeFitterComponent } from '../components/content-size-fitter-component.js'; import { addLayoutElementComponent } from '../components/layout-element-component.js'; import { @@ -687,4 +692,63 @@ describe('createUiLayoutGroupEcsSystem', () => { expect(siblingRect.sizeDelta.x).toBeCloseTo(100); }); }); + + describe('stability across frames', () => { + it("doesn't oscillate when a ContentSizeFitterEcsComponent and a force-expanding cross axis share an entity", () => { + // Regression test: a group's own RectTransformEcsComponent.rect is one + // frame stale by design (see this system's own doc comment), so it + // starts at Rects.zero on a brand-new entity - subtracting padding + // from that gives a *negative* inner cross size on the very first + // frame. Force-expanding a child to fill that negative size used to + // write a negative sizeDelta into it with no floor; a + // ContentSizeFitterEcsComponent on the same group then measured that + // corrupted child size and fed it back into the group's own size the + // next frame - a permanent oscillation between the corrupted and the + // correct size, never converging, rather than a one-frame hiccup. + const world = new EcsWorld(); + const renderContext = { width: 1920, height: 1080 } as RenderContext; + + const canvas = world.createEntity(); + + addPositionComponent(world, canvas); + addRectTransformComponent(world, canvas); + + const panel = createGroupEntity(world, 0, 0); + + addPositionComponent(world, panel); + addParentComponent(world, panel, { parent: canvas }); + addVerticalLayoutGroupComponent(world, panel, { + padding: { left: 24, right: 24, top: 64, bottom: 24 }, + spacing: 16, + }); + addContentSizeFitterComponent(world, panel, { + horizontalFit: 'preferredSize', + verticalFit: 'preferredSize', + }); + + createChild(world, panel, { x: 220, y: 56 }); + createChild(world, panel, { x: 220, y: 56 }); + createChild(world, panel, { x: 220, y: 56 }); + + const layoutGroup = createUiLayoutGroupEcsSystem(); + const layout = createUiLayoutEcsSystem(renderContext); + + world.addSystem(layoutGroup); + world.addSystem(layout, { after: [layoutGroup] }); + + const widths: number[] = []; + + for (let i = 0; i < 6; i++) { + world.update(); + + const panelRect = world.getComponent(panel, rectTransformId)!; + + widths.push(panelRect.rect.max.x - panelRect.rect.min.x); + } + + // width = max child preferred width (220) + padding (24 + 24) = 268, + // every frame from the very first one onward. + expect(widths).toEqual([268, 268, 268, 268, 268, 268]); + }); + }); }); diff --git a/src/ui/systems/ui-layout-group-system.ts b/src/ui/systems/ui-layout-group-system.ts index 0cef5595..d98a5030 100644 --- a/src/ui/systems/ui-layout-group-system.ts +++ b/src/ui/systems/ui-layout-group-system.ts @@ -325,7 +325,25 @@ function distributeExtraSpace( } } -/** A child's cross-axis size: force-expanded to fill the whole cross axis, its own measured preferred size, or its own current size, per the group's control/force-expand flags. */ +/** + * A child's cross-axis size: force-expanded to fill the whole cross axis + * (but never shrunk below the child's own measured preferred size - the + * same "grow, never shrink" floor the main axis's own leftover-space + * distribution already enforces via its `Math.max(0, ...)` clamp), its own + * measured preferred size, or its own current size, per the group's + * control/force-expand flags. + * + * The floor matters beyond just "don't shrink": `innerCross` is computed + * from this group's own rect as of the *previous* frame (`rect` is one + * frame stale by design - see this file's own system doc comment), which + * starts at `Rects.zero` for a brand-new entity and so is negative here + * once padding is subtracted. Without the floor, that transient negative + * value would get written into the child's own `sizeDelta`, which a + * `ContentSizeFitterEcsComponent` on the *group* elsewhere in this same + * tree could then measure and feed back into the group's own size next + * frame - a self-sustaining, permanent oscillation between the corrupted + * and correct size, never converging, rather than a one-frame hiccup. + */ function crossSizeOf( crossControl: boolean, crossForceExpand: boolean, @@ -339,7 +357,7 @@ function crossSizeOf( } if (crossForceExpand) { - return innerCross; + return Math.max(crossMeasure.preferred, innerCross); } return crossMeasure.preferred; @@ -684,6 +702,24 @@ export const createUiLayoutGroupEcsSystem = (): EcsSystem< const measure = createMeasure(world, childrenByParent); + // Measuring is a pure, read-only pass; arranging mutates sizeDelta - + // the very field a plain (non-group) entity's own measure() falls back + // to reading. Warming the cache for every entity here, before any + // arrange/fit call below can mutate anything, guarantees every measure() + // call from this point on is a cache hit rather than a fresh + // computation - so an entity's measured size always reflects its + // pre-arrangement state for the rest of this tick, never a sibling + // group's (or its own) already-mutated one. Skipping this and instead + // measuring lazily, interleaved with arrangement, self-corrupts for any + // entity that is both arranged by a group and re-measured later in the + // same tick (e.g. a ContentSizeFitterEcsComponent on the same entity as + // the group whose children it measures) - the two disagree on which of + // two possible sizes is current, oscillating between them forever + // rather than converging. + for (const entity of entities) { + measure(entity); + } + for (const entity of entities) { const axisGroup = world.getComponent( entity, From 6b9c85da0faa821905648750f92c4dea697d3ae4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Aug 2026 21:36:13 +0000 Subject: [PATCH 5/5] docs(demos): split the monolithic ui demo into focused per-feature demos Replace the single demos/ui page (which mixed anchors, buttons, toggles, sliders, a progress bar, and a dropdown into one crowded scene) with six standalone demos - ui-anchors, ui-button, ui-toggle, ui-slider, ui-progress-bar, and ui-dropdown - each isolating one UI feature with its own interaction instructions, alongside the existing layout-groups demo. Updates the docs site navbar accordingly. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01K9ju4fbCZYfAQmDhSBhRmH --- documentation-site/docusaurus.config.ts | 26 +- .../pages/demos/ui-anchors/_create-game.ts | 182 ++++++ .../src/pages/demos/ui-anchors/index.tsx | 51 ++ .../src/pages/demos/ui-button/_create-game.ts | 233 ++++++++ .../src/pages/demos/ui-button/index.tsx | 67 +++ .../pages/demos/ui-dropdown/_create-game.ts | 143 +++++ .../src/pages/demos/ui-dropdown/index.tsx | 51 ++ .../demos/ui-progress-bar/_create-game.ts | 142 +++++ .../demos/ui-progress-bar/_pulse.component.ts | 16 + .../demos/ui-progress-bar/_pulse.system.ts | 35 ++ .../src/pages/demos/ui-progress-bar/index.tsx | 35 ++ .../src/pages/demos/ui-slider/_create-game.ts | 179 ++++++ .../src/pages/demos/ui-slider/index.tsx | 51 ++ .../src/pages/demos/ui-toggle/_create-game.ts | 204 +++++++ .../src/pages/demos/ui-toggle/index.tsx | 51 ++ .../src/pages/demos/ui/_create-game.ts | 516 ------------------ .../src/pages/demos/ui/index.tsx | 82 --- 17 files changed, 1463 insertions(+), 601 deletions(-) create mode 100644 documentation-site/src/pages/demos/ui-anchors/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-anchors/index.tsx create mode 100644 documentation-site/src/pages/demos/ui-button/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-button/index.tsx create mode 100644 documentation-site/src/pages/demos/ui-dropdown/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-dropdown/index.tsx create mode 100644 documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-progress-bar/_pulse.component.ts create mode 100644 documentation-site/src/pages/demos/ui-progress-bar/_pulse.system.ts create mode 100644 documentation-site/src/pages/demos/ui-progress-bar/index.tsx create mode 100644 documentation-site/src/pages/demos/ui-slider/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-slider/index.tsx create mode 100644 documentation-site/src/pages/demos/ui-toggle/_create-game.ts create mode 100644 documentation-site/src/pages/demos/ui-toggle/index.tsx delete mode 100644 documentation-site/src/pages/demos/ui/_create-game.ts delete mode 100644 documentation-site/src/pages/demos/ui/index.tsx diff --git a/documentation-site/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index c00f54f1..2ff4c643 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -200,12 +200,32 @@ const config: Config = { label: 'Text Rendering', }, { - to: 'demos/ui', - label: 'UI', + to: 'demos/ui-anchors', + label: 'UI Anchors', + }, + { + to: 'demos/ui-button', + label: 'UI Buttons', + }, + { + to: 'demos/ui-toggle', + label: 'UI Toggles', + }, + { + to: 'demos/ui-slider', + label: 'UI Slider', + }, + { + to: 'demos/ui-progress-bar', + label: 'UI Progress Bar', + }, + { + to: 'demos/ui-dropdown', + label: 'UI Dropdown', }, { to: 'demos/layout-groups', - label: 'Layout Groups', + label: 'UI Layout Groups', }, ], }, diff --git a/documentation-site/src/pages/demos/ui-anchors/_create-game.ts b/documentation-site/src/pages/demos/ui-anchors/_create-game.ts new file mode 100644 index 00000000..a06e6739 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-anchors/_create-game.ts @@ -0,0 +1,182 @@ +import { + addPositionComponent, + createTransformEcsSystem, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlasCache, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + createLabel, + createPanel, + createUiCanvas, + UiAnchor, + UiAnchorPreset, +} from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +// Forge doesn't ship a reserved "UI" render category - each game picks its +// own bit and reuses it for the UI canvas's cullingMask and every UI +// visual's own category, so it's this demo's choice, not the engine's, +// which bit separates the world camera from the UI camera. +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +const textColor = new Color(0.12, 0.12, 0.16, 1); + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +/** + * Builds the anchors demo: five panels, each labeled with the `UiAnchor` + * preset that places it - a full-width top bar (`stretchTop`), and four + * corner-pinned panels (`topLeft`/`topRight`/`bottomLeft`/`bottomRight`), + * plus one centered panel (`center`). Every panel resolves its layout fresh + * every frame from the canvas's current aspect ratio, so toggling + * fullscreen keeps all of them exactly where their anchor says they should + * be, at any window shape. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createAnchorsGame = async (fontAtlasUrl: string): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + }); + + const panelImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/kenney_fantasy-ui-borders/PNG/Double/Panel/panel-030.png'), + ); + const panelSprite = createImageSprite(panelImage, renderContext, { + layer: renderLayers.ui, + slices: { + left: 26, + right: 26, + top: 26, + bottom: 26, + nativeWidth: 96, + nativeHeight: 96, + }, + }); + + const labeledPanel = ( + text: string, + anchor: UiAnchorPreset, + anchoredPosition: { x: number; y: number }, + sizeDelta: { x: number; y: number }, + ): void => { + const panel = createPanel(world, canvas, { + anchor, + anchoredPosition, + sizeDelta, + sprite: panelSprite, + }); + + createLabel(world, panel, { + text, + fontAtlas, + size: 24, + anchor: UiAnchor.stretchAll, + sizeDelta: { x: 0, y: 0 }, + horizontalAlign: textHorizontalAlignments.center, + verticalAlign: textVerticalAlignments.middle, + color: textColor, + category: renderLayers.ui, + }); + }; + + labeledPanel( + 'stretchTop', + UiAnchor.stretchTop, + { x: 0, y: -20 }, + { x: -40, y: 96 }, + ); + labeledPanel( + 'topLeft', + UiAnchor.topLeft, + { x: 20, y: -140 }, + { x: 260, y: 96 }, + ); + labeledPanel( + 'topRight', + UiAnchor.topRight, + { x: -20, y: -140 }, + { x: 260, y: 96 }, + ); + labeledPanel( + 'bottomLeft', + UiAnchor.bottomLeft, + { x: 20, y: 20 }, + { x: 260, y: 96 }, + ); + labeledPanel( + 'bottomRight', + UiAnchor.bottomRight, + { x: -20, y: 20 }, + { x: 260, y: 96 }, + ); + labeledPanel('center', UiAnchor.center, { x: 0, y: 0 }, { x: 260, y: 96 }); + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-anchors/index.tsx b/documentation-site/src/pages/demos/ui-anchors/index.tsx new file mode 100644 index 00000000..dd5c75bb --- /dev/null +++ b/documentation-site/src/pages/demos/ui-anchors/index.tsx @@ -0,0 +1,51 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { createAnchorsGame } 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, +}; + +export default function UiAnchors(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createAnchorsGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + + + } + text="Toggle fullscreen to see every panel hold its anchor at a different aspect ratio." + /> + } + codeFiles={[{ name: 'game.ts', content: gameCode }]} + /> + ); +} diff --git a/documentation-site/src/pages/demos/ui-button/_create-game.ts b/documentation-site/src/pages/demos/ui-button/_create-game.ts new file mode 100644 index 00000000..f9b46880 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-button/_create-game.ts @@ -0,0 +1,233 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + actionResetTypes, + Axis2dAction, + buttonMoments, + KeyboardAxis2dBinding, + keyCodes, + KeyboardInputSource, + KeyboardTriggerBinding, + MouseInputSource, + registerInputs, + TriggerAction, +} from '@forge-game-engine/forge/input'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, + textHorizontalAlignments, + textId, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { createButton, createLabel, createUiCanvas, UiAnchor } from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +const textColor = new Color(0.12, 0.12, 0.16, 1); + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +/** + * Wires a `MouseInputSource` and a `KeyboardInputSource` (arrow keys to + * navigate focus, Enter/Space to submit) so the three buttons below are + * both clickable and gamepad/keyboard-focus-navigable. + */ +function createUiInputs( + world: EcsWorld, + time: Time, + game: Game, +): { + mouseInputSource: MouseInputSource; + submitInput: TriggerAction; + navigateInput: Axis2dAction; +} { + const submitInput = new TriggerAction('ui-submit'); + const navigateInput = new Axis2dAction( + 'ui-navigate', + undefined, + actionResetTypes.noReset, + ); + + const inputManager = registerInputs(world, time, { + triggerActions: [submitInput], + axis2dActions: [navigateInput], + }); + + const mouseInputSource = new MouseInputSource(inputManager, game.container); + const keyboardInputSource = new KeyboardInputSource(inputManager); + + keyboardInputSource.axis2dBindings.add( + new KeyboardAxis2dBinding( + navigateInput, + keyCodes.arrowUp, + keyCodes.arrowDown, + keyCodes.arrowRight, + keyCodes.arrowLeft, + ), + ); + + keyboardInputSource.triggerBindings.add( + new KeyboardTriggerBinding(submitInput, keyCodes.enter, buttonMoments.down), + ); + keyboardInputSource.triggerBindings.add( + new KeyboardTriggerBinding(submitInput, keyCodes.space, buttonMoments.down), + ); + + return { mouseInputSource, submitInput, navigateInput }; +} + +/** + * Builds the button demo: three stacked buttons (`createButton`), each + * hoverable, clickable, and keyboard/gamepad-focus-navigable - the arrow + * keys move focus between them (top-to-bottom, matching their on-screen + * order), and Enter/Space (or a click) raises the same `onInvoke` either + * path, updating a status label with which one was last invoked. Hovering + * a button also focuses it, so the highlight follows the mouse the same + * way it follows the keyboard. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createButtonGame = async (fontAtlasUrl: string): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const { mouseInputSource, submitInput, navigateInput } = createUiInputs( + world, + time, + game, + ); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + pointerSource: mouseInputSource, + submitInput, + navigateInput, + }); + + const panelImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/kenney_fantasy-ui-borders/PNG/Double/Panel/panel-030.png'), + ); + const panelSprite = createImageSprite(panelImage, renderContext, { + layer: renderLayers.ui, + slices: { + left: 26, + right: 26, + top: 26, + bottom: 26, + nativeWidth: 96, + nativeHeight: 96, + }, + }); + + const buttonTransition = { + normalColor: Color.white, + hoverColor: new Color(0.85, 0.85, 0.85, 1), + pressedColor: new Color(0.65, 0.65, 0.65, 1), + disabledColor: new Color(0.5, 0.5, 0.5, 0.6), + }; + + const statusLabel = createLabel(world, canvas, { + text: 'Click, or focus-navigate to, a button below', + fontAtlas, + size: 26, + anchor: UiAnchor.topCenter, + anchoredPosition: { x: 0, y: -60 }, + sizeDelta: { x: 600, y: 40 }, + horizontalAlign: textHorizontalAlignments.center, + verticalAlign: textVerticalAlignments.middle, + maxWidth: 600, + color: Color.white, + category: renderLayers.ui, + }); + const statusText = world.getComponent(statusLabel, textId)!; + + const buttonY = [80, 0, -80]; + const labels = ['Play', 'Options', 'Quit']; + + for (let i = 0; i < labels.length; i++) { + const button = createButton(world, canvas, { + anchor: UiAnchor.center, + anchoredPosition: { x: 0, y: buttonY[i] }, + sizeDelta: { x: 260, y: 64 }, + sprite: panelSprite, + label: labels[i], + fontAtlas, + labelSize: 26, + labelColor: textColor, + labelCategory: renderLayers.ui, + transition: buttonTransition, + }); + + const label = labels[i]; + + button.onInvoke.registerListener(() => { + statusText.text = `Invoked: ${label}`; + }); + } + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-button/index.tsx b/documentation-site/src/pages/demos/ui-button/index.tsx new file mode 100644 index 00000000..2157708e --- /dev/null +++ b/documentation-site/src/pages/demos/ui-button/index.tsx @@ -0,0 +1,67 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +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, +}; + +export default function UiButton(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createButtonGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + + + + } + text="Click a button." + /> + + + + + } + text="Move focus between buttons." + /> + } + text="Activate the focused button." + /> + + } + codeFiles={[{ name: 'game.ts', content: gameCode }]} + /> + ); +} diff --git a/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts b/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts new file mode 100644 index 00000000..9ce0157b --- /dev/null +++ b/documentation-site/src/pages/demos/ui-dropdown/_create-game.ts @@ -0,0 +1,143 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { MouseInputSource, registerInputs } from '@forge-game-engine/forge/input'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, +} from '@forge-game-engine/forge/text'; +import { createDropdown, createUiCanvas, UiAnchor } from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +const textColor = new Color(0.12, 0.12, 0.16, 1); + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +function createPointerInput( + world: EcsWorld, + time: Time, + game: Game, +): MouseInputSource { + const inputManager = registerInputs(world, time, {}); + + return new MouseInputSource(inputManager, game.container); +} + +/** + * Builds the dropdown demo: a single `createDropdown` showing the selected + * option in its header, with a click-to-open list of option rows below it - + * each an ordinary `createButton`. Selecting an option updates the header's + * label, raises `onValueChanged`, and closes the list. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createDropdownGame = async ( + fontAtlasUrl: string, +): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const mouseInputSource = createPointerInput(world, time, game); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + pointerSource: mouseInputSource, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + + const boxColor = new Color(0.85, 0.85, 0.88, 1); + const boxSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.ui, + }); + boxSprite.tintColor = boxColor; + + const boxTransition = { + normalColor: boxColor, + hoverColor: new Color(0.78, 0.78, 0.83, 1), + pressedColor: new Color(0.68, 0.68, 0.75, 1), + disabledColor: new Color(0.6, 0.6, 0.6, 0.6), + }; + + createDropdown(world, canvas, { + headerSprite: boxSprite, + optionSprite: boxSprite, + options: ['Low', 'Medium', 'High', 'Ultra'], + fontAtlas, + labelColor: textColor, + labelCategory: renderLayers.ui, + anchor: UiAnchor.center, + anchoredPosition: { x: 0, y: 100 }, + sizeDelta: { x: 320, y: 56 }, + selectedIndex: 2, + transition: boxTransition, + }); + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-dropdown/index.tsx b/documentation-site/src/pages/demos/ui-dropdown/index.tsx new file mode 100644 index 00000000..3c7814b5 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-dropdown/index.tsx @@ -0,0 +1,51 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +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, +}; + +export default function UiDropdown(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createDropdownGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + + + } + text="Click the dropdown to open it, then pick an option." + /> + } + codeFiles={[{ name: 'game.ts', content: gameCode }]} + /> + ); +} diff --git a/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts b/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts new file mode 100644 index 00000000..bcbd4514 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-progress-bar/_create-game.ts @@ -0,0 +1,142 @@ +import { + addPositionComponent, + createTransformEcsSystem, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { createLabel, createProgressBar, createUiCanvas, UiAnchor } from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { pulseId } from './_pulse.component'; +import { createPulseEcsSystem } from './_pulse.system'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +/** + * Builds the progress bar demo: a single `createProgressBar` health bar, + * driven purely by a `value` write from `_pulse.system.ts` (not by any + * player input, since a progress bar reports state rather than accepting + * it) - `createUiProgressBarEcsSystem` picks up the write the same frame + * it's made, unlike a slider, which has no such guarantee. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createProgressBarGame = async ( + fontAtlasUrl: string, +): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const healthFillImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/Burn_Gradient.png'), + ); + + const trackSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.ui, + }); + trackSprite.tintColor = new Color(0.85, 0.85, 0.88, 1); + + createLabel(world, canvas, { + text: 'Health', + fontAtlas, + size: 28, + anchor: UiAnchor.center, + anchoredPosition: { x: -230, y: 60 }, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: renderLayers.ui, + }); + + const health = createProgressBar(world, canvas, { + trackSprite, + fillSprite: createImageSprite(healthFillImage, renderContext, { + layer: renderLayers.ui, + }), + anchor: UiAnchor.center, + anchoredPosition: { x: 0, y: 0 }, + sizeDelta: { x: 460, y: 28 }, + minValue: 0, + maxValue: 100, + value: 100, + }); + + world.addComponent(health.entity, pulseId, { + minValue: 0, + maxValue: 100, + speed: 0.15, + }); + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createPulseEcsSystem(time)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-progress-bar/_pulse.component.ts b/documentation-site/src/pages/demos/ui-progress-bar/_pulse.component.ts new file mode 100644 index 00000000..e97ebae2 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-progress-bar/_pulse.component.ts @@ -0,0 +1,16 @@ +import { createComponentId } from '@forge-game-engine/forge/ecs'; + +/** + * Demo-only component driving `_pulse.system.ts`: oscillates a + * `UiProgressBarEcsComponent.value` between `minValue` and `maxValue` over + * time, so the progress bar demo has something to show without needing any + * player interaction. + */ +export interface PulseComponent { + minValue: number; + maxValue: number; + /** Full empty-to-full-to-empty cycles per second. */ + speed: number; +} + +export const pulseId = createComponentId('pulse'); diff --git a/documentation-site/src/pages/demos/ui-progress-bar/_pulse.system.ts b/documentation-site/src/pages/demos/ui-progress-bar/_pulse.system.ts new file mode 100644 index 00000000..22e99267 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-progress-bar/_pulse.system.ts @@ -0,0 +1,35 @@ +import { Time } from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + UiProgressBarEcsComponent, + uiProgressBarId, +} from '@forge-game-engine/forge/ui'; +import { PulseComponent, pulseId } from './_pulse.component'; + +/** + * Creates a system that drives every `PulseComponent`'s paired + * `UiProgressBarEcsComponent.value` through a triangle-wave oscillation + * between `minValue` and `maxValue`, purely so this demo has a moving value + * to show without needing any player interaction. + * @param time - The time instance driving the oscillation. + * @returns The pulse ECS system. + */ +export const createPulseEcsSystem = ( + time: Time, +): EcsSystem<[PulseComponent, UiProgressBarEcsComponent]> => ({ + name: 'pulse', + query: [pulseId, uiProgressBarId], + update: (_world, { components: [pulses, progressBars] }) => { + for (let i = 0; i < pulses.length; i++) { + const pulse = pulses[i]; + const progressBar = progressBars[i]; + const range = pulse.maxValue - pulse.minValue; + + // Triangle wave in [0, 1]: ramps up, then back down, forever. + const phase = (time.timeInSeconds * pulse.speed) % 1; + const triangle = phase < 0.5 ? phase * 2 : 2 - phase * 2; + + progressBar.value = pulse.minValue + triangle * range; + } + }, +}); diff --git a/documentation-site/src/pages/demos/ui-progress-bar/index.tsx b/documentation-site/src/pages/demos/ui-progress-bar/index.tsx new file mode 100644 index 00000000..1b1f7280 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-progress-bar/index.tsx @@ -0,0 +1,35 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { createProgressBarGame } from './_create-game'; +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'; + +export default function UiProgressBar(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createProgressBarGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + ); +} diff --git a/documentation-site/src/pages/demos/ui-slider/_create-game.ts b/documentation-site/src/pages/demos/ui-slider/_create-game.ts new file mode 100644 index 00000000..41d3a4d0 --- /dev/null +++ b/documentation-site/src/pages/demos/ui-slider/_create-game.ts @@ -0,0 +1,179 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { MouseInputSource, registerInputs } from '@forge-game-engine/forge/input'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, + textId, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { createLabel, createSlider, createUiCanvas, UiAnchor } from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +function createPointerInput( + world: EcsWorld, + time: Time, + game: Game, +): MouseInputSource { + const inputManager = registerInputs(world, time, {}); + + return new MouseInputSource(inputManager, game.container); +} + +/** + * Builds the slider demo: a single `createSlider` track with a handle and + * fill, driving a live value label. The whole track is the drag surface - + * clicking anywhere on it, not just the handle, jumps the handle there. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createSliderGame = async (fontAtlasUrl: string): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const mouseInputSource = createPointerInput(world, time, game); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + pointerSource: mouseInputSource, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const handleImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/kenney_puzzle-pack-2/PNG/Coins/coin_01.png'), + ); + + const trackColor = new Color(0.85, 0.85, 0.88, 1); + const accentColor = new Color(0.35, 0.55, 0.95, 1); + + const trackSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.ui, + }); + trackSprite.tintColor = trackColor; + + const fillSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.ui, + }); + fillSprite.tintColor = accentColor; + + const trackTransition = { + normalColor: trackColor, + hoverColor: new Color(0.78, 0.78, 0.83, 1), + pressedColor: new Color(0.68, 0.68, 0.75, 1), + disabledColor: new Color(0.6, 0.6, 0.6, 0.6), + }; + + createLabel(world, canvas, { + text: 'Volume', + fontAtlas, + size: 28, + anchor: UiAnchor.center, + anchoredPosition: { x: -260, y: 60 }, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: renderLayers.ui, + }); + + const valueLabel = createLabel(world, canvas, { + text: '75', + fontAtlas, + size: 28, + anchor: UiAnchor.center, + anchoredPosition: { x: 260, y: 60 }, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: renderLayers.ui, + }); + const valueText = world.getComponent(valueLabel, textId)!; + + const slider = createSlider(world, canvas, { + trackSprite, + handleSprite: createImageSprite(handleImage, renderContext, { + layer: renderLayers.ui, + }), + fillSprite, + anchor: UiAnchor.center, + anchoredPosition: { x: 0, y: 0 }, + sizeDelta: { x: 500, y: 28 }, + minValue: 0, + maxValue: 100, + value: 75, + wholeNumbers: true, + transition: trackTransition, + }); + + slider.onValueChanged.registerListener((value) => { + valueText.text = `${value}`; + }); + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-slider/index.tsx b/documentation-site/src/pages/demos/ui-slider/index.tsx new file mode 100644 index 00000000..521f17cc --- /dev/null +++ b/documentation-site/src/pages/demos/ui-slider/index.tsx @@ -0,0 +1,51 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +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, +}; + +export default function UiSlider(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createSliderGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + + + } + text="Drag the handle, or click anywhere on the track." + /> + } + codeFiles={[{ name: 'game.ts', content: gameCode }]} + /> + ); +} diff --git a/documentation-site/src/pages/demos/ui-toggle/_create-game.ts b/documentation-site/src/pages/demos/ui-toggle/_create-game.ts new file mode 100644 index 00000000..61d8d62b --- /dev/null +++ b/documentation-site/src/pages/demos/ui-toggle/_create-game.ts @@ -0,0 +1,204 @@ +import { + addPositionComponent, + createTransformEcsSystem, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { MouseInputSource, registerInputs } from '@forge-game-engine/forge/input'; +import { + addSpriteComponent, + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createPresentEcsSystem, + createRenderEcsSystem, + RenderContext, +} from '@forge-game-engine/forge/rendering'; +import { + createTextShapingEcsSystem, + FontAtlas, + FontAtlasCache, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; +import { + addUiToggleGroupComponent, + createLabel, + createToggle, + createUiCanvas, + UiAnchor, +} from '@forge-game-engine/forge/ui'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; + +const renderLayers = { + world: 1 << 0, + ui: 1 << 1, +}; + +const textColor = new Color(0.12, 0.12, 0.16, 1); + +async function createBackdrop( + world: EcsWorld, + renderContext: RenderContext, +): Promise { + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const backdropSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.world, + }); + backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); + + const { x: width, y: height } = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + + backdropSprite.width = width; + backdropSprite.height = height; + + const backdrop = world.createEntity(); + + addPositionComponent(world, backdrop); + addSpriteComponent(world, backdrop, backdropSprite); +} + +function createPointerInput( + world: EcsWorld, + time: Time, + game: Game, +): MouseInputSource { + const inputManager = registerInputs(world, time, {}); + + return new MouseInputSource(inputManager, game.container); +} + +/** + * Builds the toggle demo: a lone checkbox-style toggle (`createToggle`, no + * `group`, flips freely) and a three-way radio group + * (`addUiToggleGroupComponent` shared across three toggles) - clicking one + * radio option turns off whichever was previously on, since a group always + * has exactly one selection (`allowSwitchOff: false`, the default). + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createToggleGame = async (fontAtlasUrl: string): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + cullingMask: renderLayers.world, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + await createBackdrop(world, renderContext); + + const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); + const fontAtlas: FontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); + + const mouseInputSource = createPointerInput(world, time, game); + + const canvas = createUiCanvas(world, renderContext, time, { + cullingMask: renderLayers.ui, + referenceResolution: { x: 1920, y: 1080 }, + pointerSource: mouseInputSource, + }); + + const whiteImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/White.png'), + ); + const crossImage = await renderContext.imageCache.getOrLoad( + getAssetUrl('img/space-shooter/icon_crossSmall.png'), + ); + + const boxColor = new Color(0.85, 0.85, 0.88, 1); + const accentColor = new Color(0.35, 0.55, 0.95, 1); + + const boxSprite = createImageSprite(whiteImage, renderContext, { + layer: renderLayers.ui, + }); + boxSprite.tintColor = boxColor; + + const crossSprite = createImageSprite(crossImage, renderContext, { + layer: renderLayers.ui, + }); + crossSprite.tintColor = accentColor; + + const boxTransition = { + normalColor: boxColor, + hoverColor: new Color(0.78, 0.78, 0.83, 1), + pressedColor: new Color(0.68, 0.68, 0.75, 1), + disabledColor: new Color(0.6, 0.6, 0.6, 0.6), + }; + + const caption = ( + text: string, + anchoredPosition: { x: number; y: number }, + ): void => { + createLabel(world, canvas, { + text, + fontAtlas, + size: 26, + anchor: UiAnchor.topLeft, + anchoredPosition, + verticalAlign: textVerticalAlignments.middle, + color: Color.white, + category: renderLayers.ui, + }); + }; + + caption('Mute', { x: 60, y: -80 }); + + createToggle(world, canvas, { + sprite: { ...boxSprite }, + checkmarkSprite: { ...crossSprite }, + anchor: UiAnchor.topLeft, + anchoredPosition: { x: 240, y: -80 }, + transition: boxTransition, + }); + + caption('Difficulty', { x: 60, y: -180 }); + + const difficultyGroup = world.createEntity(); + + addUiToggleGroupComponent(world, difficultyGroup); + + const difficulties = ['Easy', 'Medium', 'Hard']; + + for (let i = 0; i < difficulties.length; i++) { + createToggle(world, canvas, { + sprite: boxSprite, + checkmarkSprite: crossSprite, + group: difficultyGroup, + isOn: i === 0, + anchor: UiAnchor.topLeft, + anchoredPosition: { x: 240 + i * 90, y: -220 }, + transition: boxTransition, + }); + + createLabel(world, canvas, { + text: difficulties[i], + fontAtlas, + size: 20, + anchor: UiAnchor.topLeft, + anchoredPosition: { x: 236 + i * 90, y: -262 }, + horizontalAlign: textHorizontalAlignments.center, + sizeDelta: { x: 60, y: 30 }, + color: Color.white, + category: renderLayers.ui, + }); + } + + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + world.addSystem(createPresentEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/ui-toggle/index.tsx b/documentation-site/src/pages/demos/ui-toggle/index.tsx new file mode 100644 index 00000000..7744ed6d --- /dev/null +++ b/documentation-site/src/pages/demos/ui-toggle/index.tsx @@ -0,0 +1,51 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +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, +}; + +export default function UiToggle(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createToggleGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + + + } + text="Click a toggle." + /> + } + codeFiles={[{ name: 'game.ts', content: gameCode }]} + /> + ); +} diff --git a/documentation-site/src/pages/demos/ui/_create-game.ts b/documentation-site/src/pages/demos/ui/_create-game.ts deleted file mode 100644 index 79158709..00000000 --- a/documentation-site/src/pages/demos/ui/_create-game.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { - addPositionComponent, - createTransformEcsSystem, - Time, -} from '@forge-game-engine/forge/common'; -import { EcsWorld } from '@forge-game-engine/forge/ecs'; -import { - actionResetTypes, - Axis2dAction, - buttonMoments, - KeyboardAxis2dBinding, - keyCodes, - KeyboardInputSource, - KeyboardTriggerBinding, - MouseInputSource, - registerInputs, - TriggerAction, -} from '@forge-game-engine/forge/input'; -import { - addSpriteComponent, - calculateVisibleWorldSize, - Color, - createCamera, - createCameraEcsSystem, - createImageSprite, - createPresentEcsSystem, - createRenderEcsSystem, - RenderContext, - SpriteEcsComponent, -} from '@forge-game-engine/forge/rendering'; -import { - createTextShapingEcsSystem, - FontAtlas, - FontAtlasCache, - textHorizontalAlignments, - textId, - textVerticalAlignments, -} from '@forge-game-engine/forge/text'; -import { - createButton, - createDropdown, - createLabel, - createPanel, - createProgressBar, - createSlider, - createToggle, - createUiCanvas, - UiAnchor, - UiProgressBarEcsComponent, -} from '@forge-game-engine/forge/ui'; -import { createGame, Game } from '@forge-game-engine/forge/utilities'; -import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; -import { getAssetUrl } from '@site/src/utils/get-asset-url'; - -// Forge doesn't ship a reserved "UI" render category - each game picks its -// own bit and reuses it for the UI canvas's cullingMask and every UI -// visual's own category, so it's this demo's choice, not the engine's, -// which bit separates the world camera from the UI camera. -const renderLayers = { - world: 1 << 0, - ui: 1 << 1, -}; - -// The panel artwork is a flat white fill, so labels need a dark tint to -// read against it. -const textColor = new Color(0.12, 0.12, 0.16, 1); - -/** The panel artwork's own border, in texture pixels (see the nine-slice demo). */ -const panelBorderInset = 26; -const panelNativeSize = 96; - -async function createBackdrop( - world: EcsWorld, - renderContext: RenderContext, -): Promise { - const whiteImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/White.png'), - ); - const backdropSprite = createImageSprite(whiteImage, renderContext, { - layer: renderLayers.world, - }); - backdropSprite.tintColor = new Color(0.09, 0.11, 0.16, 1); - - const { x: width, y: height } = calculateVisibleWorldSize( - renderContext.width, - renderContext.height, - DEMO_VERTICAL_WORLD_UNITS, - ); - - backdropSprite.width = width; - backdropSprite.height = height; - - const backdrop = world.createEntity(); - - addPositionComponent(world, backdrop); - addSpriteComponent(world, backdrop, backdropSprite); -} - -/** - * Wires a `MouseInputSource` and a `KeyboardInputSource` (arrow keys to - * navigate focus, Enter/Space to submit) so the HUD's `Play` button is both - * clickable and gamepad/keyboard-focus-navigable. - */ -function createUiInputs( - world: EcsWorld, - time: Time, - game: Game, -): { - mouseInputSource: MouseInputSource; - submitInput: TriggerAction; - navigateInput: Axis2dAction; -} { - const submitInput = new TriggerAction('ui-submit'); - const navigateInput = new Axis2dAction( - 'ui-navigate', - undefined, - actionResetTypes.noReset, - ); - - const inputManager = registerInputs(world, time, { - triggerActions: [submitInput], - axis2dActions: [navigateInput], - }); - - const mouseInputSource = new MouseInputSource(inputManager, game.container); - const keyboardInputSource = new KeyboardInputSource(inputManager); - - keyboardInputSource.axis2dBindings.add( - new KeyboardAxis2dBinding( - navigateInput, - keyCodes.arrowUp, - keyCodes.arrowDown, - keyCodes.arrowRight, - keyCodes.arrowLeft, - ), - ); - - keyboardInputSource.triggerBindings.add( - new KeyboardTriggerBinding(submitInput, keyCodes.enter, buttonMoments.down), - ); - keyboardInputSource.triggerBindings.add( - new KeyboardTriggerBinding(submitInput, keyCodes.space, buttonMoments.down), - ); - - return { mouseInputSource, submitInput, navigateInput }; -} - -/** - * Builds a "Settings" panel showcasing the Phase 3 controls - a mute - * toggle, a volume slider, a quality dropdown, and a health progress bar - - * anchored to the canvas's right edge, alongside the existing HUD/button - * showcase. - * @returns The health bar's `UiProgressBarEcsComponent`, so the caller can - * drive it from elsewhere (the demo ties it to the `Play` button below). - */ -async function createSettingsPanel( - world: EcsWorld, - renderContext: RenderContext, - canvas: number, - fontAtlas: FontAtlas, - panelSprite: SpriteEcsComponent, -): Promise { - const whiteImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/White.png'), - ); - - // A round coin icon and an "X" icon, standing in for real game art - used - // below for the slider handle and the toggle's checkmark, so this panel - // demonstrates a distinctly-shaped image sprite (not just a tinted flat - // rectangle like every other control here) for at least one of each kind - // of graphic these controls accept. - const handleImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/kenney_puzzle-pack-2/PNG/Coins/coin_01.png'), - ); - const crossImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/space-shooter/icon_crossSmall.png'), - ); - - // A grey-to-yellow gradient strip, used below for the Health bar's fill - - // like the coin/cross above, a genuine textured image sprite rather than - // another flat tinted rectangle, this time for `fillSprite` specifically. - // `createUiLayoutEcsSystem` stretches every sprite to its resolved rect - // regardless of the source image's own size, so this thin 100x5 source - // stretches cleanly across the bar with no tiling/repeat involved. - const healthFillImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/Burn_Gradient.png'), - ); - - const buildFillSprite = (tintColor: Color): SpriteEcsComponent => { - const sprite = createImageSprite(whiteImage, renderContext, { - layer: renderLayers.ui, - }); - sprite.tintColor = tintColor; - - return sprite; - }; - - const accentColor = new Color(0.35, 0.55, 0.95, 1); - const boxColor = new Color(0.85, 0.85, 0.88, 1); - const captionSize = 22; - - // The nine-sliced panelSprite's corner decorations are sized for large - // panels/buttons - at the small sizes controls below use, the corners - // would overlap into a mess, so small controls use a plain flat sprite - // instead. - const boxSprite = buildFillSprite(boxColor); - - // createButton/createToggle/createSlider always attach a - // UiColorTransitionEcsComponent, which defaults every state to - // Color.white - without this override, it would override boxSprite's - // tint back to white every frame, on top of whatever tint the sprite - // itself was given. - const boxTransition = { - normalColor: boxColor, - hoverColor: new Color(0.78, 0.78, 0.83, 1), - pressedColor: new Color(0.68, 0.68, 0.75, 1), - disabledColor: new Color(0.6, 0.6, 0.6, 0.6), - }; - - const settingsPanel = createPanel(world, canvas, { - anchor: UiAnchor.middleRight, - anchoredPosition: { x: -20, y: 0 }, - sizeDelta: { x: 420, y: 600 }, - sprite: panelSprite, - }); - - createLabel(world, settingsPanel, { - text: 'Settings', - fontAtlas, - size: 30, - anchor: UiAnchor.stretchTopLeft, - // A stretch anchor's default sizeDelta ({100, 100}) is a margin, not a - // literal size - omitting this widens maxWidth past the panel's actual - // width by 100, off-centering the text (see createToggle's checkmark - // for the same gotcha with a sprite instead of text). - sizeDelta: { x: 0, y: 0 }, - anchoredPosition: { x: 0, y: -30 }, - horizontalAlign: textHorizontalAlignments.center, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - - const caption = (text: string, y: number): void => { - createLabel(world, settingsPanel, { - text, - fontAtlas, - size: captionSize, - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 30, y }, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - }; - - caption('Mute', -90); - - const crossSprite = createImageSprite(crossImage, renderContext, { - layer: renderLayers.ui, - }); - crossSprite.tintColor = accentColor; - - createToggle(world, settingsPanel, { - sprite: boxSprite, - checkmarkSprite: crossSprite, - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 340, y: -90 }, - transition: boxTransition, - }); - - caption('Volume', -160); - - const volumeValueLabel = createLabel(world, settingsPanel, { - text: '75', - fontAtlas, - size: captionSize, - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 340, y: -160 }, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - const volumeValueText = world.getComponent(volumeValueLabel, textId)!; - - const volumeSlider = createSlider(world, settingsPanel, { - trackSprite: boxSprite, - handleSprite: createImageSprite(handleImage, renderContext, { - layer: renderLayers.ui, - }), - fillSprite: buildFillSprite(accentColor), - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 30, y: -190 }, - sizeDelta: { x: 360, y: 20 }, - minValue: 0, - maxValue: 100, - value: 75, - wholeNumbers: true, - transition: boxTransition, - }); - - volumeSlider.onValueChanged.registerListener((value) => { - volumeValueText.text = `${value}`; - }); - - caption('Health', -250); - - const healthBar = createProgressBar(world, settingsPanel, { - trackSprite: boxSprite, - fillSprite: createImageSprite(healthFillImage, renderContext, { - layer: renderLayers.ui, - }), - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 30, y: -280 }, - sizeDelta: { x: 360, y: 20 }, - minValue: 0, - maxValue: 100, - value: 100, - }); - - caption('Quality', -350); - - // Placed last, at the panel's bottom edge, so its option list - which - // extends below the header while open - overlaps empty canvas space - // rather than the sections above (there's no rect clipping yet, see the - // UI doc's "Known limitations"). - createDropdown(world, settingsPanel, { - headerSprite: boxSprite, - optionSprite: boxSprite, - options: ['Low', 'Medium', 'High'], - fontAtlas, - labelColor: textColor, - labelCategory: renderLayers.ui, - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 30, y: -380 }, - sizeDelta: { x: 240, y: 48 }, - selectedIndex: 2, - transition: boxTransition, - }); - - return healthBar.progressBar; -} - -/** - * Builds the UI interaction demo: a "game world" (a plain tinted backdrop, - * drawn by its own camera/culling mask) with a HUD overlaid on top of it - * through a second, dedicated UI camera - a full-width top bar, a - * corner-anchored score panel, and a hoverable, clickable, keyboard/gamepad- - * focus-navigable `Play` button (see `createButton`) that increments a - * click counter on `onInvoke`, whichever path raised it (pointer or - * `submitInput`). - * @param fontAtlasUrl - The URL of the font atlas JSON to load. - * @returns The created game. - */ -export const createUiDemoGame = async (fontAtlasUrl: string): Promise => { - const { game, world, renderContext, time } = createGame('demo-game'); - - createCamera(world, { - isStatic: true, - cullingMask: renderLayers.world, - verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, - }); - - await createBackdrop(world, renderContext); - - const fontAtlasCache = new FontAtlasCache(renderContext.imageCache); - const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl); - - const { mouseInputSource, submitInput, navigateInput } = createUiInputs( - world, - time, - game, - ); - - const canvas = createUiCanvas(world, renderContext, time, { - cullingMask: renderLayers.ui, - referenceResolution: { x: 1920, y: 1080 }, - pointerSource: mouseInputSource, - submitInput, - navigateInput, - }); - - const panelImage = await renderContext.imageCache.getOrLoad( - getAssetUrl('img/kenney_fantasy-ui-borders/PNG/Double/Panel/panel-030.png'), - ); - const panelSprite = createImageSprite(panelImage, renderContext, { - layer: renderLayers.ui, - slices: { - left: panelBorderInset, - right: panelBorderInset, - top: panelBorderInset, - bottom: panelBorderInset, - nativeWidth: panelNativeSize, - nativeHeight: panelNativeSize, - }, - }); - - const topBar = createPanel(world, canvas, { - anchor: UiAnchor.stretchTop, - sizeDelta: { x: -40, y: 96 }, - anchoredPosition: { x: 0, y: -20 }, - sprite: panelSprite, - }); - - createLabel(world, topBar, { - text: 'Forge UI Demo', - fontAtlas, - size: 40, - anchor: UiAnchor.stretchHorizontalLeft, - // See the "Settings" label's own comment above for why this is needed. - sizeDelta: { x: 0, y: 0 }, - horizontalAlign: textHorizontalAlignments.center, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - - const scorePanel = createPanel(world, canvas, { - anchor: UiAnchor.topLeft, - anchoredPosition: { x: 20, y: -136 }, - sizeDelta: { x: 260, y: 96 }, - sprite: panelSprite, - }); - - createLabel(world, scorePanel, { - text: 'Score: 1234', - fontAtlas, - size: 28, - anchor: UiAnchor.stretchHorizontalLeft, - // See the "Settings" label's own comment above for why this is needed. - sizeDelta: { x: 0, y: 0 }, - horizontalAlign: textHorizontalAlignments.center, - verticalAlign: textVerticalAlignments.middle, - color: textColor, - category: renderLayers.ui, - }); - - const playButton = createButton(world, canvas, { - anchor: UiAnchor.bottomCenter, - anchoredPosition: { x: 0, y: 60 }, - sizeDelta: { x: 220, y: 72 }, - sprite: panelSprite, - label: 'Play', - fontAtlas, - labelSize: 30, - labelColor: textColor, - labelCategory: renderLayers.ui, - transition: { - normalColor: Color.white, - hoverColor: new Color(0.85, 0.85, 0.85, 1), - pressedColor: new Color(0.65, 0.65, 0.65, 1), - disabledColor: new Color(0.5, 0.5, 0.5, 0.6), - }, - }); - - const healthProgressBar = await createSettingsPanel( - world, - renderContext, - canvas, - fontAtlas, - panelSprite, - ); - - let clickCount = 0; - - const clickLabelWidth = 400; - - // `UiAnchor.bottomCenter`'s pivot sits at the box's own center, which - // `maxWidth`-based centering needs to *not* be the case (see - // `UiAnchor.stretchHorizontalLeft`'s doc comment) - a custom point anchor - // keeps the same bottom-center placement (the anchor reference point) - // while pivoting the box itself to its left edge instead, offsetting - // `anchoredPosition.x` by half the box's own (fixed, known) width to land - // in the same place visually. - const clickLabel = createLabel(world, canvas, { - text: 'Clicks: 0', - fontAtlas, - size: 24, - anchor: { - anchorMin: { x: 0.5, y: 0 }, - anchorMax: { x: 0.5, y: 0 }, - pivot: { x: 0, y: 0 }, - }, - anchoredPosition: { x: -clickLabelWidth / 2, y: 140 }, - sizeDelta: { x: clickLabelWidth, y: 40 }, - horizontalAlign: textHorizontalAlignments.center, - verticalAlign: textVerticalAlignments.middle, - maxWidth: clickLabelWidth, - color: textColor, - category: renderLayers.ui, - }); - - // Looked up once, rather than on every click - `world.getComponent` is a - // map lookup that a hot input-event handler doesn't need to repeat when - // the component reference itself never changes. - const clickText = world.getComponent(clickLabel, textId)!; - - playButton.onInvoke.registerListener(() => { - clickCount += 1; - clickText.text = `Clicks: ${clickCount}`; - - // Demonstrates createUiProgressBarEcsSystem picking up an external - // value write the same frame it's set - unlike a slider, which has to - // wait a frame for the interaction pipeline. - healthProgressBar.value = - healthProgressBar.value <= 0 ? 100 : healthProgressBar.value - 10; - }); - - world.addSystem(createCameraEcsSystem(time)); - world.addSystem(createTransformEcsSystem()); - world.addSystem(createTextShapingEcsSystem(renderContext)); - world.addSystem(createRenderEcsSystem(renderContext)); - world.addSystem(createPresentEcsSystem(renderContext)); - - return game; -}; diff --git a/documentation-site/src/pages/demos/ui/index.tsx b/documentation-site/src/pages/demos/ui/index.tsx deleted file mode 100644 index 4e24a477..00000000 --- a/documentation-site/src/pages/demos/ui/index.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import React, { JSX, useCallback } from 'react'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import { createUiDemoGame } 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, -}; - -export default function Ui(): JSX.Element { - const { siteConfig } = useDocusaurusContext(); - const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; - const createGame = useCallback( - () => createUiDemoGame(fontAtlasUrl), - [fontAtlasUrl], - ); - - return ( - - - - - } - text="Click the Play button." - /> - - - - - - - } - text="Move focus to the Play button." - /> - } - text="Activate the focused button." - /> - - - - } - text="Toggle fullscreen to see the HUD hold its layout at a different aspect ratio." - /> - - } - codeFiles={[ - { - name: 'game.ts', - content: gameCode, - }, - ]} - /> - ); -}