- Collider property isTrigger (no blocking, still reports enter/stay/exit) - Node 'Move and slide' with blocked x/y outputs, axis-wise resolution - axisSeparation/axisCorrection geometry for box/box, circle/circle, box/circle - Amber outline for trigger colliders in the viewport - Demo mover uses Move and slide; E2E step 15b, 7 new unit tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
188 lines
6.6 KiB
TypeScript
188 lines
6.6 KiB
TypeScript
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||
import type { PropertyDef } from '../properties/PropertySchema';
|
||
import type { PortType } from './Graph';
|
||
|
||
/** A value travelling along a data edge. Objects are live entity handles (play mode only). */
|
||
export type Value = PropertyValue | EntityHandle | null;
|
||
|
||
/**
|
||
* What the graph sees of a running entity. The runtime's Entity satisfies this shape; unit
|
||
* tests use plain objects. Mutating `x`/`y`/`props` is how actions take effect.
|
||
*/
|
||
export interface EntityHandle {
|
||
id: string;
|
||
name: string;
|
||
/** Position local to `parent` (world position for top-level entities). */
|
||
x: number;
|
||
y: number;
|
||
z: number;
|
||
props: PropertyBag;
|
||
/** The entity this one is parented to; children move with their parent. */
|
||
parent: EntityHandle | null;
|
||
}
|
||
|
||
/** World position of a handle: its local position plus those of all ancestors. */
|
||
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
|
||
const result = { x: handle.x, y: handle.y, z: handle.z };
|
||
const seen = new Set<EntityHandle>([handle]);
|
||
for (let p = handle.parent; p && !seen.has(p); p = p.parent) {
|
||
result.x += p.x;
|
||
result.y += p.y;
|
||
result.z += p.z;
|
||
seen.add(p);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Services a graph may use while running. Implemented by the runtime (BehaviorSystem) and
|
||
* by test doubles. Everything the graph does to the world goes through here, so the core
|
||
* stays free of runtime imports.
|
||
*/
|
||
export interface NodeContext {
|
||
/** The behavior entity itself. */
|
||
self: EntityHandle;
|
||
/** The behavior's parent object (the one the graph acts on), if any. */
|
||
owner: EntityHandle | null;
|
||
/** Mutable variable state of this behavior instance. */
|
||
variables: PropertyBag;
|
||
/** Seconds since the world started and since the last tick. */
|
||
time: number;
|
||
dt: number;
|
||
findByName(name: string): EntityHandle | null;
|
||
isKeyDown(key: string): boolean;
|
||
/**
|
||
* Whether any collider of `a` overlaps any collider of `b` right now. A collider entity
|
||
* stands for itself; any other entity stands for its direct collider children.
|
||
*/
|
||
isTouching(a: EntityHandle, b: EntityHandle): boolean;
|
||
/**
|
||
* Moves `target` by (dx, dy) in local units, stopping its solid colliders at other solid
|
||
* colliders axis by axis (x first, then y) so it slides along walls. Reports which axes hit.
|
||
*/
|
||
moveAndSlide(target: EntityHandle, dx: number, dy: number): { blockedX: boolean; blockedY: boolean };
|
||
log(message: string): void;
|
||
destroy(target: EntityHandle): void;
|
||
stop(): void;
|
||
}
|
||
|
||
export interface PortDef {
|
||
key: string;
|
||
label: string;
|
||
type: PortType;
|
||
/** Inline value used while the input is unconnected and the node has no own value (default 0/''/false). */
|
||
default?: PropertyValue;
|
||
}
|
||
|
||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action';
|
||
|
||
export interface RunResult {
|
||
/** Values for the node's data outputs (cached for the rest of the pulse). */
|
||
outputs?: Record<string, Value>;
|
||
/** Exec output(s) to continue along; omitted = the node ends the flow. */
|
||
next?: string | string[];
|
||
}
|
||
|
||
/**
|
||
* Describes one node type: its ports, its inline settings and how it runs. The editor
|
||
* palette, port rendering, connection validation and the inspector fields are all derived
|
||
* from this – adding a node type means adding one definition, no UI code.
|
||
*/
|
||
export interface NodeDef {
|
||
type: string;
|
||
label: string;
|
||
category: NodeCategory;
|
||
/** Shown in the palette. */
|
||
description: string;
|
||
inputs: PortDef[];
|
||
outputs: PortDef[];
|
||
/** Node settings that are not ports (key name, operator, variable). */
|
||
params: PropertyDef[];
|
||
/**
|
||
* Executes the node. Pure data nodes (no exec pins) are evaluated on demand whenever an
|
||
* input downstream needs their value; exec nodes run when the flow reaches them.
|
||
* Event nodes have no `run`; the interpreter seeds their outputs when it fires them.
|
||
*/
|
||
run?(ctx: NodeContext, inputs: Record<string, Value>, params: PropertyBag): RunResult;
|
||
}
|
||
|
||
const defs = new Map<string, NodeDef>();
|
||
|
||
export function registerNode(def: NodeDef): NodeDef {
|
||
if (defs.has(def.type)) throw new Error(`Node type "${def.type}" registered twice`);
|
||
defs.set(def.type, def);
|
||
return def;
|
||
}
|
||
|
||
export function nodeDef(type: string): NodeDef | undefined {
|
||
return defs.get(type);
|
||
}
|
||
|
||
export function requireNodeDef(type: string): NodeDef {
|
||
const def = defs.get(type);
|
||
if (!def) throw new Error(`Unknown node type "${type}"`);
|
||
return def;
|
||
}
|
||
|
||
export function allNodeDefs(): NodeDef[] {
|
||
return [...defs.values()];
|
||
}
|
||
|
||
export function isEventNode(def: NodeDef): boolean {
|
||
return def.category === 'event';
|
||
}
|
||
|
||
export function hasExecInput(def: NodeDef): boolean {
|
||
return def.inputs.some((p) => p.type === 'exec');
|
||
}
|
||
|
||
/** Data inputs that may carry an inline value when unconnected (numbers, strings, booleans, any). */
|
||
export function inlineInputs(def: NodeDef): PortDef[] {
|
||
return def.inputs.filter((p) => p.type !== 'exec' && p.type !== 'object');
|
||
}
|
||
|
||
/** A PropertyDef for editing an inline input value of the given port. */
|
||
export function inlineInputDef(port: PortDef): PropertyDef {
|
||
switch (port.type) {
|
||
case 'boolean':
|
||
return { key: port.key, label: port.label, kind: 'boolean', default: asBoolean(port.default ?? false) };
|
||
case 'string':
|
||
case 'any':
|
||
return { key: port.key, label: port.label, kind: 'text', default: asString(port.default ?? '') };
|
||
default:
|
||
return { key: port.key, label: port.label, kind: 'number', default: asNumber(port.default ?? 0), step: 1 };
|
||
}
|
||
}
|
||
|
||
/** The value an unconnected input carries: the node's own inline value, else the port default. */
|
||
export function inlineValue(node: { params: PropertyBag }, port: PortDef): Value {
|
||
return node.params[port.key] ?? port.default ?? null;
|
||
}
|
||
|
||
// --- value helpers for node implementations ---------------------------------------------
|
||
|
||
export function asNumber(value: Value | undefined): number {
|
||
if (typeof value === 'number') return value;
|
||
if (typeof value === 'boolean') return value ? 1 : 0;
|
||
if (typeof value === 'string') {
|
||
const n = Number(value);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
export function asBoolean(value: Value | undefined): boolean {
|
||
if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0';
|
||
return Boolean(value);
|
||
}
|
||
|
||
export function asString(value: Value | undefined): string {
|
||
if (value === null || value === undefined) return '';
|
||
if (typeof value === 'object') return value.name;
|
||
return String(value);
|
||
}
|
||
|
||
export function asEntity(value: Value | undefined): EntityHandle | null {
|
||
return value && typeof value === 'object' ? value : null;
|
||
}
|