Two bodies could not be connected: no door on a hinge, no lamp on
a rope, no wheel on an axle. This is the first step of the issue,
the four joints without a motor. The slider and the motors follow
in a second step under the same issue.
- Joint is a new asset type, placed as a child of the object it
holds, like a collider or a rigidbody. "Add joint…" in the
object menu makes one or places one the project has. An object
takes as many as it needs.
- Where the joint sits is the point it holds its object at. A
hinge and a fixed joint have that one point only. A spring and a
rope end at a connected anchor on the other object, counted in
that object's own units, or in the scene's without one.
- The rows follow the type: length, stiffness and damping for a
spring, length for a rope, an angle limit for a hinge. Length 0
takes the distance the two ends were placed at. The limit counts
from how the two were placed.
- The connected object is an object reference in the schema of the
type. Pasting, containers and removing treat it as they treat
every reference since #258.
- shownFor is a new schema option: a row is shown while an enum of
the same bag holds one of the named values.
- The object needs a rigidbody, its own or one further up. Without
one the joint holds nothing and the console says so once.
- A connected object without a rigidbody is a moving anchor: the
joint has a kinematic body of its own that follows it.
- A connected object destroyed while playing makes the joint let go
with a hint. Removed in the editor the reference empties, so the
joint holds on to the scene. The issue asked for "inactive" there.
- "Add joint" ties two objects while the game runs, from a joint
asset as the template. Destroy on the joint lets go, there is no
Break joint node. Destroy with keep children takes the joints
along with the object.
- The scene view draws a line from each joint to what it holds on
to and rings the held point. A selected spring or rope has a
round handle on its far end, and dragging it is one undo step.
- Joints hold in Play only. The editor run simulates nothing
(#148), and the inspector of a placed joint says so.
- Rapier counts a revolute joint's limits absolutely, not from the
start, so the placed angle is added. Measured with a probe.
- The + menu has 25 types now and no longer fits under its button
on a canvas 800 high. It scrolls there, and e2e 04 scrolls to
the last entry instead of expecting it in view.
Tests:
- Gate on melektaus-atx for this state: 218 files, 3390 tests
(3336 + 54), exit 0.
- e2e 1-16,24,39-46,51,52,57,58 on melektaus-atx: 831 checks, all
pass. Module 41 alone: 25 checks (21 + 4).
- 54 new unit tests: 40 in joints.test.ts (rope, spring, pendulum,
limit, fixed, the other end, the node, containers, paste), 6 for
the line the scene view draws, 4 catalog cases of the node. The
remaining 4 come from tests that make a case per asset type.
- Four new e2e checks in module 41: Add joint from the menu, the
rope to the hook with its line and handle, dragging the handle
with one undo, the bob hanging in Play next to one that falls.
- e2e 04, 40 and 46 count the new type in their lists.
- 21 of 22 unit counter-probes fail their tests. The other one is
equivalent: Rapier ignores removing a joint that left with its
body, the guard stays.
- Three browser counter-probes fail their checks (2, 1 and 2).
- One screenshot looked at: icon, line, handle and the rope rows.
- The e2e sum of 1691 is counted (1687 + 4, 1687 measured by CI
on e066613). The full suite and the smokes did not run.
- Not seen in the browser: spring, hinge with limit and fixed
joint, the node Add joint, a connected object that moves, joints
inside a container, the released game. Unit tests cover them
but the last. Limits under a mirrored parent are not handled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
648 lines
28 KiB
TypeScript
648 lines
28 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, 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 { 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 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;
|
||
}
|
||
|
||
/**
|
||
* 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[] = [];
|
||
/** 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;
|
||
}
|
||
|
||
/**
|
||
* 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) 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,
|
||
);
|
||
}
|
||
|
||
/** 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`) 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) 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;
|
||
}
|
||
}
|