Split GraphCanvas (1076 -> 831 lines) into EdgeLayer and graphMenus; the port and marker menus were the same list and are now one function. Value specs of the inspector and the list tab share valueSpecs.ts, and the remaining copies (compound commands, type glyphs, typed asset lookup, name rule, play/editor world setup, marquee) are merged. Four copies had already drifted apart; merging them fixes: - "Disconnect N links" in the port menu is one undo step, not N - "+ Add collider" is hidden on a placed container instance, as in the hierarchy menu, instead of always failing - a bool property coerced from the text "false" becomes false - a port command on an unknown node type reports instead of crashing Comment-only edits leave the bundle byte-identical. README corrected in 35 places (step limit, jump-run clock, suite duration, 409 body, ...). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
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<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;
|
||
}
|
||
}
|
||
|
||
/** 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);
|
||
}
|
||
}
|