/** * Footprints in world units, shared by the viewport, by pointer hit tests in the running game and by * "Add collider", so all three agree on how big a thing is – and, through the object's transform, on * where its corners are. */ import { type AssetType, hasSceneIcon } from '../model/AssetTypes'; import type { FrameOutline } from '../model/frameOutline'; import { apply, boxCorners, type Extent, extentOfPoints, invert, type Mat, scaleOf, translation, } from '../model/matrix'; import { type PropertyBag, textOf, type Vec2 } from '../model/Properties'; import { tilemapSize } from '../model/tilemap'; import { cameraFrame } from './camera'; import { colliderSize } from './collider'; import { defaultsFor } from './PropertySchema'; const TEXT_DEFAULTS = defaultsFor('text'); /** Symbol sizes in world units. */ export const BEHAVIOR_SYMBOL = 28; export const CONTAINER_SYMBOL = 24; export const PARTICLES_SYMBOL = 24; export const RIGIDBODY_SYMBOL = 24; /** Rough width of one character relative to the font size, for a plain sans. */ const CHAR_WIDTH = 0.55; const LINE_HEIGHT = 1.25; /** Estimated from the content and font size, without measuring the text. */ export function textSize(props: PropertyBag): { w: number; h: number } { const size = Math.max(1, Number(props.fontSize ?? TEXT_DEFAULTS.fontSize)); const lines = textOf(props.text ?? TEXT_DEFAULTS.text).split('\n'); const longest = lines.reduce((max, line) => Math.max(max, line.length), 0); return { w: Math.max(size * CHAR_WIDTH, longest * size * CHAR_WIDTH), h: lines.length * size * LINE_HEIGHT, }; } /** 0 = left edge on the position, 0.5 = centred, 1 = right. */ export function textAnchor(props: PropertyBag): number { const align = textOf(props.align ?? TEXT_DEFAULTS.align); return align === 'left' ? 0 : align === 'right' ? 1 : 0.5; } /** * Centred on the object's position; text is anchored instead (see `textAnchor`). A collider that * wears a frame (#193) is that frame's size, which only the caller can look up. */ export function footprintOf( type: AssetType, props: PropertyBag, frame?: FrameOutline | null, ): { w: number; h: number } { switch (type) { case 'sprite': return { w: Number(props.width ?? 1), h: Number(props.height ?? 1) }; case 'text': return textSize(props); case 'collider': return colliderSize(props, frame); case 'behavior': return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL }; case 'container': return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL }; case 'rigidbody': return { w: RIGIDBODY_SYMBOL, h: RIGIDBODY_SYMBOL }; // The particles themselves are drawn wherever the graph puts them; what can be grabbed and // framed is the source, which is a point with a handle around it. case 'particles': return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL }; // The whole grid, centred on the object's position like every other footprint – so its cells // are the same squares for drawing, for picking and for the outline around it. case 'tilemap': return tilemapSize(props); default: return cameraFrame(props); } } /** * How an item of this type is drawn: with its whole transform, or – for the icons of behaviors, * containers, particle sources and the camera frame – upright and unscaled at its position, since an * icon that turns with its parent would only be harder to read. A source's own frame still goes into * every particle; what stays upright is the square that can be grabbed, so it covers its icon. * Colliders are symbols too but follow their body. */ export function placementOf(type: AssetType, m: Mat): Mat { return hasSceneIcon(type) && type !== 'collider' ? translation(m.tx, m.ty) : m; } function anchorOffset(type: AssetType, props: PropertyBag, w: number): number { return type === 'text' ? (0.5 - textAnchor(props)) * w : 0; } /** The footprint's corners in world units, grown by `pad` world units on every side. */ export function footprintCorners( type: AssetType, props: PropertyBag, m: Mat, pad = 0, frame?: FrameOutline | null, ): Vec2[] { const placed = placementOf(type, m); const { w, h } = footprintOf(type, props, frame); const local = pad / (scaleOf(placed) || 1); return boxCorners(placed, w + 2 * local, h + 2 * local, anchorOffset(type, props, w), 0); } /** The world rectangle around the drawn footprint – what culling, framing and labels need. */ export function footprintExtent( type: AssetType, props: PropertyBag, m: Mat, frame?: FrameOutline | null, ): Extent { return extentOfPoints(footprintCorners(type, props, m, 0, frame)); } export function footprintContains(type: AssetType, props: PropertyBag, m: Mat, point: Vec2): boolean { const { w, h } = footprintOf(type, props); const local = apply(invert(placementOf(type, m)), point); return Math.abs(local.x - anchorOffset(type, props, w)) <= w / 2 && Math.abs(local.y) <= h / 2; }