Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 94 additions & 3 deletions documentation-site/docs/docs/ui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions documentation-site/docusaurus.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
],
},
Expand Down
135 changes: 135 additions & 0 deletions documentation-site/src/pages/demos/layout-groups/_create-game.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<Game> => {
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;
};
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
}
Loading
Loading