An Object variable could only be set by a graph while the game runs.
A placed object always showed "(null)", although the other objects of
its scene are there to choose from and the runtime can already find
any object by its id. Now the placed object stores a reference to one
of them, and the running game turns it into the real object.
- Stored as {"$object": "<id>"}. The empty placeholder stays as it
was, so old projects load unchanged and empty still means "(null)".
- The row of an Object variable on a placed object is a dropdown with
None and every object of the scene, indented like the Hierarchy.
The same goes for Object fields in structs and Object items in
lists, also in the Variables and List tabs.
- The behavior asset keeps "(null)". An asset has no scene, so "Apply
to asset" is gone from Object rows.
- The runtime resolves the reference when the Variables or Properties
node reads it. A destroyed target reads as null, a missing one shows
"(missing)" in the row.
- Inside a container only objects of the same container are offered.
The reference keeps the template id, so every placement finds its
own copy.
- Deleting the target clears the reference in the same undo step.
Paste points references inside the pasted group at the copies and
leaves outside references alone. Create container and Unpack carry
references along with the new ids.
- A value a graph writes while running stays in the run. The scene
keeps the picked object, also in "Run in editor".
Found on the way:
- Setting an override coerced every object slot to empty, in plain
values, struct fields and list items. The shared objectPlace() now
keeps the reference.
Not done:
- No eyedropper to pick in the scene, the dropdown is enough for now.
- Deleting a template object in the container editor does not clean up
references in other scenes. They read as null there.
Tests:
- ./check.sh: 187 files, 2724 tests, CHECK EXIT 0
- e2e 1-9, 51, 57: ALL PASSED, 389 checks
- e2e 1, 57 on the final bundle: ALL PASSED, 17 checks
- 13 unit tests in object-references.test.ts, 5 checks in e2e 57
- Counter-checks: 8 unit breaks fail their tests, the browser break
without a pick list fails 57b-57d
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
11 KiB
TypeScript
249 lines
11 KiB
TypeScript
import type { AnimationPlayer } from '../model/animation';
|
||
import type { Mat } from '../model/matrix';
|
||
import type { Position, PropertyBag } from '../model/Properties';
|
||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||
import { enabledAlongChain } from '../properties/enabled';
|
||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||
|
||
/**
|
||
* Marks a live entity apart from a struct value: both reach a data edge as an object. A symbol
|
||
* rather than a field, so no struct field name can impersonate it – and it never serializes,
|
||
* which is right for a thing that exists only while the game runs.
|
||
*/
|
||
export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
|
||
|
||
/**
|
||
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
|
||
* `x`/`y`/`props` is how actions take effect.
|
||
*/
|
||
export interface EntityHandle {
|
||
readonly [ENTITY_HANDLE]: true;
|
||
id: string;
|
||
assetId: string;
|
||
name: string;
|
||
/** Position local to `parent` (world position for top-level entities). */
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
props: PropertyBag;
|
||
parent: EntityHandle | null;
|
||
/**
|
||
* Counts the Emit pulses a particle source has been given. Kept off `props` for the reason the
|
||
* animation players are: what the world writes back to the model must not grow an override per frame.
|
||
*/
|
||
emits?: number;
|
||
/** What `Play animation` started on this object (see `model/animation.ts`). */
|
||
animations?: AnimationPlayer[];
|
||
}
|
||
|
||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||
export function worldTransformOf(handle: EntityHandle): ChainTransform {
|
||
return composeAlongChain(
|
||
handle,
|
||
(h) => h.parent,
|
||
(h) => ({ m: localMatrixOf(h, transformOf(h.props)), z: h.z }),
|
||
);
|
||
}
|
||
|
||
export function worldMatrixOf(handle: EntityHandle): Mat {
|
||
return worldTransformOf(handle).m;
|
||
}
|
||
|
||
export function worldPositionOf(handle: EntityHandle): Position {
|
||
return positionOf(worldTransformOf(handle));
|
||
}
|
||
|
||
/** Whether a handle and every ancestor of it are switched on. */
|
||
export function isHandleEnabled(handle: EntityHandle): boolean {
|
||
return enabledAlongChain(
|
||
handle,
|
||
(h) => h.props,
|
||
(h) => h.parent,
|
||
);
|
||
}
|
||
|
||
/** Position in world units. */
|
||
export interface PointerState {
|
||
x: number;
|
||
y: number;
|
||
inside: boolean;
|
||
down: boolean;
|
||
pressed: boolean;
|
||
released: boolean;
|
||
}
|
||
|
||
/**
|
||
* What is shared for the length of one run: the values of each Variables asset, keyed by its id,
|
||
* and the current state of each `global` machine. Two Variables assets may declare the same name
|
||
* without meeting, since a node names the asset it reads (see `graph/sharedState.ts`).
|
||
*/
|
||
export interface SharedState {
|
||
variables: Record<string, PropertyBag>;
|
||
states: Record<string, string>;
|
||
}
|
||
|
||
/**
|
||
* What a graph can do to the played game's physics (#147). The object is the one a rigidbody sits
|
||
* on, or the rigidbody itself; only a dynamic body is acted on or read, and each call answers
|
||
* whether there was one. Vectors are world units, turning is degrees the way rotation counts.
|
||
*/
|
||
export interface PhysicsControl {
|
||
velocityOf(target: EntityHandle): { x: number; y: number; angular: number } | null;
|
||
setVelocity(target: EntityHandle, x: number, y: number): boolean;
|
||
setAngularVelocity(target: EntityHandle, degrees: number): boolean;
|
||
/** World units × kg per second: the velocity changes by impulse ÷ mass. */
|
||
applyImpulse(target: EntityHandle, x: number, y: number): boolean;
|
||
/** World units per second squared, for the rest of the run. */
|
||
setGravity(x: number, y: number): void;
|
||
}
|
||
|
||
/**
|
||
* What a move with collision met: which of the object's own axes something stood against, whether
|
||
* it hit anything, and the world normal of the first contact (pointing away from what was hit).
|
||
*/
|
||
export interface MoveOutcome {
|
||
blockedX: boolean;
|
||
blockedY: boolean;
|
||
hit: boolean;
|
||
normal: { x: number; y: number };
|
||
}
|
||
|
||
/** What a ray met (#153): the object and the collider (or tilemap), where, the face and how far. */
|
||
export interface RayOutcome {
|
||
object: EntityHandle;
|
||
collider: EntityHandle;
|
||
point: { x: number; y: number };
|
||
normal: { x: number; y: number };
|
||
distance: number;
|
||
}
|
||
|
||
/**
|
||
* Everything the graph does to the world goes through here, so the core stays free of runtime
|
||
* imports. Implemented by the runtime (BehaviorSystem) and by test doubles.
|
||
*/
|
||
export interface NodeContext {
|
||
self: EntityHandle;
|
||
/** The behavior's parent object – the one the graph acts on. */
|
||
owner: EntityHandle | null;
|
||
variables: PropertyBag;
|
||
/**
|
||
* The current state of each state machine this instance has set, keyed by machine asset id,
|
||
* as the state node's id. A machine not in here is in no state: `State branch` follows nothing.
|
||
*/
|
||
states: Record<string, string>;
|
||
shared: SharedState;
|
||
/**
|
||
* A machine moved from one state to another – what `On state exited` / `On state entered` are
|
||
* fired from. Reported rather than fired on the spot: the events belong after the exec flow that
|
||
* caused them, never in the middle of it (see `BehaviorSystem`).
|
||
*/
|
||
stateChanged(machineId: string, from: string, to: string): void;
|
||
/** Seconds since the world started and since the last tick. */
|
||
time: number;
|
||
dt: number;
|
||
findByName(name: string): EntityHandle | null;
|
||
/** Whether the object is still in the game – false once destroyed, or once its world has ended. */
|
||
isAlive(target: EntityHandle): boolean;
|
||
/** The object a reference on `from` names (#258, see `model/objectRefs.ts`); null when there is none. */
|
||
findReferenced(from: EntityHandle, ref: string): EntityHandle | null;
|
||
/** The nearest object called `name` below `parent` – a child before a grandchild; null for none. */
|
||
childByName(parent: EntityHandle, name: string): EntityHandle | null;
|
||
isKeyDown(key: string): boolean;
|
||
pointer: PointerState;
|
||
/**
|
||
* Whether a world point lies on `target`: on its colliders when it has any, otherwise on its own
|
||
* footprint or that of anything parented below it.
|
||
*/
|
||
hitTest(target: EntityHandle, x: number, y: number): boolean;
|
||
/** A collider entity stands for itself; any other entity for its direct collider children. */
|
||
isTouching(a: EntityHandle, b: EntityHandle): boolean;
|
||
/**
|
||
* Moves `target` by (dx, dy) in local units; its solid colliders stop flush at other solid ones
|
||
* and the rest of the move goes on along the surface, less what friction takes.
|
||
*/
|
||
moveAndSlide(target: EntityHandle, dx: number, dy: number): MoveOutcome;
|
||
/**
|
||
* The first solid a ray from (x, y) along (dx, dy) meets within `length`, all in world units;
|
||
* null for none. `ignore` and whatever is parented below it are not met, and neither is anything
|
||
* off the layers of `layers` (a 16-bit mask, #158).
|
||
*/
|
||
raycast(
|
||
from: { x: number; y: number },
|
||
direction: { x: number; y: number },
|
||
length: number,
|
||
options: { ignore: EntityHandle | null; triggers: boolean; layers: number },
|
||
): RayOutcome | null;
|
||
/**
|
||
* Everything that ray meets, nearest first, rather than only the first of them (#157). One entry
|
||
* per collider and one per tilemap cell; `maxHits` above 0 keeps that many of the nearest.
|
||
*/
|
||
raycastAll(
|
||
from: { x: number; y: number },
|
||
direction: { x: number; y: number },
|
||
length: number,
|
||
options: { ignore: EntityHandle | null; triggers: boolean; layers: number; maxHits: number },
|
||
): RayOutcome[];
|
||
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
||
ignoreOneWay(target: EntityHandle, seconds: number): void;
|
||
/** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */
|
||
isTilemap(target: EntityHandle): boolean;
|
||
/**
|
||
* The tile id in a cell of a placed tilemap, `''` for an empty one; null when `target` is no
|
||
* tilemap, has no layer of that name (`''` = its first) or the cell lies outside its grid.
|
||
*/
|
||
tileAt(target: EntityHandle, layer: string, cell: { x: number; y: number }): string | null;
|
||
/**
|
||
* Puts a tile into that cell, `''` empties it; false where `tileAt` answers null or the id is no
|
||
* tile. Only this placement changes, and only for the run (see `model/liveTilemap.ts`).
|
||
*/
|
||
setTile(target: EntityHandle, layer: string, cell: { x: number; y: number }, tileId: string): boolean;
|
||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||
physics?: PhysicsControl;
|
||
log(message: string): void;
|
||
/**
|
||
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
|
||
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
|
||
* because the asset says what a sound *is* and the node only colours it. Every call is a voice of
|
||
* its own: two plays overlap, whether they loop or not, which is what makes one sound effect
|
||
* fired twice in a row audible twice.
|
||
*
|
||
* The voice belongs to the behavior that started it and ends when that behavior is destroyed –
|
||
* a sound is something a thing in the world is doing. `detached` gives that up on purpose, for
|
||
* the one case where the rule is in the way: the death sound of an object that destroys itself.
|
||
*/
|
||
playSound(assetId: string, volume: number, loop: boolean | undefined, detached: boolean): void;
|
||
/** Silences every voice of that sound; `''` silences everything this run started. */
|
||
stopSound(assetId: string): void;
|
||
/**
|
||
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
|
||
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
|
||
* the kind of thing nobody can debug. The last request of a frame is the one that happens.
|
||
*/
|
||
loadScene(sceneId: string): void;
|
||
/**
|
||
* The position is local to `parent` (world when none); null when the asset does not exist or
|
||
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
|
||
*/
|
||
spawn(
|
||
assetId: string,
|
||
position: { x: number; y: number; z: number },
|
||
parent: EntityHandle | null,
|
||
): EntityHandle | null;
|
||
/**
|
||
* Removes `target` with everything below it. With `keepChildren` only the object goes: its
|
||
* children move up to its parent where they stand, and are answered, nearest level only.
|
||
*/
|
||
destroy(target: EntityHandle, keepChildren: boolean): EntityHandle[];
|
||
stop(): void;
|
||
/** Ends the run and starts it again from the saved scene (everything spawned is lost). */
|
||
restart(): void;
|
||
/** The global time scale the run goes at (#167): 1 is real time, 0 frozen. */
|
||
timeScale(): number;
|
||
/** For the rest of the run, the next scene included; Play and Stop set it back to 1. */
|
||
setTimeScale(scale: number): void;
|
||
/** Whether a behavior paused the game – not the editor's own pause, which runs no graph at all. */
|
||
isPaused(): boolean;
|
||
/** From the next frame on, only behaviors that run while paused are ticked. */
|
||
setPaused(paused: boolean): void;
|
||
}
|