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; } /** 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 = 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(); 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(); /** Set when an entity left the run; the next `syncToModel` drops its object. */ private entitiesGone = false; private readonly modelStates = new WeakMap(); 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(); /** Objects this scene did not build because one brought along is it already, by their id. */ private readonly standIns = new Map(); 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(); 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(); 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; } }