Deleting a tile left the cells painted with it in the tilemaps. They drew nothing, blocked nothing and did not show in the delete dialog, but still read as a tile for "Tile at" and stopped a fill. - DeleteAssetCommand empties the cells of a deleted tile on every layer of every tilemap in the same step. Undo puts the tile and its cells back together. - The delete dialog counts them, for example "3 cell(s) on 1 tilemap(s) are painted with it and become empty." (cellsPainted). - presentTile treats a cell naming a missing tile as empty. "Tile at" (World.tileAt) and floodFill use it, so cells already in existing files read and fill as empty. There is no migration. - The brush, the rectangle and the fill check the tile in hand. A tile deleted after it was picked no longer paints new invisible cells, the editor says "Choose a tile to paint with." instead. - Unchanged on purpose: the picker still reports such a cell as naming a tile that no longer exists, and erasing removes it. Tests: - ./check.sh: 195 files, 2823 tests, CHECK EXIT 0 (+6). - GM_STEPS=39,40: ALL PASSED, 100 checks (4 new in 40t). - Full e2e sum is now 1590 (1586 confirmed by CI on round 8, plus 4). - Counter-checks, all fail as expected: 5 in unit tests (delete without emptying, undo without cells, fill and "Tile at" without presentTile, wrong map count) and 3 in the browser (dialog without count, delete without emptying, brush without the check). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
542 lines
23 KiB
TypeScript
542 lines
23 KiB
TypeScript
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } 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, type Vec2 } from '../core/model/Properties';
|
||
import { isPlaceableType } from '../core/model/AssetTypes';
|
||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||
import { groupNamed, 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 { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project';
|
||
import { createSceneObject, objectName, type SceneObject } from '../core/model/SceneObject';
|
||
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 { 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 `syncToModel` wrote: `structure` = objects appeared or vanished, `values` = the rest. */
|
||
export interface ModelWrite {
|
||
structure: boolean;
|
||
values: boolean;
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
};
|
||
/** 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[] = [];
|
||
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>();
|
||
private tilesChanged = false;
|
||
|
||
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);
|
||
world.syncFromModel();
|
||
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.findByObjectId(object.id);
|
||
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 = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
/**
|
||
* 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 alive = new Set(this.entities.map((e) => e.objectId));
|
||
const before = scene.objects.length;
|
||
for (let i = scene.objects.length - 1; i >= 0; i--) {
|
||
const object = scene.objects[i]!;
|
||
const asset = findAsset(this.project, object.assetId);
|
||
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
|
||
}
|
||
for (const object of resolveObjects(this.project, scene)) {
|
||
// A dragged object's place belongs to the drag, which reaches the entity on the next re-read.
|
||
if (this.held.has(object.id)) continue;
|
||
const entity = this.findByObjectId(object.id);
|
||
const asset = entity && findAsset(this.project, entity.assetId);
|
||
if (!entity || !asset) continue;
|
||
const p = object.position;
|
||
const moved = p.x !== entity.x || p.y !== entity.y || p.z !== entity.z;
|
||
const base = bagWithoutItemTypes(resolveSceneObjectProps(this.project, asset, object));
|
||
// 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.
|
||
const drifted = Object.keys(entity.props).filter(
|
||
(k) =>
|
||
k in base &&
|
||
!isPrivateVariable(asset, k) &&
|
||
base[k] !== entity.props[k] &&
|
||
!sameAtRest(base[k], entity.props[k]),
|
||
);
|
||
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, object.id);
|
||
if (!ref) continue;
|
||
if (moved) ref.setPosition({ x: entity.x, y: entity.y, z: entity.z });
|
||
for (const key of drifted) ref.setOverride(key, withoutObjects(entity.props[key]));
|
||
values = true;
|
||
}
|
||
return { structure: spawned || reparented || scene.objects.length !== before, values };
|
||
}
|
||
|
||
/** 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;
|
||
for (const system of this.systems)
|
||
if (!this.frameFrozen || system.runsWhilePaused) system.update(this, dt);
|
||
// 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) and whether it is over the view at all. */
|
||
movePointer(x: number, y: number, inside: boolean): void {
|
||
this.pointer.x = x;
|
||
this.pointer.y = y;
|
||
this.pointer.inside = inside;
|
||
}
|
||
|
||
/** 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),
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
);
|
||
}
|
||
|
||
findByObjectId(objectId: string): Entity | undefined {
|
||
return this.entities.find((e) => e.objectId === objectId);
|
||
}
|
||
|
||
/** 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`) 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 {
|
||
const on = this.entities.filter((e) => e.type === 'camera' && isHandleEnabled(e));
|
||
if (on.length < 2) return on[0];
|
||
// 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.reduce((first, camera) => (rank(camera) < rank(first) ? camera : first));
|
||
}
|
||
|
||
/** 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) this.entities.splice(index, 1);
|
||
}
|
||
|
||
/**
|
||
* 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 and its rigidbody 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') 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;
|
||
}
|
||
}
|