An asset could only be picked as a setting on the Spawn node. It could not be wired in, held in a variable or put into a list. Finding an object below the own one needed a scene-wide search by name. - "Asset" is now a kind like Animation or Sound: port, variable, list item and a new "Asset" literal node, with its own cube icon. - Spawn and Is asset have an `asset` input. It uses the key of the old setting, so a saved asset reads as the value of the input and older projects keep spawning without a migration. - New node "Child by name" searches below an object level by level. A child wins over a grandchild of the same name. Nothing found or no object gives null. Tests: - ./check.sh: 168 files, 2453 tests, CHECK EXIT 0. - 9 new unit tests in tests/core/asset-kind.test.ts, catalog cases for Spawn, Is asset, Child by name and the literal. - e2e 6z-ii (2 checks): node rows and a real drag of the literal onto `asset`. e2e 6: 119 checks passed. Spawn checks in 3 and 9 passed. - Counter-checks: depth-first search fails 1, Spawn reading only the old setting fails 3, runtime ignoring the parent fails 1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
241 lines
10 KiB
TypeScript
241 lines
10 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;
|
||
/** 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;
|
||
destroy(target: EntityHandle): void;
|
||
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;
|
||
}
|