Toggle fullscreen to see the orange playground panel (and every reference panel) hold its anchor at a different aspect ratio.
// 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<void> {
const whiteImage = await renderContext.imageCache.getOrLoad(
'img/White.png',
);
const backdropSprite = createImageSprite(whiteImage, renderContext, {
pixelsPerUnit: 1,
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);
}
const playgroundTintColor = new Color(0.95, 0.55, 0.2, 1);
/**
* Builds the anchors demo: four corner-pinned reference panels
* (`topLeft`/`topRight`/`bottomLeft`/`bottomRight`) and a full-width top bar
* (`stretchTop`), plus one orange "playground" panel whose anchor,
* position, and size/margin are live-controlled by `_PlaygroundControls.tsx`
* via the `AnchorPlayground` handed back through `onPlaygroundReady`. Every
* panel resolves its layout fresh every frame from the canvas's current
* aspect ratio, so toggling fullscreen (or dragging the playground's
* controls) keeps each one exactly where its anchor says it should be, at
* any window shape.
* @param fontAtlasUrl - The URL of the font atlas JSON to load.
* @param onPlaygroundReady - Called once the playground panel's live
* components exist, so the page can wire its controls up to them.
* @returns The created game.
*/
export const createAnchorsGame = async (
fontAtlasUrl: string,
onPlaygroundReady?: (playground: AnchorPlayground) => void,
): Promise<Game> => {
const { game, world, renderContext, time } = createGame('demo-game');
createCamera(world, {
isStatic: true,
cullingMask: renderLayers.world,
verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS,
});
const fontAtlasCache = new FontAtlasCache(renderContext.imageCache);
const fontAtlas = await fontAtlasCache.getOrLoad(fontAtlasUrl);
registerUiSystems(world, renderContext, time);
const canvas = createUiCanvas(world, renderContext, {
cullingMask: renderLayers.ui,
referenceResolution: { x: 1920, y: 1080 },
});
const panelImage = await renderContext.imageCache.getOrLoad(
'img/kenney_fantasy-ui-borders/PNG/Double/Panel/panel-030.png',
);
const panelSprite = createImageSprite(panelImage, renderContext, {
pixelsPerUnit: 1,
layer: renderLayers.ui,
slices: {
left: 26,
right: 26,
top: 26,
bottom: 26,
nativeWidth: 96,
nativeHeight: 96,
},
});
const labeledPanel = (
text: string,
anchor: UiAnchorConfig,
anchoredPosition: { x: number; y: number },
tintColor?: Color,
): AnchorPlayground => {
const panel = createPanel(world, canvas, {
anchor,
anchoredPosition,
sprite: panelSprite,
});
if (tintColor) {
const sprite = world.getComponent(panel, spriteId);
if (!sprite) {
throw new Error(
`Panel entity "${panel}" is missing its sprite component.`,
);
}
sprite.tintColor = tintColor;
}
const label = createLabel(world, panel, {
text,
fontAtlas,
size: 24,
anchor: UiAnchor.stretchAll(),
horizontalAlign: textHorizontalAlignments.center,
verticalAlign: textVerticalAlignments.middle,
color: textColor,
category: renderLayers.ui,
});
const rectTransform = world.getComponent(panel, rectTransformId);
const labelText = world.getComponent(label, textId);
if (!rectTransform || !labelText) {
throw new Error(
`Panel entity "${panel}" is missing its rect transform or label text component.`,
);
}
return { rectTransform, labelText };
};
labeledPanel(
'stretchTop',
UiAnchor.stretchTop({ height: 96, horizontalMargin: -40 }),
{ x: 0, y: -20 },
);
labeledPanel('topLeft', UiAnchor.topLeft({ x: 260, y: 96 }), {
x: 20,
y: -140,
});
labeledPanel('topRight', UiAnchor.topRight({ x: 260, y: 96 }), {
x: -20,
y: -140,
});
labeledPanel('bottomLeft', UiAnchor.bottomLeft({ x: 260, y: 96 }), {
x: 20,
y: 20,
});
labeledPanel('bottomRight', UiAnchor.bottomRight({ x: 260, y: 96 }), {
x: -20,
y: 20,
});
const playground = labeledPanel(
anchorPlaygroundDefaults.presetName,
anchorPlaygroundConfigBuilders[anchorPlaygroundDefaults.presetName]({
x: anchorPlaygroundDefaults.sizeOrMarginX,
y: anchorPlaygroundDefaults.sizeOrMarginY,
}),
{
x: anchorPlaygroundDefaults.anchoredPositionX,
y: anchorPlaygroundDefaults.anchoredPositionY,
},
playgroundTintColor,
);
onPlaygroundReady?.(playground);
world.addSystem(createCameraEcsSystem(time));
world.addSystem(createTransformEcsSystem());
world.addSystem(createTextShapingEcsSystem(renderContext));
world.addSystem(createRenderEcsSystem(renderContext));
world.addSystem(createPresentEcsSystem(renderContext));
return game;
};
Four corner-pinned reference panels (topLeft, topRight, bottomLeft, bottomRight) and a full-width top bar (stretchTop), plus one orange playground panel you can drive yourself with the controls above: pick any UiAnchor preset, then drag its position and size/margin sliders to see exactly how each axis's anchor, pivot, anchoredPosition, and size (or margin, for a stretched axis) interact. Every panel's RectTransformEcsComponent resolves fresh every frame from the canvas's current aspect ratio, so toggling fullscreen keeps each one exactly where its own anchor says it should be, at any window shape - no manual reflow code required.