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>
744 lines
31 KiB
TypeScript
744 lines
31 KiB
TypeScript
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;
|
||
}
|
||
}
|