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/docusaurus.config.ts b/documentation-site/docusaurus.config.ts index cbefc57f..2ff4c643 100644 --- a/documentation-site/docusaurus.config.ts +++ b/documentation-site/docusaurus.config.ts @@ -200,8 +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: 'UI 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-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, - }, - ]} - /> - ); -} 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..8733ebd2 --- /dev/null +++ b/src/ui/systems/ui-aspect-ratio-fitter-system.test.ts @@ -0,0 +1,143 @@ +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, + }); + }); + + 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-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..9d7671f7 --- /dev/null +++ b/src/ui/systems/ui-layout-group-system.test.ts @@ -0,0 +1,754 @@ +import { describe, expect, it } from 'vitest'; +import { createUiLayoutGroupEcsSystem } from './ui-layout-group-system.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 { + 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); + }); + + 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', () => { + 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); + }); + + 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', () => { + 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, + }); + } + }); + + 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', () => { + 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, + }); + }); + + 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', () => { + 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); + }); + }); + + 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 new file mode 100644 index 00000000..d98a5030 --- /dev/null +++ b/src/ui/systems/ui-layout-group-system.ts @@ -0,0 +1,756 @@ +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)); + // `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); + + 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 + * (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, + innerCross: number, + crossMeasure: AxisMeasure, + isHorizontal: boolean, + childRect: RectTransformEcsComponent, +): number { + if (!crossControl) { + return isHorizontal ? childRect.sizeDelta.y : childRect.sizeDelta.x; + } + + if (crossForceExpand) { + return Math.max(crossMeasure.preferred, 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); + + // 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, + 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); }