Use a shader graph as a node in other shaders (#303)
game-maker / gate (push) Successful in 3m16s
game-maker / suite (push) Failing after 14m37s

A calculation needed in several shaders (a greyscale, a noise, an
outline) had to be built again in every one of them. A shader
function keeps it as an asset of its own, called like a behavior is
since #16.

- New asset type `shaderfunction` ("Shader function"), edited in the
  Shader tab. The panel beside the graph shows no picture, the
  readout counts its inputs and outputs.
- Two new nodes: `Function input` and `Function output`. Their
  declared ports, numbers or colours only, are the ports of every
  call. One of each per function, like Start and End.
- The call is node `ShaderCall`, named after the function. The
  palette lists every function, it can be dragged in from the Assets
  panel, and a double-click opens it.
- The compiler sets the function in where it is called
  (`inlineFunctions`). It renames the copied nodes `<call>/<node>`
  and turns an unconnected port into a constant. Passes, Time, the
  Edge cut and the corner walk then see a plain graph.
- A problem inside a function is reported at the call, in the
  function's words. The function's own tab checks it on its own
  (`checkShaderFunction`).
- `ShaderLibrary` keys a shader on its graph and on every function it
  calls, however deep, so a change reaches every caller.
- The port commands mirror a new, renamed or removed port into every
  call, as for a behavior's Start and End. `NodeDef.portKinds` holds
  the two ends to Number and Color.
- As agreed in the issue: no `Property`, `Color property`, Output or
  Vertex output in a function, and `Time` is the caller's clock.
- Own decision: a `Pass` may stand in a function. It becomes a pass
  of the calling shader, so a wide blur can be a function.
- A function calling itself, also through another, says so at the
  node and is not set in. The palette does not offer such a call.
- The Shader tab showed only the compiler's problems. A call nothing
  reaches was never compiled and looked healthy, so a call now also
  shows its own problem there, as in a behavior.

Tests:
- Gate: 228 files, 3510 tests, CHECK EXIT 0 (+13: 11 in
  tests/core/shader-functions.test.ts, 2 for the new icon)
- e2e 28: 100 checks, all passed (+3 in 28l-viii). Called and in
  place both #606060/#a2a2a2, halved in the function #303030/#515151.
- Counter-check: a fingerprint without the called functions fails 1
  unit test, and in the browser the change check (#606060 stays).
- The first e2e runs found two faults, both fixed: a double-click
  only selected the function, and an unreached call of itself
  showed no problem.
- e2e total 1728 is computed (1725 + 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-03 17:09:53 +02:00
1 parent 2e55071fa5
commit a526e1b40f
28 files changed
+1091 -83

No files matched your search

+1
View File
@@ -7,6 +7,7 @@ export const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
behavior: 'graph',
statemachine: 'statemachine',
shader: 'shader',
shaderfunction: 'shader',
particles: 'particles',
texture: 'texture',
vectorart: 'vectorart',
+4 -1
View File
@@ -7,9 +7,11 @@ import {
BEHAVIOR_PARAM,
findAnnotation,
findNode,
FUNCTION_PARAM,
type GraphNode,
nodeDef,
namesBehavior,
SHADER_CALL,
} from '../core/graph';
import type { GraphAsset } from '../core/model/Asset';
import { type Clip, copyGraph, copyObjects, type GraphClip, type ObjectClip } from '../core/model/clipboard';
@@ -160,7 +162,8 @@ function fitsGraph(project: Project, asset: GraphAsset, node: GraphNode): boolea
return paletteEntries(project, asset).some(
(entry) =>
entry.def.type === node.type &&
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]),
(!namesBehavior(node.type) || entry.params[BEHAVIOR_PARAM] === node.params[BEHAVIOR_PARAM]) &&
(node.type !== SHADER_CALL || entry.params[FUNCTION_PARAM] === node.params[FUNCTION_PARAM]),
);
}
+36 -11
View File
@@ -11,6 +11,7 @@ import {
drawnDef,
edgesInto,
edgesOutOf,
FUNCTION_PARAM,
ghostProblem,
type Graph,
type GraphNode,
@@ -19,6 +20,8 @@ import {
resolveDef,
namesBehavior,
RUN_BEHAVIOR,
SHADER_CALL,
callsFunction,
startStateOf,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
@@ -278,13 +281,16 @@ export class GraphCanvas extends Widget {
this.annotationLayer.sync(asset.graph);
const seen = new Set<string>();
const lookup = graphLookup(this.state.project);
// In a compiled graph a node's problem is the compiler's, not a call's.
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset) : null;
// In a compiled graph a node's problem is the compiler's, not a call's – but a shader function's
// call says what is wrong with it even where nothing reaches it, as a behavior's does.
const shaderBuild = asset.type === 'shader' ? shaderLibrary.buildFor(asset, this.state.project) : null;
const compilerProblems = shaderBuild
? { ...shaderBuild.hints, ...shaderBuild.problems }
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
: asset.type === 'shaderfunction'
? shaderLibrary.checkFor(asset, this.state.project).problems
: asset.type === 'particles'
? particleLibrary.buildFor(asset).problems
: null;
for (const node of asset.graph.nodes) {
const resolved = resolveDef(node, asset.graph, lookup);
if (!resolved) continue;
@@ -299,7 +305,8 @@ export class GraphCanvas extends Widget {
this.nodeLayer.addChild(view);
}
const problem =
(compilerProblems ? (compilerProblems[node.id] ?? null) : callProblem(node, lookup, asset.id)) ??
compilerProblems?.[node.id] ??
(!compilerProblems || node.type === SHADER_CALL ? callProblem(node, lookup, asset.id) : null) ??
ghostProblem(node, asset.graph, resolved) ??
blockProblem(asset.graph, node);
view.sync(node, asset.graph, this.state.project, def, problem);
@@ -628,6 +635,12 @@ export class GraphCanvas extends Widget {
}
private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SHADER_CALL) {
const id = textOf(node.params[FUNCTION_PARAM] ?? '');
if (findAsset(this.state.project, id)?.type !== 'shaderfunction') return false;
this.state.openAsset(id);
return true;
}
if (!namesBehavior(node.type)) return false;
const id = textOf(node.params[BEHAVIOR_PARAM] ?? '');
const asset = id ? findAsset(this.state.project, id) : undefined;
@@ -683,23 +696,33 @@ export class GraphCanvas extends Widget {
}
/**
* Follows a behavior dragged in from the asset panel. The node is centred under the pointer, so
* Follows a behavior or shader function dragged in from the asset panel. The node is centred under the pointer, so
* it lands where the ghost was rather than below and right of it.
*/
private trackIncoming(dragged: DragPayload, e: FederatedPointerEvent): void {
const local = this.toLocal(e.global);
const inside = local.x >= 0 && local.x <= this.w && local.y >= 0 && local.y <= this.h;
const asset = dragged.kind === 'asset' ? findAsset(this.state.project, dragged.id) : undefined;
// Only a behavior graph runs other behaviors; a state machine has nothing to drop in.
if (!inside || this.asset()?.type !== 'behavior' || asset?.type !== 'behavior') {
// A behavior graph runs other behaviors and a shader calls functions (#303); a state machine
// has nothing to drop in.
const into = this.asset()?.type;
const calls = asset?.type === 'shaderfunction' && (into === 'shader' || into === 'shaderfunction');
if (!inside || !asset || !(calls || (into === 'behavior' && asset.type === 'behavior'))) {
this.setIncoming(null);
return;
}
const g = this.screenToGraph(local.x, local.y);
const rejection = calls
? callsFunction(graphLookup(this.state.project), asset.id, this.assetId)
? 'A function cannot call itself'
: ''
: asset.id === this.assetId
? 'A behavior cannot run itself'
: '';
this.setIncoming({
assetId: asset.id,
at: { x: Math.round(g.x - NODE_WIDTH / 2), y: Math.round(g.y - TITLE_HEIGHT / 2) },
rejection: asset.id === this.assetId ? 'A behavior cannot run itself' : '',
rejection,
});
}
@@ -714,7 +737,9 @@ export class GraphCanvas extends Widget {
this.state.setNotice(drop.rejection);
return;
}
const entry = this.entries().find((it) => it.id === `${RUN_BEHAVIOR}:${drop.assetId}`);
const type =
findAsset(this.state.project, drop.assetId)?.type === 'shaderfunction' ? SHADER_CALL : RUN_BEHAVIOR;
const entry = this.entries().find((it) => it.id === `${type}:${drop.assetId}`);
if (entry) this.addNode(entry, drop.at);
}
+38 -15
View File
@@ -3,7 +3,9 @@ import {
allNodeDefs,
BEHAVIOR_END,
BEHAVIOR_PARAM,
callsFunction,
createEmptyGraph,
FUNCTION_PARAM,
isTerminalType,
namesBehavior,
nextStateName,
@@ -12,11 +14,14 @@ import {
ON_BEHAVIOR,
resolveDef,
RUN_BEHAVIOR,
SHADER_CALL,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
State,
STATE_NAME_PARAM,
terminalNode,
} from '../../core/graph';
import type { GraphAsset } from '../../core/model/Asset';
import type { GraphAsset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import { FIND, FIND_MODE } from '../../core/graph/findBlock';
import { graphLookup } from '../../core/model/graphLookup';
import type { PropertyBag } from '../../core/model/Properties';
@@ -34,6 +39,7 @@ import {
SHADER_PROPERTY,
} from '../../core/properties/shaders';
import { nextParticlePropertyName, PARTICLE_NODES, PARTICLE_OUTPUT } from '../../core/particles/nodes';
import { offeredIn, ShaderFunctionInput, ShaderFunctionOutput } from '../../core/shader/functions';
import { SHADER_NODES, SHADER_OUTPUT, SHADER_VERTEX_OUTPUT } from '../../core/shader/nodes';
/**
@@ -88,21 +94,17 @@ function filterEntry(find: NodeDef): PaletteEntry {
}
/**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes, its one Output dropping out once
* placed. In a behavior, every other behavior appears under its own name instead of behind a
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
* A shader's nodes, its one Output dropping out once placed, and every shader function under its own
* name (#303). A function holds the same nodes but those that end a shader or give the wearer a row,
* and its own two ends instead; it calls every function but those that would come back to it.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader') {
const placed = new Set(asset.graph.nodes.map((n) => n.type));
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT];
return SHADER_NODES.filter((def) => !once.includes(def.type) || !placed.has(def.type)).map((def) => {
function shaderEntries(project: Project, asset: ShaderAsset | ShaderFunctionAsset): PaletteEntry[] {
const inFunction = asset.type === 'shaderfunction';
const placed = new Set(asset.graph.nodes.map((n) => n.type));
const once = [SHADER_OUTPUT, SHADER_VERTEX_OUTPUT, SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT];
const entries: PaletteEntry[] = [...SHADER_NODES, ShaderFunctionInput, ShaderFunctionOutput]
.filter((def) => offeredIn(def.type, inFunction) && (!once.includes(def.type) || !placed.has(def.type)))
.map((def) => {
const params: PropertyBag =
def.type === SHADER_PROPERTY
? { [PROPERTY_NAME_PARAM]: nextPropertyName(asset.graph) }
@@ -111,7 +113,28 @@ export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntr
: {};
return { id: def.type, hint: def.category, def, params };
});
const lookup = graphLookup(project);
for (const other of project.assets) {
if (other.type !== 'shaderfunction' || callsFunction(lookup, other.id, asset.id)) continue;
const params = { [FUNCTION_PARAM]: other.id };
const def = resolveDef({ id: '', type: SHADER_CALL, x: 0, y: 0, params }, createEmptyGraph(), lookup);
if (def) entries.push({ id: `${SHADER_CALL}:${other.id}`, hint: 'function', def, params });
}
return entries;
}
/**
* The node types offerable in `asset`'s graph. A state machine holds states and nothing else, each
* new one named by its number; a shader holds shader nodes (`shaderEntries`). In a behavior, every other behavior appears under its own name instead of behind a
* generic "Run behavior" – and once more as the event "On <name>" if it has an End to be heard
* through (#294) –, and the two terminals drop out once the graph has them.
*/
export function paletteEntries(project: Project, asset: GraphAsset): PaletteEntry[] {
if (asset.type === 'statemachine') {
const params = { [STATE_NAME_PARAM]: nextStateName(asset.graph) };
return [{ id: State.type, hint: State.category, def: State, params }];
}
if (asset.type === 'shader' || asset.type === 'shaderfunction') return shaderEntries(project, asset);
if (asset.type === 'particles') {
const hasOutput = asset.graph.nodes.some((n) => n.type === PARTICLE_OUTPUT);
const taken = new Set(asset.graph.nodes.filter(isParticlePropertyNode).map(propertyNameOf));
+16 -6
View File
@@ -8,16 +8,19 @@ import {
SetPortWriteCommand,
} from '../../core/commands/portCommands';
import {
BEHAVIOR_START,
type DeclaredPort,
type GraphNode,
isEntryTerminal,
isPortOf,
isTerminalNode,
missingFieldPorts,
nodeDef,
OBJECT_BEHAVIOR_PARAM,
OBJECT_BEHAVIOR_SETTING,
OBJECT_TYPE_SETTING,
portKindOf,
SHADER_FUNCTION_INPUT,
SHADER_FUNCTION_OUTPUT,
VARIABLES_SETTING,
variablesSourceOf,
propertyTargetOf,
@@ -211,8 +214,9 @@ export function buildPortSection(
variables: readonly PropertyDef[] = [],
typeName: string | null = null,
): void {
const onStart = node.type === BEHAVIOR_START;
const onStart = isEntryTerminal(node.type);
const noun = isTerminalNode(node) ? (onStart ? 'Input' : 'Output') : 'Field';
const ofFunction = node.type === SHADER_FUNCTION_INPUT || node.type === SHADER_FUNCTION_OUTPUT;
const base = noun === 'Field' ? 'field' : onStart ? 'in' : 'out';
const header = sectionHeader(host, `${noun.toUpperCase()}S`);
@@ -233,7 +237,9 @@ export function buildPortSection(
const add = new Dropdown(`+ ${noun}`, { variant: 'ghost' });
add.onClick = () => {
// A further way in or out is a start or end node's to declare; a struct has fields only.
const kinds = DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field');
const kinds =
nodeDef(node.type)?.portKinds ??
DECLARED_PORT_KINDS.filter((kind) => kind !== 'flow' || noun !== 'Field');
const items = kindMenu(kinds, (kind) =>
host.state.execute(new AddPortCommand(assetId, node.id, { key: nextPortName(node, base), kind })),
);
@@ -248,14 +254,18 @@ export function buildPortSection(
host,
noun === 'Field'
? 'No fields yet. Every field added here becomes a port of the struct.'
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
: ofFunction
? `No ${plural(noun)} yet. Every ${noun.toLowerCase()} added here, a number or a color, becomes a port on the nodes calling this function.`
: onStart
? 'No inputs yet. Every input added here becomes a port on the node that runs this behavior; one of the kind Flow is a further way in.'
: 'No outputs yet. Every output added here becomes a result of running this behavior; one of the kind Flow is a further way out.',
);
for (const port of ports) portRow(host, assetId, node, port, portNameField(host, assetId, node, port));
}
const plural = (noun: string) => `${noun.toLowerCase()}s`;
function portNameField(host: InspectorHost, assetId: string, node: GraphNode, port: DeclaredPort): TextField {
return new TextField(port.key, {
width: NAME_WIDTH,
+1
View File
@@ -31,6 +31,7 @@ export const ASSET_GROUP_LABELS: Record<AssetType, string> = {
tilemap: 'Tilemaps',
statemachine: 'State machines',
shader: 'Shaders',
shaderfunction: 'Shader functions',
font: 'Fonts',
sound: 'Sounds',
particles: 'Particles',
+2
View File
@@ -22,6 +22,7 @@ import plugins from './icons/plugins.svg?raw';
import rigidbody from './icons/rigidbody.svg?raw';
import scene from './icons/scene.svg?raw';
import shader from './icons/shader.svg?raw';
import shaderfunction from './icons/shaderfunction.svg?raw';
import sound from './icons/sound.svg?raw';
import sprite from './icons/sprite.svg?raw';
import statemachine from './icons/statemachine.svg?raw';
@@ -50,6 +51,7 @@ export const ASSET_ICON_SVG: Record<AssetType, string> = {
rigidbody,
scene,
shader,
shaderfunction,
sound,
sprite,
statemachine,
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 16 16" width="16" height="16">
<!-- The shader's half-lit disc between the brackets of a call: a part used in other shaders. -->
<circle cx="0" cy="0" r="3.6" fill="none" stroke="currentColor" stroke-width="1.25" />
<path d="M0 -3.6 A3.6 3.6 0 0 1 0 3.6 Z" fill="currentColor" />
<path d="M-4.6 -6.8 Q-7.6 0 -4.6 6.8 M4.6 -6.8 Q7.6 0 4.6 6.8" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 493 B

+39 -12
View File
@@ -1,4 +1,5 @@
import type { Asset, ShaderAsset } from '../../core/model/Asset';
import type { Asset, ShaderAsset, ShaderFunctionAsset } from '../../core/model/Asset';
import { SHADER_FUNCTION_INPUT, SHADER_FUNCTION_OUTPUT, terminalNode } from '../../core/graph';
import type { FlatDef } from '../../core/properties/PropertySchema';
import { type ShaderParamDef, shaderParamDefs } from '../../core/properties/shaders';
import {
@@ -37,12 +38,18 @@ const HOLD_WIDTH = 52;
const PARAM_NAME_WIDTH = 72;
const PARAM_VALUE_WIDTH = 44;
/** What the tab edits: a shader, or a function used in shaders (#303), which has no picture of its own. */
type ShaderTabAsset = ShaderAsset | ShaderFunctionAsset;
/** The panel's line while a function is open: it is seen in the shaders that call it. */
export const FUNCTION_SUBJECT = 'A function shows in the shaders calling it';
/**
* The nodes of one shader asset, on the graph canvas the Behavior tab uses, and beside them a picture
* wearing it (#168). The picture is whatever was last selected that has one – a sprite, a texture, a
* frame – so the shader is judged on the image it is meant for; before that, a built-in test image.
*/
export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
export class ShaderEditor extends AssetTabEditor<ShaderTabAsset, GraphCanvas> {
readonly snap = new SnapToggle(() => this.canvas);
readonly preview: ShaderPreview;
/** Assigned by `body`; public for the check scripts, which drag it. */
@@ -108,8 +115,10 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
/** Whether the panel is a moving picture: a Time node reaches the output and the clock runs. */
get animates(): boolean {
const asset = this.asset();
return Boolean(asset && !this.preview.holding && shaderLibrary.buildFor(asset).compiled?.time);
const asset = this.shader();
return Boolean(
asset && !this.preview.holding && shaderLibrary.buildFor(asset, this.state.project).compiled?.time,
);
}
/** Called every frame while the tab is showing. */
@@ -150,21 +159,37 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
return this.state.selectedShaderId ?? '';
}
override isMine(asset: Asset): asset is ShaderAsset {
return asset.type === 'shader';
override isMine(asset: Asset): asset is ShaderTabAsset {
return asset.type === 'shader' || asset.type === 'shaderfunction';
}
protected override infoText(asset: ShaderAsset): string {
const build = shaderLibrary.buildFor(asset);
/** The open asset while it is a shader rather than a function. */
private shader(): ShaderAsset | null {
const asset = this.asset();
return asset?.type === 'shader' ? asset : null;
}
protected override infoText(asset: ShaderTabAsset): string {
if (asset.type === 'shaderfunction') {
const check = shaderLibrary.checkFor(asset, this.state.project);
if (check.reason) return check.reason;
const count = (type: string, noun: string) => {
const n = terminalNode(asset.graph, type)?.ports?.length ?? 0;
return `${n} ${noun}${n === 1 ? '' : 's'}`;
};
return `${count(SHADER_FUNCTION_INPUT, 'input')}, ${count(SHADER_FUNCTION_OUTPUT, 'output')}`;
}
const build = shaderLibrary.buildFor(asset, this.state.project);
if (!build.compiled) return build.reason;
const count = shaderParamDefs(asset.graph).length;
return `${count} ${count === 1 ? 'property' : 'properties'}`;
}
protected override syncExtras(asset: ShaderAsset | null): void {
this.snap.setEnabled(Boolean(asset));
protected override syncExtras(open: ShaderTabAsset | null): void {
this.snap.setEnabled(Boolean(open));
const asset = open?.type === 'shader' ? open : null;
const picture = this.subject && previewPicture(this.state.project, this.subject);
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : '');
this.subjectLabel.setText(asset ? (picture?.name ?? 'Test image') : open ? FUNCTION_SUBJECT : '');
const defs = asset ? shaderParamDefs(asset.graph) : [];
// A new picture or another shader starts over from what that sprite wears; an edit of the graph
// keeps what the sliders were moved to.
@@ -180,7 +205,9 @@ export class ShaderEditor extends AssetTabEditor<ShaderAsset, GraphCanvas> {
}
this.syncParams(defs);
this.preview.show(asset, picture, this.values);
this.holdButton.setEnabled(Boolean(asset && shaderLibrary.buildFor(asset).compiled?.time));
this.holdButton.setEnabled(
Boolean(asset && shaderLibrary.buildFor(asset, this.state.project).compiled?.time),
);
this.restartButton.setEnabled(this.holdButton.enabled);
this.syncControls();
}
+4 -4
View File
@@ -4,7 +4,7 @@ import type { ObjectPick, PickEnd } from './objectPick';
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, SceneAsset } from '../core/model/Asset';
import type { Asset, GraphAsset, SceneAsset } from '../core/model/Asset';
import { findAnnotation, type Graph } from '../core/graph';
import { findObject } from '../core/model/hierarchy';
import { allShapes } from '../core/model/vectorTree';
@@ -800,9 +800,9 @@ export class EditorState {
return this.selectedGraphId('statemachine');
}
/** The shader the current selection refers to: the asset, or one of its nodes. */
/** The shader or shader function the current selection refers to: the asset, or one of its nodes. */
get selectedShaderId(): string | null {
return this.selectedGraphId('shader');
return this.selectedGraphId('shader') ?? this.selectedGraphId('shaderfunction');
}
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
@@ -810,7 +810,7 @@ export class EditorState {
return this.selectedGraphId('particles');
}
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): string | null {
private selectedGraphId(type: GraphAsset['type']): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;