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:
rootandClaude Fable 5.1 committed 2026-09-07 12:44:43 +02:00
1 parent d4b1ffa051
commit 922774778b
36 files changed
+2969 -147

No files matched your search

+132
View File
@@ -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);
}