Behaviors with Blueprint-style node graph, graph editor tab and BehaviorSystem
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
d4b1ffa051
commit
922774778b
36 files changed
+2969
-147
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { createEmptyGraph } from '../graph/Graph';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { findAsset, findUsages, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue, defaultsFor, schemaFor } from '../properties/PropertySchema';
|
||||
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
|
||||
import { newId } from '../ids';
|
||||
import { type Command, CommandError } from './Command';
|
||||
|
||||
@@ -16,6 +17,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'camera' };
|
||||
case 'sprite':
|
||||
return { ...base, type: 'sprite' };
|
||||
case 'behavior':
|
||||
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +50,7 @@ export class DeleteAssetCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { asset: Asset; index: number };
|
||||
private removedUsages: { sceneId: string; object: SceneObject; index: number }[] = [];
|
||||
private clearedOwners: { sceneId: string; objectId: string; ownerId: string }[] = [];
|
||||
private previousActiveScene = '';
|
||||
|
||||
constructor(private readonly assetId: string) {
|
||||
@@ -62,9 +66,18 @@ export class DeleteAssetCommand implements Command {
|
||||
object,
|
||||
index: scene.objects.indexOf(object),
|
||||
}));
|
||||
this.clearedOwners = [];
|
||||
for (const usage of this.removedUsages) {
|
||||
const scene = findAsset(project, usage.sceneId);
|
||||
if (scene && scene.type === 'scene') scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
|
||||
if (!scene || scene.type !== 'scene') continue;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== usage.object.id);
|
||||
// Objects that pointed at a removed object as their owner lose that reference.
|
||||
for (const other of scene.objects) {
|
||||
if (other.ownerId === usage.object.id) {
|
||||
this.clearedOwners.push({ sceneId: scene.id, objectId: other.id, ownerId: other.ownerId });
|
||||
other.ownerId = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
this.removed = { asset, index };
|
||||
project.assets.splice(index, 1);
|
||||
@@ -83,6 +96,11 @@ export class DeleteAssetCommand implements Command {
|
||||
const scene = findAsset(project, usage.sceneId);
|
||||
if (scene && scene.type === 'scene') scene.objects.splice(usage.index, 0, usage.object);
|
||||
}
|
||||
for (const cleared of this.clearedOwners) {
|
||||
const scene = findAsset(project, cleared.sceneId);
|
||||
const object = scene && scene.type === 'scene' ? scene.objects.find((o) => o.id === cleared.objectId) : undefined;
|
||||
if (object) object.ownerId = cleared.ownerId;
|
||||
}
|
||||
project.activeSceneId = this.previousActiveScene;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +132,7 @@ export class SetAssetPropertyCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
||||
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||
this.previous = asset.props[this.key];
|
||||
asset.props[this.key] = coerceValue(def, this.value);
|
||||
@@ -138,7 +156,7 @@ export class ResetAssetPropertyCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
||||
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||
this.previous = asset.props[this.key];
|
||||
asset.props[this.key] = def.default;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
|
||||
import { requireNodeDef } from '../graph/NodeRegistry';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||||
import { newId } from '../ids';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './variableCommands';
|
||||
|
||||
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
|
||||
export class AddNodeCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly node: GraphNode;
|
||||
|
||||
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
|
||||
const def = requireNodeDef(type);
|
||||
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def.params), ...params } };
|
||||
this.label = `Add ${def.label}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== this.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a node and every edge touching it. */
|
||||
export class RemoveNodeCommand implements Command {
|
||||
readonly label = 'Remove node';
|
||||
private removed?: { node: GraphNode; index: number; edges: { edge: GraphEdge; index: number }[] };
|
||||
|
||||
constructor(private readonly assetId: string, private readonly nodeId: string) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const index = graph.nodes.findIndex((n) => n.id === this.nodeId);
|
||||
const node = graph.nodes[index];
|
||||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||||
const edges = edgesOfNode(graph, this.nodeId).map((edge) => ({ edge, index: graph.edges.indexOf(edge) }));
|
||||
this.removed = { node, index, edges };
|
||||
graph.nodes.splice(index, 1);
|
||||
graph.edges = graph.edges.filter((e) => e.from.node !== this.nodeId && e.to.node !== this.nodeId);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
graph.nodes.splice(this.removed.index, 0, this.removed.node);
|
||||
for (const { edge, index } of [...this.removed.edges].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
|
||||
}
|
||||
}
|
||||
|
||||
export class MoveNodeCommand implements Command {
|
||||
readonly label = 'Move node';
|
||||
private previous = { x: 0, y: 0 };
|
||||
|
||||
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly x: number, private readonly y: number, previous?: { x: number; y: number }) {
|
||||
if (previous) this.previous = { ...previous };
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
this.previous = { x: node.x, y: node.y };
|
||||
node.x = Math.round(this.x);
|
||||
node.y = Math.round(this.y);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
node.x = this.previous.x;
|
||||
node.y = this.previous.y;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets a node param or the inline value of an unconnected data input. */
|
||||
export class SetNodeParamCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly key: string, private readonly value: PropertyValue) {
|
||||
this.label = `Set ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
const def = requireNodeDef(node.type);
|
||||
const param = def.params.find((p) => p.key === this.key);
|
||||
this.previous = node.params[this.key];
|
||||
// Inline input values are stored as-is; declared params are coerced against their definition.
|
||||
node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
if (this.previous === undefined) delete node.params[this.key];
|
||||
else node.params[this.key] = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects an output to an input. Validates port existence and type compatibility; a data
|
||||
* input holds at most one edge, so an existing one is replaced (and restored on undo).
|
||||
*/
|
||||
export class ConnectCommand implements Command {
|
||||
readonly label = 'Connect';
|
||||
readonly edge: GraphEdge;
|
||||
private replaced: { edge: GraphEdge; index: number }[] = [];
|
||||
|
||||
constructor(private readonly assetId: string, from: { node: string; port: string }, to: { node: string; port: string }) {
|
||||
this.edge = { id: newId(), from: { ...from }, to: { ...to } };
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const fromNode = findNode(graph, this.edge.from.node);
|
||||
const toNode = findNode(graph, this.edge.to.node);
|
||||
if (!fromNode || !toNode) throw new CommandError('Node not found');
|
||||
if (fromNode === toNode) throw new CommandError('A node cannot connect to itself');
|
||||
const out = requireNodeDef(fromNode.type).outputs.find((p) => p.key === this.edge.from.port);
|
||||
const inp = requireNodeDef(toNode.type).inputs.find((p) => p.key === this.edge.to.port);
|
||||
if (!out || !inp) throw new CommandError('Port not found');
|
||||
if (!portsCompatible(out.type, inp.type)) throw new CommandError(`Cannot connect ${out.type} to ${inp.type}`);
|
||||
if (graph.edges.some((e) => sameEnds(e, this.edge))) throw new CommandError('Already connected');
|
||||
this.replaced = [];
|
||||
if (inp.type !== 'exec') {
|
||||
for (const existing of edgesInto(graph, this.edge.to.node, this.edge.to.port)) {
|
||||
this.replaced.push({ edge: existing, index: graph.edges.indexOf(existing) });
|
||||
}
|
||||
graph.edges = graph.edges.filter((e) => !this.replaced.some((r) => r.edge === e));
|
||||
}
|
||||
graph.edges.push(this.edge);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
graph.edges = graph.edges.filter((e) => e.id !== this.edge.id);
|
||||
for (const { edge, index } of [...this.replaced].sort((a, b) => a.index - b.index)) graph.edges.splice(index, 0, edge);
|
||||
}
|
||||
}
|
||||
|
||||
export class DisconnectCommand implements Command {
|
||||
readonly label = 'Disconnect';
|
||||
private removed?: { edge: GraphEdge; index: number };
|
||||
|
||||
constructor(private readonly assetId: string, private readonly edgeId: string) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const index = graph.edges.findIndex((e) => e.id === this.edgeId);
|
||||
const edge = graph.edges[index];
|
||||
if (!edge) throw new CommandError(`Edge ${this.edgeId} not found`);
|
||||
this.removed = { edge, index };
|
||||
graph.edges.splice(index, 1);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (this.removed) requireBehavior(project, this.assetId).graph.edges.splice(this.removed.index, 0, this.removed.edge);
|
||||
}
|
||||
}
|
||||
|
||||
function requireNode(project: Project, assetId: string, nodeId: string): GraphNode {
|
||||
const node = findNode(requireBehavior(project, assetId).graph, nodeId);
|
||||
if (!node) throw new CommandError(`Node ${nodeId} not found`);
|
||||
return node;
|
||||
}
|
||||
|
||||
function sameEnds(a: GraphEdge, b: GraphEdge): boolean {
|
||||
return a.from.node === b.from.node && a.from.port === b.from.port && a.to.node === b.to.node && a.to.port === b.to.port;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { isPlaceableType } from '../model/AssetTypes';
|
||||
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
|
||||
import { findScene, findSceneObject, type Project } from '../model/Project';
|
||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
||||
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
|
||||
import { newId } from '../ids';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './assetCommands';
|
||||
@@ -13,7 +13,7 @@ function requireScene(project: Project, sceneId: string) {
|
||||
return scene;
|
||||
}
|
||||
|
||||
function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
||||
export function requireObject(project: Project, sceneId: string, objectId: string): SceneObject {
|
||||
const object = findSceneObject(requireScene(project, sceneId), objectId);
|
||||
if (!object) throw new CommandError(`Scene object ${objectId} not found`);
|
||||
return object;
|
||||
@@ -42,6 +42,7 @@ export class PlaceAssetCommand implements Command {
|
||||
export class RemoveSceneObjectCommand implements Command {
|
||||
readonly label = 'Remove object';
|
||||
private removed?: { object: SceneObject; index: number };
|
||||
private clearedOwners: string[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string) {}
|
||||
|
||||
@@ -52,11 +53,24 @@ export class RemoveSceneObjectCommand implements Command {
|
||||
if (!object) throw new CommandError(`Scene object ${this.objectId} not found`);
|
||||
this.removed = { object, index };
|
||||
scene.objects.splice(index, 1);
|
||||
// Objects owned by the removed one lose their owner reference.
|
||||
this.clearedOwners = [];
|
||||
for (const other of scene.objects) {
|
||||
if (other.ownerId === this.objectId) {
|
||||
other.ownerId = '';
|
||||
this.clearedOwners.push(other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
requireScene(project, this.sceneId).objects.splice(this.removed.index, 0, this.removed.object);
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
scene.objects.splice(this.removed.index, 0, this.removed.object);
|
||||
for (const id of this.clearedOwners) {
|
||||
const other = findSceneObject(scene, id);
|
||||
if (other) other.ownerId = this.objectId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +133,7 @@ export class SetOverrideCommand implements Command {
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
const def = schemaFor(asset.type).find((d) => d.key === this.key);
|
||||
const def = schemaForAsset(asset).find((d) => d.key === this.key);
|
||||
if (!def) throw new CommandError(`Property "${this.key}" is not defined for ${asset.type}`);
|
||||
this.previous = object.overrides[this.key];
|
||||
object.overrides[this.key] = coerceValue(def, this.value);
|
||||
@@ -152,3 +166,25 @@ export class ClearOverrideCommand implements Command {
|
||||
requireObject(project, this.sceneId, this.objectId).overrides[this.key] = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** Points an object (typically a behavior) at another object of the same scene, or at nothing (''). */
|
||||
export class SetOwnerCommand implements Command {
|
||||
readonly label = 'Set owner';
|
||||
private previous = '';
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly ownerId: string) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
if (this.ownerId) {
|
||||
if (this.ownerId === this.objectId) throw new CommandError('An object cannot own itself');
|
||||
requireObject(project, this.sceneId, this.ownerId);
|
||||
}
|
||||
this.previous = object.ownerId;
|
||||
object.ownerId = this.ownerId;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireObject(project, this.sceneId, this.objectId).ownerId = this.previous;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import type { BehaviorAsset } from '../model/Asset';
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { findAsset, findUsages, type Project } from '../model/Project';
|
||||
import { type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||||
import { type Command, CommandError } from './Command';
|
||||
|
||||
export function requireBehavior(project: Project, assetId: string): BehaviorAsset {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (!asset || asset.type !== 'behavior') throw new CommandError(`Behavior ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
/** "var1", "var2", … – the first variable name not yet used by the behavior. */
|
||||
export function nextVariableName(asset: BehaviorAsset, base = 'var'): string {
|
||||
const taken = new Set(asset.variables.map((v) => v.key));
|
||||
for (let i = 1; ; i++) {
|
||||
const candidate = `${base}${i}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
/** Declares a new variable on a behavior; its default value is written to `props`. */
|
||||
export class AddVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
readonly def: PropertyDef;
|
||||
|
||||
constructor(private readonly assetId: string, key: string, kind: VariableKind) {
|
||||
this.def = variableDef(key, kind);
|
||||
this.label = `Add variable ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
if (!isValidVariableName(this.def.key)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
|
||||
if (asset.variables.some((v) => v.key === this.def.key)) throw new CommandError(`Variable "${this.def.key}" already exists`);
|
||||
asset.variables.push(this.def);
|
||||
asset.props[this.def.key] = this.def.default;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
asset.variables = asset.variables.filter((v) => v.key !== this.def.key);
|
||||
delete asset.props[this.def.key];
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes a variable together with its default and every instance override. */
|
||||
export class RemoveVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { def: PropertyDef; index: number; value: PropertyValue | undefined };
|
||||
private overrides: { sceneId: string; objectId: string; value: PropertyValue }[] = [];
|
||||
|
||||
constructor(private readonly assetId: string, private readonly key: string) {
|
||||
this.label = `Remove variable ${key}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const index = asset.variables.findIndex((v) => v.key === this.key);
|
||||
const def = asset.variables[index];
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.removed = { def, index, value: asset.props[this.key] };
|
||||
asset.variables.splice(index, 1);
|
||||
delete asset.props[this.key];
|
||||
this.overrides = [];
|
||||
for (const { scene, object } of findUsages(project, this.assetId)) {
|
||||
const value = object.overrides[this.key];
|
||||
if (value === undefined) continue;
|
||||
this.overrides.push({ sceneId: scene.id, objectId: object.id, value });
|
||||
delete object.overrides[this.key];
|
||||
}
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
asset.variables.splice(this.removed.index, 0, this.removed.def);
|
||||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||
for (const o of this.overrides) {
|
||||
const object = findUsages(project, this.assetId).find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object;
|
||||
if (object) object.overrides[this.key] = o.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames a variable everywhere: definition, default, instance overrides and graph nodes referring to it. */
|
||||
export class RenameVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(private readonly assetId: string, private readonly from: string, private readonly to: string) {
|
||||
this.label = `Rename variable ${from}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.rename(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.rename(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private rename(project: Project, from: string, to: string): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
if (from === to) return;
|
||||
if (!isValidVariableName(to)) throw new CommandError('Variable names must start with a letter and contain only letters, digits or _');
|
||||
const def = asset.variables.find((v) => v.key === from);
|
||||
if (!def) throw new CommandError(`Variable "${from}" not found`);
|
||||
if (asset.variables.some((v) => v.key === to)) throw new CommandError(`Variable "${to}" already exists`);
|
||||
def.key = to;
|
||||
def.label = to;
|
||||
if (asset.props[from] !== undefined) {
|
||||
asset.props[to] = asset.props[from]!;
|
||||
delete asset.props[from];
|
||||
}
|
||||
for (const { object } of findUsages(project, this.assetId)) {
|
||||
if (object.overrides[from] === undefined) continue;
|
||||
object.overrides[to] = object.overrides[from]!;
|
||||
delete object.overrides[from];
|
||||
}
|
||||
for (const node of asset.graph.nodes) {
|
||||
const def = nodeDef(node.type);
|
||||
for (const param of def?.params ?? []) {
|
||||
if (param.key === 'variable' && node.params.variable === from) node.params.variable = to;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isValidVariableName(name: string): boolean {
|
||||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
|
||||
/**
|
||||
* Port data types. `exec` carries control flow (Blueprint-style white arrows); the others
|
||||
* carry values. `any` accepts every value type (variables, logging).
|
||||
*/
|
||||
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'any';
|
||||
|
||||
export interface PortRef {
|
||||
node: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One node of a behavior graph. `type` names a NodeDef in the registry; `params` holds
|
||||
* the node's own settings (e.g. which key) and inline values of unconnected data inputs,
|
||||
* both keyed by the param / port key.
|
||||
*/
|
||||
export interface GraphNode {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
params: PropertyBag;
|
||||
}
|
||||
|
||||
/** A connection from an output port to an input port. */
|
||||
export interface GraphEdge {
|
||||
id: string;
|
||||
from: PortRef;
|
||||
to: PortRef;
|
||||
}
|
||||
|
||||
export interface Graph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
|
||||
export function createEmptyGraph(): Graph {
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
|
||||
return graph.nodes.find((n) => n.id === nodeId);
|
||||
}
|
||||
|
||||
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
|
||||
return graph.edges.find((e) => e.id === edgeId);
|
||||
}
|
||||
|
||||
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
|
||||
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
|
||||
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
|
||||
}
|
||||
|
||||
/** Edges leaving one output port. */
|
||||
export function edgesOutOf(graph: Graph, nodeId: string, port: string): GraphEdge[] {
|
||||
return graph.edges.filter((e) => e.from.node === nodeId && e.from.port === port);
|
||||
}
|
||||
|
||||
/** Every edge touching a node (used when removing it). */
|
||||
export function edgesOfNode(graph: Graph, nodeId: string): GraphEdge[] {
|
||||
return graph.edges.filter((e) => e.from.node === nodeId || e.to.node === nodeId);
|
||||
}
|
||||
|
||||
/** Whether a value may flow from an output of type `from` into an input of type `to`. */
|
||||
export function portsCompatible(from: PortType, to: PortType): boolean {
|
||||
if (from === 'exec' || to === 'exec') return from === to;
|
||||
return from === to || from === 'any' || to === 'any';
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { edgesInto, edgesOutOf, type Graph, type GraphNode } from './Graph';
|
||||
import { hasExecInput, isEventNode, type NodeContext, requireNodeDef, type Value } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* Guards against runaway flows (a Sequence looping back into itself, …). The interpreter
|
||||
* recurses along exec edges, so the limit also keeps the call stack shallow.
|
||||
*/
|
||||
const MAX_STEPS_PER_FIRE = 1000;
|
||||
|
||||
export interface TraceListener {
|
||||
/** An exec edge was followed. */
|
||||
onEdge(edgeId: string): void;
|
||||
/** A node ran (exec) or was evaluated (data). */
|
||||
onNode(nodeId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one behavior graph for one instance. Blueprint semantics: an event node starts a
|
||||
* *pulse*; the interpreter follows exec edges, and whenever a node needs a data input it
|
||||
* pulls the value from the connected output – evaluating pure data nodes lazily and
|
||||
* reading exec nodes' outputs from the pulse cache. Per-node state (timers) lives here,
|
||||
* so every instance has its own.
|
||||
*/
|
||||
export class GraphInterpreter {
|
||||
/** Per-node persistent state across pulses (timer accumulators etc.). */
|
||||
readonly nodeState = new Map<string, Record<string, number>>();
|
||||
private outputCache = new Map<string, Record<string, Value>>();
|
||||
private steps = 0;
|
||||
trace: TraceListener | null = null;
|
||||
|
||||
constructor(readonly graph: Graph) {}
|
||||
|
||||
/** Nodes of one event type, in graph order. */
|
||||
eventNodes(type: string): GraphNode[] {
|
||||
return this.graph.nodes.filter((n) => n.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires every event node of `type` whose params satisfy `filter`, seeding its outputs with
|
||||
* `outputs` (e.g. `{ dt }`). Returns how many event nodes fired.
|
||||
*/
|
||||
fire(type: string, ctx: NodeContext, outputs: Record<string, Value> = {}, filter?: (node: GraphNode) => boolean): number {
|
||||
let fired = 0;
|
||||
for (const node of this.eventNodes(type)) {
|
||||
if (filter && !filter(node)) continue;
|
||||
this.outputCache = new Map();
|
||||
this.steps = 0;
|
||||
this.outputCache.set(node.id, { ...outputs });
|
||||
this.trace?.onNode(node.id);
|
||||
this.follow(node, 'exec', ctx);
|
||||
fired++;
|
||||
}
|
||||
return fired;
|
||||
}
|
||||
|
||||
/** Follows the exec edges leaving `port` of `node`, running each target in order. */
|
||||
private follow(node: GraphNode, port: string, ctx: NodeContext): void {
|
||||
for (const edge of edgesOutOf(this.graph, node.id, port)) {
|
||||
const target = this.node(edge.to.node);
|
||||
if (!target) continue;
|
||||
this.trace?.onEdge(edge.id);
|
||||
this.execute(target, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
private execute(node: GraphNode, ctx: NodeContext): void {
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (infinite loop?)');
|
||||
const def = requireNodeDef(node.type);
|
||||
if (!def.run) return;
|
||||
const inputs = this.collectInputs(node, ctx);
|
||||
const result = def.run(ctx, inputs, node.params);
|
||||
this.outputCache.set(node.id, result.outputs ?? {});
|
||||
this.trace?.onNode(node.id);
|
||||
const next = result.next === undefined ? [] : Array.isArray(result.next) ? result.next : [result.next];
|
||||
for (const port of next) this.follow(node, port, ctx);
|
||||
}
|
||||
|
||||
/** Resolves every data input: connected → pulled from the source, else the inline param. */
|
||||
private collectInputs(node: GraphNode, ctx: NodeContext): Record<string, Value> {
|
||||
const def = requireNodeDef(node.type);
|
||||
const inputs: Record<string, Value> = {};
|
||||
for (const port of def.inputs) {
|
||||
if (port.type === 'exec') continue;
|
||||
const edge = edgesInto(this.graph, node.id, port.key)[0];
|
||||
if (edge) inputs[port.key] = this.readOutput(edge.from.node, edge.from.port, ctx);
|
||||
else inputs[port.key] = node.params[port.key] ?? null;
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/** Value of one output port: from the pulse cache, or by evaluating a pure data node. */
|
||||
private readOutput(nodeId: string, port: string, ctx: NodeContext): Value {
|
||||
const cached = this.outputCache.get(nodeId);
|
||||
if (cached) return cached[port] ?? null;
|
||||
const source = this.node(nodeId);
|
||||
if (!source) return null;
|
||||
const def = requireNodeDef(source.type);
|
||||
// Exec nodes that have not run in this pulse have no value yet.
|
||||
if (isEventNode(def) || hasExecInput(def) || !def.run) return null;
|
||||
if (++this.steps > MAX_STEPS_PER_FIRE) throw new Error('Behavior graph exceeded the step limit (data cycle?)');
|
||||
// Mark before evaluating so a data cycle reads null instead of recursing forever.
|
||||
this.outputCache.set(nodeId, {});
|
||||
const outputs = def.run(ctx, this.collectInputs(source, ctx), source.params).outputs ?? {};
|
||||
this.outputCache.set(nodeId, outputs);
|
||||
this.trace?.onNode(nodeId);
|
||||
return outputs[port] ?? null;
|
||||
}
|
||||
|
||||
private node(id: string): GraphNode | undefined {
|
||||
return this.graph.nodes.find((n) => n.id === id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 scene object the behavior was attached to, 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;
|
||||
log(message: string): void;
|
||||
destroy(target: EntityHandle): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
export interface PortDef {
|
||||
key: string;
|
||||
label: string;
|
||||
type: PortType;
|
||||
}
|
||||
|
||||
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: false };
|
||||
case 'string':
|
||||
case 'any':
|
||||
return { key: port.key, label: port.label, kind: 'text', default: '' };
|
||||
default:
|
||||
return { key: port.key, label: port.label, kind: 'number', default: 0, step: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Importing the graph package registers the built-in node set as a side effect.
|
||||
import './nodes';
|
||||
|
||||
export * from './Graph';
|
||||
export * from './NodeRegistry';
|
||||
export { GraphInterpreter, type TraceListener } from './GraphInterpreter';
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import { asBoolean, asEntity, asNumber, asString, type PortDef, registerNode } from './NodeRegistry';
|
||||
|
||||
/**
|
||||
* The built-in node set. Each entry is data + a `run` function; everything else (palette,
|
||||
* ports, inspector fields, validation) is derived. Importing this module registers them.
|
||||
*/
|
||||
|
||||
const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
|
||||
const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
|
||||
const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
|
||||
const obj = (key: string, label = key): PortDef => ({ key, label, type: 'object' });
|
||||
const any = (key: string, label = key): PortDef => ({ key, label, type: 'any' });
|
||||
|
||||
const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
|
||||
/** Placeholder: the graph inspector replaces the options with the asset's variables. */
|
||||
const variableParam: PropertyDef = { key: 'variable', label: 'Variable', kind: 'enum', default: '', options: [] };
|
||||
|
||||
// --- events -------------------------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'OnStart', label: 'On start', category: 'event', description: 'Runs once when the game starts.', inputs: [], outputs: [exec()], params: [] });
|
||||
registerNode({ type: 'OnUpdate', label: 'On update', category: 'event', description: 'Runs every frame.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [] });
|
||||
registerNode({ type: 'OnKeyDown', label: 'On key down', category: 'event', description: 'Runs once when the key is pressed.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||
registerNode({ type: 'OnKeyUp', label: 'On key up', category: 'event', description: 'Runs once when the key is released.', inputs: [], outputs: [exec()], params: [keyParam] });
|
||||
registerNode({ type: 'OnKeyHeld', label: 'On key held', category: 'event', description: 'Runs every frame while the key is held.', inputs: [], outputs: [exec(), num('dt', 'dt')], params: [keyParam] });
|
||||
registerNode({
|
||||
type: 'OnTimer',
|
||||
label: 'On timer',
|
||||
category: 'event',
|
||||
description: 'Runs every N seconds.',
|
||||
inputs: [],
|
||||
outputs: [exec()],
|
||||
params: [{ key: 'interval', label: 'Interval (s)', kind: 'number', default: 1, min: 0.01, step: 0.1 }],
|
||||
});
|
||||
|
||||
// --- flow ---------------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'Branch',
|
||||
label: 'Branch',
|
||||
category: 'flow',
|
||||
description: 'Continues along "then" or "else" depending on the condition.',
|
||||
inputs: [exec(), bool('condition')],
|
||||
outputs: [exec('then', 'then'), exec('else', 'else')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => ({ next: asBoolean(inputs.condition) ? 'then' : 'else' }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'Sequence',
|
||||
label: 'Sequence',
|
||||
category: 'flow',
|
||||
description: 'Runs its outputs one after another.',
|
||||
inputs: [exec()],
|
||||
outputs: [exec('a', '1'), exec('b', '2'), exec('c', '3')],
|
||||
params: [],
|
||||
run: () => ({ next: ['a', 'b', 'c'] }),
|
||||
});
|
||||
|
||||
// --- data: references --------------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'Self', label: 'Self', category: 'data', description: 'This behavior object.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.self } }) });
|
||||
registerNode({ type: 'Owner', label: 'Owner', category: 'data', description: 'The object this behavior is attached to.', inputs: [], outputs: [obj('object')], params: [], run: (ctx) => ({ outputs: { object: ctx.owner } }) });
|
||||
registerNode({
|
||||
type: 'ObjectByName',
|
||||
label: 'Object by name',
|
||||
category: 'data',
|
||||
description: 'Finds a scene object by its name.',
|
||||
inputs: [],
|
||||
outputs: [obj('object')],
|
||||
params: [{ key: 'name', label: 'Name', kind: 'text', default: '' }],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { object: ctx.findByName(String(params.name ?? '')) } }),
|
||||
});
|
||||
|
||||
// --- variables ----------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'GetVariable',
|
||||
label: 'Get variable',
|
||||
category: 'variable',
|
||||
description: 'Reads one of this behavior\'s variables.',
|
||||
inputs: [],
|
||||
outputs: [any('value')],
|
||||
params: [variableParam],
|
||||
run: (ctx, _inputs, params) => ({ outputs: { value: ctx.variables[String(params.variable ?? '')] ?? null } }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetVariable',
|
||||
label: 'Set variable',
|
||||
category: 'variable',
|
||||
description: 'Writes one of this behavior\'s variables.',
|
||||
inputs: [exec(), any('value')],
|
||||
outputs: [exec()],
|
||||
params: [variableParam],
|
||||
run: (ctx, inputs, params) => {
|
||||
const key = String(params.variable ?? '');
|
||||
const current = ctx.variables[key];
|
||||
const value = inputs.value;
|
||||
if (key) {
|
||||
if (typeof current === 'number') ctx.variables[key] = asNumber(value);
|
||||
else if (typeof current === 'boolean') ctx.variables[key] = asBoolean(value);
|
||||
else ctx.variables[key] = asString(value);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
|
||||
// --- data: properties and positions --------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'GetProperty',
|
||||
label: 'Get property',
|
||||
category: 'data',
|
||||
description: 'Reads a property (width, color, …) of an object.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [any('value')],
|
||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||
run: (_ctx, inputs, params) => ({ outputs: { value: asEntity(inputs.object)?.props[String(params.property ?? '')] ?? null } }),
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetProperty',
|
||||
label: 'Set property',
|
||||
category: 'action',
|
||||
description: 'Writes a property (width, color, …) of an object.',
|
||||
inputs: [exec(), obj('object'), any('value')],
|
||||
outputs: [exec()],
|
||||
params: [{ key: 'property', label: 'Property', kind: 'text', default: 'width' }],
|
||||
run: (_ctx, inputs, params) => {
|
||||
const target = asEntity(inputs.object);
|
||||
const key = String(params.property ?? '');
|
||||
if (target && key) {
|
||||
const current = target.props[key];
|
||||
const value = inputs.value;
|
||||
if (typeof current === 'number') target.props[key] = asNumber(value);
|
||||
else if (typeof current === 'boolean') target.props[key] = asBoolean(value);
|
||||
else target.props[key] = asString(value);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'GetPosition',
|
||||
label: 'Get position',
|
||||
category: 'data',
|
||||
description: 'The x/y position of an object.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('x'), num('y')],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
return { outputs: { x: target?.x ?? 0, y: target?.y ?? 0 } };
|
||||
},
|
||||
});
|
||||
|
||||
// --- data: literals and math ---------------------------------------------------------------
|
||||
|
||||
registerNode({ type: 'Number', label: 'Number', category: 'data', description: 'A constant number.', inputs: [], outputs: [num('value')], params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: 1 }], run: (_c, _i, p) => ({ outputs: { value: asNumber(p.value) } }) });
|
||||
registerNode({ type: 'Boolean', label: 'Boolean', category: 'data', description: 'A constant true/false.', inputs: [], outputs: [bool('value')], params: [{ key: 'value', label: 'Value', kind: 'boolean', default: false }], run: (_c, _i, p) => ({ outputs: { value: asBoolean(p.value) } }) });
|
||||
registerNode({ type: 'String', label: 'String', category: 'data', description: 'A constant text.', inputs: [], outputs: [{ key: 'value', label: 'value', type: 'string' }], params: [{ key: 'value', label: 'Value', kind: 'text', default: '' }], run: (_c, _i, p) => ({ outputs: { value: asString(p.value) } }) });
|
||||
|
||||
const MATH: [string, string, string, (a: number, b: number) => number][] = [
|
||||
['Add', 'Add', 'a + b', (a, b) => a + b],
|
||||
['Subtract', 'Subtract', 'a − b', (a, b) => a - b],
|
||||
['Multiply', 'Multiply', 'a × b', (a, b) => a * b],
|
||||
['Divide', 'Divide', 'a ÷ b (0 when b is 0)', (a, b) => (b === 0 ? 0 : a / b)],
|
||||
];
|
||||
for (const [type, label, description, fn] of MATH) {
|
||||
registerNode({ type, label, category: 'data', description, inputs: [num('a'), num('b')], outputs: [num('result')], params: [], run: (_c, i) => ({ outputs: { result: fn(asNumber(i.a), asNumber(i.b)) } }) });
|
||||
}
|
||||
|
||||
const COMPARE: Record<string, (a: number, b: number) => boolean> = {
|
||||
'==': (a, b) => a === b,
|
||||
'!=': (a, b) => a !== b,
|
||||
'<': (a, b) => a < b,
|
||||
'<=': (a, b) => a <= b,
|
||||
'>': (a, b) => a > b,
|
||||
'>=': (a, b) => a >= b,
|
||||
};
|
||||
registerNode({
|
||||
type: 'Compare',
|
||||
label: 'Compare',
|
||||
category: 'data',
|
||||
description: 'Compares two numbers.',
|
||||
inputs: [num('a'), num('b')],
|
||||
outputs: [bool('result')],
|
||||
params: [{ key: 'op', label: 'Operator', kind: 'enum', default: '<', options: Object.keys(COMPARE).map((op) => ({ value: op, label: op })) }],
|
||||
run: (_c, i, p) => ({ outputs: { result: (COMPARE[String(p.op)] ?? COMPARE['=='])!(asNumber(i.a), asNumber(i.b)) } }),
|
||||
});
|
||||
registerNode({ type: 'And', label: 'And', category: 'data', description: 'Both true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) && asBoolean(i.b) } }) });
|
||||
registerNode({ type: 'Or', label: 'Or', category: 'data', description: 'Either true.', inputs: [bool('a'), bool('b')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: asBoolean(i.a) || asBoolean(i.b) } }) });
|
||||
registerNode({ type: 'Not', label: 'Not', category: 'data', description: 'Inverts a boolean.', inputs: [bool('value')], outputs: [bool('result')], params: [], run: (_c, i) => ({ outputs: { result: !asBoolean(i.value) } }) });
|
||||
registerNode({ type: 'KeyIsDown', label: 'Key is down', category: 'data', description: 'Whether a key is currently held.', inputs: [], outputs: [bool('down')], params: [keyParam], run: (ctx, _i, p) => ({ outputs: { down: ctx.isKeyDown(String(p.key ?? '')) } }) });
|
||||
|
||||
// --- actions --------------------------------------------------------------------------------
|
||||
|
||||
registerNode({
|
||||
type: 'Move',
|
||||
label: 'Move',
|
||||
category: 'action',
|
||||
description: 'Moves an object by dx/dy.',
|
||||
inputs: [exec(), obj('object'), num('dx'), num('dy')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_c, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) {
|
||||
target.x += asNumber(i.dx);
|
||||
target.y += asNumber(i.dy);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'SetPosition',
|
||||
label: 'Set position',
|
||||
category: 'action',
|
||||
description: 'Places an object at x/y.',
|
||||
inputs: [exec(), obj('object'), num('x'), num('y')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_c, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) {
|
||||
target.x = asNumber(i.x);
|
||||
target.y = asNumber(i.y);
|
||||
}
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'Destroy',
|
||||
label: 'Destroy',
|
||||
category: 'action',
|
||||
description: 'Removes an object from the running game.',
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, i) => {
|
||||
const target = asEntity(i.object);
|
||||
if (target) ctx.destroy(target);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({
|
||||
type: 'Log',
|
||||
label: 'Log',
|
||||
category: 'action',
|
||||
description: 'Prints a message to the console.',
|
||||
inputs: [exec(), any('message')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, i) => {
|
||||
ctx.log(asString(i.message));
|
||||
return { next: 'exec' };
|
||||
},
|
||||
});
|
||||
registerNode({ type: 'StopGame', label: 'Stop game', category: 'action', description: 'Ends play mode.', inputs: [exec()], outputs: [], params: [], run: (ctx) => { ctx.stop(); return {}; } });
|
||||
+18
-1
@@ -1,3 +1,5 @@
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
@@ -24,8 +26,23 @@ export interface SceneAsset extends AssetBase {
|
||||
objects: SceneObject[];
|
||||
}
|
||||
|
||||
export type Asset = SpriteAsset | CameraAsset | SceneAsset;
|
||||
/**
|
||||
* A behavior is logic: a node graph plus the variables it declares. Variables are ordinary
|
||||
* properties – `variables` is the asset's own property schema, `props` holds their default
|
||||
* values, and placed instances override them like any other property.
|
||||
*/
|
||||
export interface BehaviorAsset extends AssetBase {
|
||||
type: 'behavior';
|
||||
variables: PropertyDef[];
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset;
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
return asset.type === 'scene';
|
||||
}
|
||||
|
||||
export function isBehaviorAsset(asset: Asset): asset is BehaviorAsset {
|
||||
return asset.type === 'behavior';
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/** All asset types known in phase 1. Scenes are assets too, but cannot be placed in scenes. */
|
||||
export const ASSET_TYPES = ['scene', 'camera', 'sprite'] as const;
|
||||
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
|
||||
export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior'] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
|
||||
@@ -13,3 +13,11 @@ export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
export function isAssetType(value: unknown): value is AssetType {
|
||||
return typeof value === 'string' && (ASSET_TYPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Types drawn as editor symbols (icon in the overlay layer) instead of as scene content.
|
||||
* They exist in the scene for their position, not for their looks.
|
||||
*/
|
||||
export function isSymbolType(type: AssetType): boolean {
|
||||
return type === 'camera' || type === 'behavior';
|
||||
}
|
||||
@@ -13,9 +13,21 @@ export interface SceneObject {
|
||||
/** World position plus draw order (`z`). */
|
||||
position: Position;
|
||||
overrides: PropertyBag;
|
||||
/**
|
||||
* Another object of the same scene this one acts on (used by behaviors: "the owner").
|
||||
* Empty string = none. Any object may carry one, only behaviors interpret it.
|
||||
*/
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
|
||||
export function createSceneObject(id: string, assetId: string, position: Vec2 | Position): SceneObject {
|
||||
return { id, assetId, name: '', position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 }, overrides: {} };
|
||||
return {
|
||||
id,
|
||||
assetId,
|
||||
name: '',
|
||||
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
|
||||
overrides: {},
|
||||
ownerId: '',
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
|
||||
@@ -9,12 +10,20 @@ export type PropertyDef =
|
||||
| { key: string; label: string; kind: 'number'; default: number; min?: number; max?: number; step?: number }
|
||||
| { key: string; label: string; kind: 'text'; default: string }
|
||||
| { key: string; label: string; kind: 'color'; default: number }
|
||||
| { key: string; label: string; kind: 'boolean'; default: boolean };
|
||||
| { key: string; label: string; kind: 'boolean'; default: boolean }
|
||||
/** One of a fixed set of string values, shown as a dropdown. */
|
||||
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
| { key: string; label: string; kind: 'key'; default: string };
|
||||
|
||||
export type PropertyKind = PropertyDef['kind'];
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
/** Kinds a behavior variable may have (what the node graph can compute with). */
|
||||
export const VARIABLE_KINDS = ['number', 'boolean', 'text'] as const;
|
||||
export type VariableKind = (typeof VARIABLE_KINDS)[number];
|
||||
|
||||
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
|
||||
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||||
@@ -28,6 +37,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xe0553f },
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset – see `schemaForAsset`.
|
||||
behavior: [],
|
||||
};
|
||||
|
||||
/** Properties every scene object has regardless of its asset type. */
|
||||
@@ -36,16 +47,38 @@ export const SCENE_OBJECT_SCHEMA: PropertySchema = [
|
||||
{ key: 'y', label: 'Y', kind: 'number', default: 0, step: 1 },
|
||||
];
|
||||
|
||||
/** Schema shared by every asset of a type. Behaviors add per-asset variables on top. */
|
||||
export function schemaFor(type: AssetType): PropertySchema {
|
||||
return ASSET_SCHEMAS[type];
|
||||
}
|
||||
|
||||
/** The effective schema of one asset: the type schema, plus declared variables for behaviors. */
|
||||
export function schemaForAsset(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? asset.variables : ASSET_SCHEMAS[asset.type];
|
||||
}
|
||||
|
||||
export function defaultsFor(type: AssetType): PropertyBag {
|
||||
return defaultsOf(ASSET_SCHEMAS[type]);
|
||||
}
|
||||
|
||||
export function defaultsOf(schema: PropertySchema): PropertyBag {
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of ASSET_SCHEMAS[type]) bag[def.key] = def.default;
|
||||
for (const def of schema) bag[def.key] = def.default;
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** Builds a variable definition of the given kind with the kind's natural default. */
|
||||
export function variableDef(key: string, kind: VariableKind, defaultValue?: PropertyValue): PropertyDef {
|
||||
switch (kind) {
|
||||
case 'number':
|
||||
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
|
||||
case 'boolean':
|
||||
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
|
||||
case 'text':
|
||||
return { key, label: key, kind, default: defaultValue === undefined ? '' : String(defaultValue) };
|
||||
}
|
||||
}
|
||||
|
||||
/** Clamp / coerce a raw value so it satisfies the definition. Unknown kinds are returned as-is. */
|
||||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||||
switch (def.kind) {
|
||||
@@ -63,6 +96,11 @@ export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyVal
|
||||
case 'boolean':
|
||||
return Boolean(value);
|
||||
case 'text':
|
||||
case 'key':
|
||||
return String(value);
|
||||
case 'enum': {
|
||||
const s = String(value);
|
||||
return def.options.some((o) => o.value === s) ? s : def.default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { schemaFor } from './PropertySchema';
|
||||
import { schemaForAsset } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* Effective property values of an asset: schema defaults filled with the asset's own values.
|
||||
@@ -9,7 +9,7 @@ import { schemaFor } from './PropertySchema';
|
||||
*/
|
||||
export function resolveAssetProps(asset: Asset): PropertyBag {
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of schemaFor(asset.type)) {
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
const own = asset.props[def.key];
|
||||
bag[def.key] = own !== undefined ? own : def.default;
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function resolveAssetProps(asset: Asset): PropertyBag {
|
||||
*/
|
||||
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
|
||||
const bag = resolveAssetProps(asset);
|
||||
for (const def of schemaFor(asset.type)) {
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
const override = object.overrides[def.key];
|
||||
if (override !== undefined) bag[def.key] = override;
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export function isOverridden(object: SceneObject, key: string): boolean {
|
||||
* counterpart of an override. The inspector highlights such values and offers a reset.
|
||||
*/
|
||||
export function isModified(asset: Asset, key: string): boolean {
|
||||
const def = schemaFor(asset.type).find((d) => d.key === key);
|
||||
const def = schemaForAsset(asset).find((d) => d.key === key);
|
||||
if (!def) return false;
|
||||
const own = asset.props[key];
|
||||
return own !== undefined && own !== def.default;
|
||||
|
||||
+82
-15
@@ -1,9 +1,11 @@
|
||||
// The graph package index registers the built-in nodes; unknown node types are dropped below.
|
||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode, nodeDef } from '../graph';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type AssetType, isAssetType } from '../model/AssetTypes';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue, schemaFor } from '../properties/PropertySchema';
|
||||
import { coerceValue, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
|
||||
|
||||
/**
|
||||
* Turns untrusted JSON (server file, import) into a well-formed Project. Unknown keys are
|
||||
@@ -14,22 +16,34 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const r = asRecord(raw);
|
||||
const rawAssets = (Array.isArray(r.assets) ? r.assets : []).map(asRecord);
|
||||
|
||||
// Pass 1: asset ids and types, needed to coerce scene-object overrides in pass 2.
|
||||
const types = new Map<string, AssetType>();
|
||||
// Pass 1: asset ids and their effective schemas (behaviors declare their own variables),
|
||||
// needed to coerce scene-object overrides in pass 2.
|
||||
const schemas = new Map<string, PropertySchema>();
|
||||
const variables = new Map<string, PropertyDef[]>();
|
||||
for (const a of rawAssets) {
|
||||
if (typeof a.id === 'string' && a.id && isAssetType(a.type)) types.set(a.id, a.type);
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
if (a.type === 'behavior') {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, vars);
|
||||
} else {
|
||||
schemas.set(a.id, schemaFor(a.type));
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: build assets.
|
||||
const assets: Asset[] = [];
|
||||
for (const a of rawAssets) {
|
||||
const id = typeof a.id === 'string' ? a.id : '';
|
||||
const type = types.get(id);
|
||||
if (!type) continue;
|
||||
const schema = schemas.get(id);
|
||||
if (!schema || !isAssetType(a.type)) continue;
|
||||
const type = a.type;
|
||||
const name = typeof a.name === 'string' ? a.name : type;
|
||||
const props = normalizeBag(a.props, type, false);
|
||||
const props = normalizeBag(a.props, schema, false);
|
||||
if (type === 'scene') {
|
||||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, types) });
|
||||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
|
||||
} else if (type === 'behavior') {
|
||||
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
|
||||
} else {
|
||||
assets.push({ id, type, name, props });
|
||||
}
|
||||
@@ -55,29 +69,82 @@ export function serializeProject(project: Project): unknown {
|
||||
return JSON.parse(JSON.stringify(project));
|
||||
}
|
||||
|
||||
function normalizeObjects(raw: unknown, types: Map<string, AssetType>): SceneObject[] {
|
||||
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
|
||||
const objects: SceneObject[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const o = asRecord(item);
|
||||
if (typeof o.id !== 'string' || !o.id || typeof o.assetId !== 'string') continue;
|
||||
const type = types.get(o.assetId);
|
||||
if (!type || type === 'scene') continue; // dangling reference or illegal scene-in-scene
|
||||
const schema = schemas.get(o.assetId);
|
||||
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
||||
const p = asRecord(o.position);
|
||||
objects.push({
|
||||
id: o.id,
|
||||
assetId: o.assetId,
|
||||
name: typeof o.name === 'string' ? o.name : '',
|
||||
position: { x: toNumber(p.x), y: toNumber(p.y), z: toNumber(p.z) },
|
||||
overrides: normalizeBag(o.overrides, type, true),
|
||||
overrides: normalizeBag(o.overrides, schema, true),
|
||||
ownerId: typeof o.ownerId === 'string' ? o.ownerId : '',
|
||||
});
|
||||
}
|
||||
// Owners must exist in the same scene and differ from the object itself.
|
||||
const ids = new Set(objects.map((o) => o.id));
|
||||
for (const o of objects) if (o.ownerId && (o.ownerId === o.id || !ids.has(o.ownerId))) o.ownerId = '';
|
||||
return objects;
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, type: AssetType, sparse: boolean): PropertyBag {
|
||||
function normalizeVariables(raw: unknown): PropertyDef[] {
|
||||
const defs: PropertyDef[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const v = asRecord(item);
|
||||
const key = typeof v.key === 'string' ? v.key : '';
|
||||
const kind = (VARIABLE_KINDS as readonly string[]).includes(String(v.kind)) ? (v.kind as VariableKind) : 'number';
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const def = variableDef(key, kind);
|
||||
def.default = coerceValue(def, (v.default ?? def.default) as PropertyValue) as never;
|
||||
defs.push(def);
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
function normalizeGraph(raw: unknown): Graph {
|
||||
const g = asRecord(raw);
|
||||
const graph = createEmptyGraph();
|
||||
const nodeIds = new Set<string>();
|
||||
for (const item of Array.isArray(g.nodes) ? g.nodes : []) {
|
||||
const n = asRecord(item);
|
||||
const id = typeof n.id === 'string' ? n.id : '';
|
||||
const type = typeof n.type === 'string' ? n.type : '';
|
||||
if (!id || nodeIds.has(id) || !nodeDef(type)) continue; // unknown node types are dropped
|
||||
nodeIds.add(id);
|
||||
const params: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(asRecord(n.params))) {
|
||||
if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') params[key] = value;
|
||||
}
|
||||
const node: GraphNode = { id, type, x: toNumber(n.x), y: toNumber(n.y), params };
|
||||
graph.nodes.push(node);
|
||||
}
|
||||
const edgeIds = new Set<string>();
|
||||
for (const item of Array.isArray(g.edges) ? g.edges : []) {
|
||||
const e = asRecord(item);
|
||||
const from = asRecord(e.from);
|
||||
const to = asRecord(e.to);
|
||||
const id = typeof e.id === 'string' ? e.id : '';
|
||||
if (!id || edgeIds.has(id)) continue;
|
||||
if (typeof from.node !== 'string' || typeof from.port !== 'string' || typeof to.node !== 'string' || typeof to.port !== 'string') continue;
|
||||
if (!nodeIds.has(from.node) || !nodeIds.has(to.node)) continue;
|
||||
edgeIds.add(id);
|
||||
const edge: GraphEdge = { id, from: { node: from.node, port: from.port }, to: { node: to.node, port: to.port } };
|
||||
graph.edges.push(edge);
|
||||
}
|
||||
return graph;
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||
const r = asRecord(raw);
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of schemaFor(type)) {
|
||||
for (const def of schema) {
|
||||
const value = r[def.key];
|
||||
if (value === undefined || value === null) {
|
||||
if (!sparse) bag[def.key] = def.default;
|
||||
|
||||
Reference in new issue
Block a user