Files
feuerwerk/src/runtime/World.ts
T
adminandClaude Opus 5.5 d4ff652293
feuerwerk / gate (push) Successful in 5m21s
feuerwerk / suite (push) Successful in 18m31s
Keep descenders of a text whole and frame it at its drawn size
A text was drawn into a texture as tall as Pixi measures the string
"|ÉqÅM". Whatever reached further, like the "g" of Manrope (0.015 em
below the "q"), was cut off at the bottom. The blue frame around a
text was only estimated from the font size and the number of letters,
so it was far too wide for most texts.

- textPicture.ts takes the height of a line from the font itself
  (fontBoundingBoxAscent and fontBoundingBoxDescent, as a browser
  does). Pixi draws with a margin, and the picture is cut to that box
  afterwards. Where Pixi's own box is larger, Pixi's box wins.
- Both raster paths (linear and nearest) use the cut.
- The height no longer depends on the letters typed, so a text does
  not jump while it is edited.
- bounds.ts asks a measurer that textPicture.ts registers
  (setTextMeasure). Selection, picking in a running game, framing the
  camera, the drop preview and culling all get the measured size.
  The footprint functions take the font file for this.
- Unit tests in node have no canvas and keep the estimate
  (estimatedTextSize).
- Texts get taller: Source Sans 3 at 48 px is 68 instead of 55 px,
  Manrope is 1.366 em instead of 1.25 em. The glyphs stay in place,
  the centre moves by less than half a pixel. A shader on a text
  gets a taller UV rectangle.

Tests:
- Gate on melektaus-atx green: 307 files, 4762 tests.
- 5 new unit tests for textBox, textCut and the measurer hook.
- e2e 30: 26 checks, 30f-iii new. The texture is 68 high, the font
  box is 68, the frame equals the drawn picture (237x68).
- Counter test: with Pixi's old box the texture is 55 high and
  30f-iii is red.
- Not checked in the browser: the Manrope caption of the user and
  texts with several lines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 15:18:59 +02:00

