import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldPositionOf } from '../core/graph'; import { newId } from '../core/ids'; import type { Asset } from '../core/model/Asset'; import { isPlaceableType } from '../core/model/AssetTypes'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { treeOrder } from '../core/model/hierarchy'; import { descendantsBelow } from '../core/model/walk'; import { findAsset, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { footprintContains } from '../core/properties/bounds'; import { colliderShape, shapeContains } from '../core/properties/collider'; import { resolveSceneObjectProps } from '../core/properties/resolve'; import type { Entity } from './Entity'; import type { System } from './systems/System'; 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[] = []; backgroundColor = 0x000000; /** 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; /** * 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, }; /** Messages logged by behaviors (Log node), newest last. */ readonly log: string[] = []; private readonly systems: System[] = []; private elapsed = 0; /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ private structureChanged = 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; } /** 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; const background = scene.props.backgroundColor; this.backgroundColor = typeof background === 'number' ? background : 0x000000; 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; } } /** 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, }; this.entities.push(entity); } entity.assetId = asset.id; entity.type = asset.type; entity.name = object.name || asset.name; entity.x = object.position.x; entity.y = object.position.y; entity.z = object.position.z; const resolved = resolveSceneObjectProps(asset, object); for (const key of Object.keys(entity.props)) if (!(key in resolved)) delete entity.props[key]; Object.assign(entity.props, resolved); 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 ?? ''); 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; 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)) { 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 = resolveSceneObjectProps(asset, object); const drifted = Object.keys(entity.props).filter((k) => k in base && 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, entity.props[key]); values = true; } return { structure: spawned || 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; } /** Advances the simulation by `dt` seconds. */ tick(dt: number): void { this.elapsed += dt; for (const system of this.systems) 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; } /** 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 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)); const colliders = parts.filter((e) => e.type === 'collider'); if (colliders.length > 0) return colliders.some((c) => shapeContains(colliderShape(c.props, worldPositionOf(c)), point)); return parts.some( (e) => (e.type === 'sprite' || e.type === 'text') && footprintContains(e.type, e.props, worldPositionOf(e), point), ); } 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 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 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); } }