744 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ENTITY_HANDLE,
isHandleEnabled,
type PointerState,
worldMatrixOf,
worldPositionOf,
} from '../core/graph';
import { newId } from '../core/ids';
import { type Asset, isPrivateVariable, type TilemapAsset } from '../core/model/Asset';
import { sameAtRest, withoutObjects } from '../core/graph/liveObjects';
import { bagWithoutItemTypes, type PropertyBag, setBagValue, type Vec2 } from '../core/model/Properties';
import { isPlaceableType } from '../core/model/AssetTypes';
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
import { groupNamed, type ObjectTree, treeOrder } from '../core/model/hierarchy';
import { liveTileAt, withTile } from '../core/model/liveTilemap';
import { referenceCandidates } from '../core/model/objectRefs';
import { type Cell, presentTile } from '../core/model/tilemap';
import { descendantsBelow } from '../core/model/walk';
import { isOverlayCamera } from '../core/model/renderLayers';
import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject';
import { fontFileOf } from '../core/model/font';
import { apply, IDENTITY } from '../core/model/matrix';
import { hasTransformProps } from '../core/properties/PropertySchema';
import { carriedTransform, localMatrixOf, transformOf } from '../core/properties/transform';
import { footprintContains } from '../core/properties/bounds';
import { fontOf } from '../core/properties/fonts';
import { pointerThrough } from '../core/properties/camera';
import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { Entity } from './Entity';
import type { System } from './systems/System';
/** How long (seconds of run time) a cast ray stays drawn, and how many at most. */
export const RAY_SHOWN = 0.25;
const MAX_RAYS = 64;
export interface CastRay {
from: Vec2;
to: Vec2;
hit: boolean;
time: number;
}
export interface WorldOptions {
/**
* Whether a placed container's content may be written into the scene (see `syncFromModel`).
* Baking is a real, irreversible edit, so the editor run switches it off; its content still runs,
* but what it writes goes into the instance edits and it cannot destroy itself for good.
*/
bakeContainers?: boolean;
/** What a `Load scene` brings along from the world before (#165); given, the scene counts as changed. */
brought?: BroughtEntities;
}
/**
* The kept entities of the world before, whose objects the scene holds by now, and the ids of the
* scene's own objects they stand in for (see `keptObjects.ts`).
*/
export interface BroughtEntities {
entities: readonly Entity[];
standIns: ReadonlyMap<string, string>;
}
/** What `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */
export interface ModelWrite {
structure: boolean;
values: boolean;
}
/**
* What the model said of an entity when the world last read it or wrote it. `syncToModel` runs every
* frame and compares against this instead of resolving every object anew (#287), which holds as long
* as whoever else changes the model has it read again (`syncFromModel`).
*/
interface ModelState {
asset: Asset;
x: number;
y: number;
z: number;
props: PropertyBag;
}
const NO_KEYS: readonly string[] = [];
/** The keys whose value in the run no longer says what the model does. */
function driftedKeys(live: PropertyBag, model: ModelState): readonly string[] {
let keys: string[] | undefined;
for (const key of Object.keys(live)) {
const value = live[key];
const atRest = model.props[key];
if (atRest === value || !(key in model.props)) continue;
// What a run holds of an object stays in the run; the scene gets the empty one.
// A private variable (#257) has no override to hold its value: that stays in the run.
if (isPrivateVariable(model.asset, key) || sameAtRest(atRest, value)) continue;
(keys ??= []).push(key);
}
return keys ?? NO_KEYS;
}
/** `order`'s members of `list` in that order, in the slots of `list` they held before. */
function reorderIn<T>(list: T[], order: readonly T[]): void {
const slots = new Map(list.map((value, index) => [value, index]));
const present = order.filter((value) => slots.has(value));
const indices = present.map((value) => slots.get(value)!).sort((a, b) => a - b);
indices.forEach((slot, i) => (list[slot] = present[i]!));
}
/**
* The running game: entities of one scene plus the systems ticking them. Built on the play copy
* (discarded on stop) or, for the editor run, on the saved model, where its writes stay.
*/
export class World {
readonly entities: Entity[] = [];
/** The running scene's own properties – read from the model, and written by an animation over the scene. */
readonly sceneProps: PropertyBag = {};
/** What the run itself set on the scene (`setSceneProp`); re-reading the model does not undo it. */
private readonly runSceneProps: PropertyBag = {};
/** Set by a behavior (Stop game node); the editor leaves play mode on the next tick. */
stopRequested = false;
/** Set by a behavior (Restart game node); the editor restarts the scene on the next tick. */
restartRequested = false;
/**
* Set by a behavior (Load scene node): the scene to swap to once this tick is over, `''` for
* none asked. Asked twice in a tick, the last one wins – a flag, not a queue, because loading
* two scenes in a row is not a thing anyone means.
*/
loadSceneRequested = '';
/**
* Set by a behavior (Pause game / Resume game, #167). The world time, the physics and the
* animations stand still, and only behaviors that run while paused are ticked. Belongs to the
* world, so the next scene starts unpaused.
*/
paused = false;
private frameFrozen = false;
/**
* Mouse state in world units, fed by the game view (the only place with the camera
* transform). `pressed`/`released` last for one tick, like the keyboard edges.
*/
readonly pointer: PointerState = {
x: 0,
y: 0,
inside: false,
down: false,
pressed: false,
released: false,
wheel: 0,
};
/** The mouse on the main camera's picture, from its centre in parts of its width and height (#304). */
private pointerOnPicture: Vec2 = { x: 0, y: 0 };
/** Objects the editor is dragging (ids): the physics puts them where the drag does, at rest. */
held: ReadonlySet<string> = new Set();
/** Messages logged by behaviors (Log node), newest last. */
readonly log: string[] = [];
/** The rays cast lately (#153), for the editor run to draw: where from, where to, whether they met anything. */
readonly rays: CastRay[] = [];
/** Until when (run time) an object falls through one-way colliders and tiles – `Ignore one-way` (#155). */
private readonly ignoringOneWay = new Map<Entity, number>();
private readonly systems: System[] = [];
/** Told what each system took of a tick, in ms, when set – the editor's performance overlay (#275). */
timeSystem: ((system: System, ms: number) => void) | null = null;
private elapsed = 0;
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
private structureChanged = false;
/** Children a Destroy moved up to their grandparent; written into the scene by `syncToModel`. */
private readonly movedUp = new Set<Entity>();
/** Set when an entity left the run; the next `syncToModel` drops its object. */
private entitiesGone = false;
private readonly modelStates = new WeakMap<Entity, ModelState>();
private tilesChanged = false;
/** Entities of the world before, by object id, until the scene's object takes each one up. */
private readonly arriving = new Map<string, Entity>();
/** Objects this scene did not build because one brought along is it already, by their id. */
private readonly standIns = new Map<string, Entity>();
private constructor(
readonly project: Project,
readonly sceneId: string,
private readonly bakeContainers: boolean,
) {}
/** Builds a world from the given scene. Throws when the scene does not exist. */
static instantiate(project: Project, sceneId: string, options: WorldOptions = {}): World {
if (!findScene(project, sceneId)) throw new Error(`Scene ${sceneId} not found`);
const world = new World(project, sceneId, options.bakeContainers ?? true);
const brought = options.brought;
for (const entity of brought?.entities ?? []) world.arriving.set(entity.objectId, entity);
world.syncFromModel();
world.arriving.clear();
if (!brought) return world;
for (const [id, standIn] of brought.standIns) {
const entity = world.entities.find((e) => e.objectId === standIn);
if (entity) world.standIns.set(id, entity);
}
world.structureChanged = true;
return world;
}
/** Seconds since the world started. */
get time(): number {
return this.elapsed;
}
get backgroundColor(): number {
const background = this.sceneProps.backgroundColor;
return typeof background === 'number' ? background : 0x000000;
}
/** Makes the entities match the scene; props are updated in place so systems holding a bag stay current. */
syncFromModel(): void {
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
// Baking spells a placed container's content out as stored objects, which is what lets a
// behavior destroy one of them for good. Play does it (on its throwaway copy); the editor run
// must not, so there the content stays resolved and `objectRef` writes into the instance edits.
if (this.bakeContainers && bakeInstances(this.project, scene)) this.structureChanged = true;
Object.assign(this.sceneProps, scene.props, this.runSceneProps);
const objects = new Map(resolveObjects(this.project, scene).map((o) => [o.id, o]));
const keep = new Set<Entity>();
for (const object of objects.values()) {
const asset = findAsset(this.project, object.assetId);
if (!asset || !isPlaceableType(asset.type)) continue;
keep.add(this.materialize(object, asset));
}
for (let i = this.entities.length - 1; i >= 0; i--)
if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
// Link parents once every entity exists (storage order is not guaranteed to be parent-first).
for (const entity of this.entities) {
const object = objects.get(entity.objectId);
entity.parent = object?.parentId ? (this.findByObjectId(object.parentId) ?? null) : null;
}
}
/** Remembers a ray for a moment, forgetting those cast longer ago than `RAY_SHOWN`. */
noteRay(from: Vec2, to: Vec2, hit: boolean): void {
const since = this.time - RAY_SHOWN;
const kept = this.rays.filter((ray) => ray.time >= since);
this.rays.splice(0, this.rays.length, ...kept.slice(-(MAX_RAYS - 1)), { from, to, hit, time: this.time });
}
/** Lets the object, and anything below it, fall through one-way colliders for `seconds` from now. */
ignoreOneWay(entity: Entity, seconds: number): void {
this.ignoringOneWay.set(entity, this.time + Math.max(0, seconds));
}
/** Whether one-way colliders let the entity through right now, asked of it or any object above it. */
ignoresOneWay(entity: Entity): boolean {
for (let e: Entity | null = entity; e; e = e.parent) {
const until = this.ignoringOneWay.get(e);
if (until !== undefined && until > this.time) return true;
}
return false;
}
/** Whether a drag holds the object: itself, an object above it, or its rigidbody is dragged. */
holds(entity: Entity): boolean {
if (this.held.size === 0) return false;
for (let e: Entity | null = entity; e; e = e.parent) if (this.held.has(e.objectId)) return true;
return this.childrenOf(entity).some((c) => c.type === 'rigidbody' && this.held.has(c.objectId));
}
/** The asset of a placed tilemap; undefined for anything else. */
private tilemapAssetOf(entity: Entity): TilemapAsset | undefined {
return entity.type === 'tilemap' ? findAssetOf(this.project, entity.assetId, 'tilemap') : undefined;
}
isTilemap(entity: Entity): boolean {
return this.tilemapAssetOf(entity) !== undefined;
}
/**
* The tile in a cell of a placed tilemap as the run left it (see `model/liveTilemap.ts`), `''` for
* a cell naming a tile the project does not have, as it is drawn.
*/
tileAt(entity: Entity, layer: string, cell: Cell): string | null {
const asset = this.tilemapAssetOf(entity);
const tile = asset ? liveTileAt(asset, entity.tileEdits, entity.props, layer, cell) : null;
return tile === null ? null : presentTile(this.project, tile);
}
/** Sets a cell of this one placement for the rest of the run; `''` empties it. */
setTile(entity: Entity, layer: string, cell: Cell, tileId: string): boolean {
const asset = this.tilemapAssetOf(entity);
if (!asset || (tileId !== '' && !findAssetOf(this.project, tileId, 'tile'))) return false;
const edits = withTile(asset, entity.tileEdits, entity.props, layer, cell, tileId);
if (!edits) return false;
if (edits !== entity.tileEdits) this.tilesChanged = true;
entity.tileEdits = edits;
return true;
}
/**
* Whether a tile was set since the last ask. The editor run needs it: a changed cell reaches no
* model, so nothing else would tell the views to draw the map again.
*/
takeTileChange(): boolean {
const changed = this.tilesChanged;
this.tilesChanged = false;
return changed;
}
/** Sets a scene property for the rest of the run; the scene in the model keeps its own. */
setSceneProp(key: string, value: PropertyBag[string]): void {
this.sceneProps[key] = value;
this.runSceneProps[key] = value;
}
/** The entity for a scene object, created when missing; position, name and props re-read. */
private materialize(object: SceneObject, asset: Asset): Entity {
let entity = this.entities.find((e) => e.objectId === object.id);
const arrived = entity ? undefined : this.arriving.get(object.id);
if (arrived) {
// It comes as the run left it: what the scene says of it is only what the next write compares
// against, so the run's values reach the scene instead of the scene's replacing them.
this.entities.push(arrived);
arrived.assetId = asset.id;
arrived.type = asset.type;
arrived.name = objectName(object, asset);
this.modelStates.set(arrived, { asset, ...object.position, props: this.modelProps(asset, object) });
return arrived;
}
if (!entity) {
entity = {
[ENTITY_HANDLE]: true,
id: `e-${object.id}`,
objectId: object.id,
assetId: asset.id,
type: asset.type,
name: '',
x: 0,
y: 0,
z: 0,
props: {},
parent: null,
};
if (asset.type === 'collider') {
const collider = entity;
collider.wornFrame = () => colliderFrame(this.project, collider.props);
}
this.entities.push(entity);
}
entity.assetId = asset.id;
entity.type = asset.type;
entity.name = objectName(object, asset);
entity.x = object.position.x;
entity.y = object.position.y;
entity.z = object.position.z;
const resolved = this.modelProps(asset, object);
this.modelStates.set(entity, { asset, ...object.position, props: resolved });
for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key];
// The scene only ever got the objects' empty places, so a value that differs by them alone stays.
// A private value never reaches the scene, so the scene's is only where it starts.
for (const [key, value] of Object.entries(resolved)) {
if (isPrivateVariable(asset, key) && key in entity.props) continue;
if (!sameAtRest(value, entity.props[key])) entity.props[key] = value;
}
return entity;
}
/** An object's values as a run holds them. */
private modelProps(asset: Asset, object: SceneObject): PropertyBag {
return bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
}
/**
* Adds the object to the scene and creates only its entity, leaving the others alone (they may have
* moved this tick already). Position is local to `parent`, world when there is none.
*/
spawn(
assetId: string,
position: { x: number; y: number; z: number },
parent: Entity | null,
): Entity | null {
const scene = findScene(this.project, this.sceneId);
const asset = findAsset(this.project, assetId);
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
// Spawned at the top level, it is listed in a group named after its asset, not as a row of its own.
if (!parentEntity) object.groupId = groupNamed(scene, asset.name, newId).id;
scene.objects.push(object);
// A container brings its content along (parented below it, in template order).
const created = asset.type === 'container' ? materializeInstance(this.project, scene, object) : [];
scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
for (const child of created) {
const childAsset = findAsset(this.project, child.assetId);
if (!childAsset || !isPlaceableType(childAsset.type)) continue;
this.materialize(child, childAsset).parent = this.findByObjectId(child.parentId) ?? null;
}
this.structureChanged = true;
return entity;
}
/**
* Adds objects a composition plays into the scene (#417), each below the parent it names, and
* answers their entities. An object of the same id still in the scene – one taken away this tick –
* gives way, so the scene never holds an id twice.
*/
place(objects: readonly SceneObject[]): Entity[] {
const scene = findScene(this.project, this.sceneId);
if (!scene) return [];
const ids = new Set(objects.map((o) => o.id));
for (let i = scene.objects.length - 1; i >= 0; i--)
if (ids.has(scene.objects[i]!.id)) scene.objects.splice(i, 1);
scene.objects.push(...objects);
const placed: Entity[] = [];
for (const object of objects) {
const asset = findAsset(this.project, object.assetId);
if (!asset || !isPlaceableType(asset.type)) continue;
const entity = this.materialize(object, asset);
entity.parent = this.findByObjectId(object.parentId) ?? null;
placed.push(entity);
}
this.structureChanged = true;
return placed;
}
/**
* Puts these entities, and their objects in the scene, in this order among the places they already
* hold – the order that decides between equal `z` (#417).
*/
arrange(order: readonly Entity[]): void {
reorderIn(this.entities, order);
const scene = findScene(this.project, this.sceneId);
if (!scene) return;
const byId = new Map(scene.objects.map((o) => [o.id, o]));
reorderIn(
scene.objects,
order.map((e) => byId.get(e.objectId)).filter((o): o is SceneObject => o !== undefined),
);
}
/**
* Writes the entities' state into the scene: positions, property values that differ from
* what the model resolves (stored as overrides, so the inspector marks them) and removed
* entities (their objects are dropped – only stored ones, see `WorldOptions.bakeContainers`).
*/
syncToModel(): ModelWrite {
const scene = findScene(this.project, this.sceneId);
if (!scene) return { structure: false, values: false };
let values = false;
const spawned = this.structureChanged;
this.structureChanged = false;
// Moved up by a Destroy that kept them. Only a stored object can say so: a container's content
// has its place in the template, which is why the editor run cannot destroy it for good either.
for (const entity of this.movedUp) {
const object = scene.objects.find((o) => o.id === entity.objectId);
if (object && this.entities.includes(entity)) object.parentId = entity.parent?.objectId ?? '';
}
const reparented = this.movedUp.size > 0;
this.movedUp.clear();
const dropped = this.entitiesGone && this.dropGone(scene);
this.entitiesGone = false;
for (const entity of this.entities) {
// A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
if (this.held.has(entity.objectId)) continue;
const model = this.modelStates.get(entity);
if (!model) continue;
const moved = model.x !== entity.x || model.y !== entity.y || model.z !== entity.z;
const drifted = driftedKeys(entity.props, model);
if (!moved && drifted.length === 0) continue;
// Only now, because a ref for resolved content costs a resolve of its own – and because a
// direct write to such an object would land in the freshly built copy and be lost.
const ref = objectRef(this.project, scene, entity.objectId);
if (!ref) continue;
if (moved) {
ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
model.x = entity.x;
model.y = entity.y;
model.z = entity.z;
}
if (drifted.length > 0) {
// Read again rather than patched: an override may bring rows of its own (a sprite's shader).
// A container's content is a copy made before the write, so the written values go on top.
const overrides = { ...ref.object.overrides };
for (const key of drifted) {
const value = withoutObjects(entity.props[key]);
ref.setOverride(key, value);
setBagValue(overrides, key, value);
}
model.props = this.modelProps(model.asset, { ...ref.object, overrides });
}
values = true;
}
return { structure: spawned || reparented || dropped, values };
}
/** Drops the objects whose entity left the run; true when there was one. */
private dropGone(scene: ObjectTree): boolean {
// Looked up once for the whole pass: asking the list per object grows with the square of the
// entities (#284). The first of an id counts, as `findByObjectId` has it.
const alive = new Set(this.entities.map((e) => e.objectId));
const assets = new Map<string, Asset>();
for (const asset of this.project.assets) if (!assets.has(asset.id)) assets.set(asset.id, asset);
const before = scene.objects.length;
for (let i = scene.objects.length - 1; i >= 0; i--) {
const object = scene.objects[i]!;
const asset = assets.get(object.assetId);
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
}
return scene.objects.length !== before;
}
/** Registers a system; it starts immediately and is ticked in registration order. */
addSystem(system: System): this {
this.systems.push(system);
system.start?.(this);
return this;
}
/** Whether the frame being ticked is a paused one – read once when it began, see `tick`. */
get frozen(): boolean {
return this.frameFrozen;
}
tick(dt: number): void {
// A pause asked for halfway through a frame starts with the next one, so every behavior of
// this frame sees the same world.
this.frameFrozen = this.paused;
if (!this.frameFrozen) this.elapsed += dt;
const timed = this.timeSystem;
for (const system of this.systems) {
if (this.frameFrozen && !system.runsWhilePaused) continue;
if (!timed) {
system.update(this, dt);
continue;
}
const start = performance.now();
system.update(this, dt);
timed(system, performance.now() - start);
}
// The pointer edges belong to exactly one tick, whatever the systems did with them.
this.dropPointerEdges();
}
/** Forgets a pending click edge without running a tick (used while paused). */
dropPointerEdges(): void {
this.pointer.pressed = false;
this.pointer.released = false;
this.pointer.wheel = 0;
}
/**
* Game view: where the mouse is (world units), whether it is over the view at all, and where it
* lies on the main camera's picture (see `pointerOnPicture`).
*/
movePointer(x: number, y: number, inside: boolean, onPicture: Vec2 = { x: 0, y: 0 }): void {
this.pointer.x = x;
this.pointer.y = y;
this.pointer.inside = inside;
this.pointerOnPicture = onPicture;
}
/**
* Where the mouse is in the world as `camera` sees it (#304). The main camera answers `pointer`.
* Kept on the picture rather than in the world, so a main camera on the move does not drag the
* mouse across an overlay. Only a camera that is drawn can have the mouse on its picture.
*/
pointerThrough(camera: Entity): { x: number; y: number; inside: boolean } {
const { x, y, inside } = this.pointer;
const main = this.activeCamera();
if (camera === main) return { x, y, inside };
if (!main || camera.type !== 'camera') return { x, y, inside: false };
const seen = pointerThrough(this.pointerOnPicture, inside, main.props, {
...worldPositionOf(camera),
props: camera.props,
});
return { ...seen, inside: seen.inside && this.overlayCameras().includes(camera) };
}
/** Game view: the wheel turned by `notches`; what turns within one tick adds up. */
turnWheel(notches: number): void {
this.pointer.wheel += notches;
}
/** Game view: the mouse button changed. Sets the edge flag the current tick reports. */
setPointerDown(down: boolean): void {
if (down === this.pointer.down) return;
this.pointer.down = down;
if (down) this.pointer.pressed = true;
else this.pointer.released = true;
}
/** Stops the systems and drops every entity; the world is unusable afterwards. */
destroy(): void {
for (const system of this.systems) system.stop?.(this);
this.systems.length = 0;
this.entities.length = 0;
}
/**
* Whether a world point lies on an entity: on the colliders of its subtree when it has
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
* hidden screen cannot swallow clicks meant for what is drawn behind it.
*/
hitTest(entity: Entity, x: number, y: number): boolean {
const point = { x, y };
const parts = [entity, ...this.descendantsOf(entity)].filter(
(e) => isHandleEnabled(e) && this.insideClipAreas(e, point),
);
const colliders = parts.filter((e) => e.type === 'collider');
if (colliders.length > 0)
return colliders.some((c) =>
shapeContains(colliderShape(c.props, worldMatrixOf(c), c.wornFrame?.()), point),
);
return parts.some(
(e) =>
(e.type === 'sprite' || e.type === 'text') &&
footprintContains(
e.type,
e.props,
worldMatrixOf(e),
point,
fontFileOf(this.project, fontOf(e.props)),
),
);
}
/** Whether the point is inside every clip area above the entity (#260): what is cut off is not hit. */
private insideClipAreas(entity: Entity, point: Vec2): boolean {
for (let at = entity.parent; at; at = at.parent)
if (at.type === 'clip' && !footprintContains('clip', at.props, worldMatrixOf(at), point)) return false;
return true;
}
childrenOf(entity: Entity): Entity[] {
return this.entities.filter((e) => e.parent === entity);
}
descendantsOf(entity: Entity): Entity[] {
return descendantsBelow(
entity,
(parent) => this.childrenOf(parent),
(child) => child,
);
}
/** An object the scene left out for one brought along (#165) answers as that one. */
findByObjectId(objectId: string): Entity | undefined {
const own = this.entities.find((e) => e.objectId === objectId);
if (own) return own;
const standIn = this.standIns.get(objectId);
return standIn && this.entities.includes(standIn) ? standIn : undefined;
}
/** The entity a reference on `from` names (#258): its own container placement's copy first. */
findReferenced(from: Entity, ref: string): Entity | undefined {
for (const id of referenceCandidates(from.objectId, ref)) {
const found = this.findByObjectId(id);
if (found) return found;
}
return undefined;
}
/** The first entity with that name; an object without a name of its own carries its asset's name. */
findByName(name: string): Entity | undefined {
return this.entities.find((e) => e.name === name);
}
/** The nearest entity with that name below `parent`, level by level – a child before a grandchild. */
childByName(parent: Entity, name: string): Entity | undefined {
for (
let level = this.childrenOf(parent);
level.length > 0;
level = level.flatMap((e) => this.childrenOf(e))
) {
const found = level.find((e) => e.name === name);
if (found) return found;
}
return undefined;
}
/**
* The camera the game is seen through: of the cameras that are switched on (itself and every
* parent, see `properties/enabled.ts`) and no overlay (#302) the first one in hierarchy order.
* Undefined while none is on, and then nothing is rendered – switching one off is how a scene
* hands the view over.
*/
activeCamera(): Entity | undefined {
return this.camerasOn().find((camera) => !isOverlayCamera(camera.props));
}
/** The overlay cameras that are switched on (#302), in hierarchy order: the order they are laid on. */
overlayCameras(): Entity[] {
return this.camerasOn().filter((camera) => isOverlayCamera(camera.props));
}
private camerasOn(): Entity[] {
const on = this.entities.filter((e) => e.type === 'camera' && isHandleEnabled(e));
if (on.length < 2) return on;
// Ranked only when the order can decide something, since this runs per frame.
const scene = findScene(this.project, this.sceneId);
const order = scene ? treeOrder(scene).map(({ object }) => object.id) : [];
const rank = (camera: Entity) => {
const index = order.indexOf(camera.objectId);
return index < 0 ? order.length : index;
};
return on.sort((a, b) => rank(a) - rank(b));
}
/** Removes an entity and, recursively, everything parented to it. */
remove(entity: Entity): void {
for (const child of this.childrenOf(entity)) this.remove(child);
const index = this.entities.indexOf(entity);
if (index < 0) return;
this.entities.splice(index, 1);
this.entitiesGone = true;
}
/**
* Removes only the entity: its children move up to its parent (top level when it had none) and
* stay where they are in the world. Its behaviors, its rigidbody and its joints are part of it rather
* than things it carries, so they go with it – moved up they would drive the parent instead.
* Answers the children that moved up.
*/
removeKeepingChildren(entity: Entity): Entity[] {
if (!this.entities.includes(entity)) return [];
const own = localMatrixOf(entity, transformOf(entity.props));
const kept: Entity[] = [];
for (const child of this.childrenOf(entity)) {
if (child.type === 'behavior' || child.type === 'rigidbody' || child.type === 'joint') continue;
const at = apply(own, child);
child.x = at.x;
child.y = at.y;
child.z += entity.z;
const before = transformOf(child.props);
const carried = carriedTransform(before, own, IDENTITY);
if (hasTransformProps(child.type) && carried !== before) {
child.props.rotation = carried.rotation;
child.props.scale = carried.scale;
child.props.mirrorX = carried.mirrorX;
child.props.mirrorY = carried.mirrorY;
}
child.parent = entity.parent;
this.movedUp.add(child);
kept.push(child);
}
this.remove(entity);
return kept;
}
}