Files
feuerwerk/src/editor/list/slot.ts
T
rootandClaude Fable 5.1 42cafa7543
game-maker / suite (push) Failing after 3m59s
Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
A shader is an asset carrying a node graph (Texture color, Output, Property,
Color, Add/Subtract/Multiply/Divide, Mix, Luminance) that compiles to the GLSL
fragment of a Pixi filter. The Shader tab reuses the Behavior canvas; a sprite
wears a shader through its Shader picker, and the rows below it are the
graph's Property nodes. Property schema, resolution and deviation take the
project because a sprite's rows now live in another asset. The built-in
registry of the prototype is gone; "Adjust colors" ships as a graph in the
fixture and the three example games.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-12 22:41:14 +02:00

213 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Resolves a `ListSlot` to its list and write-back, reusing each inspector section's spec so a List tab
* edit gets the same command, coercion and undo step. A `field` slot still writes the whole struct.
*/
import { edgesInto, type Graph, type GraphNode } from '../../core/graph/Graph';
import { resolveDef } from '../../core/graph/nested';
import { inlineInputDef, inlineInputs, type NodeDef } from '../../core/graph/NodeRegistry';
import type { Asset } from '../../core/model/Asset';
import { graphLookup } from '../../core/model/graphLookup';
import type { ObjectRef } from '../../core/model/instance';
import { isList, type ListValue, type PropertyValue, type StructValue } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { FIELD_KINDS, LIST_ITEM_KINDS, type ListItemKind } from '../../core/properties/kinds';
import { fieldLike, type ListItem, itemLike, structItemOf } from '../../core/properties/listItems';
import {
type PropertyDef,
schemaForAsset,
schemaForObject,
structOf,
} from '../../core/properties/PropertySchema';
import { resolveAssetProps } from '../../core/properties/resolve';
import type { FieldHandlers } from '../inspector/fieldFactory';
import { currentProps, currentRef } from '../inspector/objectQueries';
import { previewingHandlers, type PreviewedValue } from '../inspector/previewHandlers';
import { assetValue, currentNode, nodeParamValue, overrideValue } from '../inspector/valueSpecs';
import type { EditorState, ListSlot } from '../state';
export type ListDef = Extract<PropertyDef, { kind: 'list' }>;
/** What the tab needs of a slot: what is in it, and the two ways of writing it back. */
export interface ListAccess {
readonly def: ListDef;
readonly value: ListValue;
/**
* The kinds this list may hold. A list in a struct field takes primitives only – `struct → list →
* struct` is past the depth the stored value goes, so offering it would add an item that the next
* write silently drops (`flatListOf`).
*/
readonly kinds: readonly ListItemKind[];
/** What the tab header says the list belongs to. */
readonly title: string;
/** One item, previewed and committed exactly as the inspector does it. */
itemHandlers(index: number): FieldHandlers;
/** A whole new list in one command – adding, deleting, and the bulk text field. */
replace(next: ListValue): void;
}
/** The property a slot's `key` names, and how the section that owns it reads and writes it. */
interface Owner<Target> {
def: PropertyDef;
/** Its effective value, re-read on every call – a preview writes into the model. */
current: () => PropertyValue | undefined;
title: string;
spec: Omit<PreviewedValue<Target>, 'toModel'>;
}
export function resolveList(state: EditorState, slot: ListSlot): ListAccess | null {
const inSlot = <Target>(owner: Owner<Target> | null) => owner && listIn(state, owner, slot.field);
if (slot.kind === 'asset') return inSlot(assetOwner(state, slot));
if (slot.kind === 'object') return inSlot(objectOwner(state, slot));
return inSlot(nodeOwner(state, slot));
}
const asList = (value: PropertyValue | undefined): ListValue => (isList(value) ? value : []);
const withItem = (list: ListValue, index: number, item: ListItem): ListValue =>
list.map((value, i) => (i === index ? item : value));
/** The owner's own list, or – with a `field` – the list one of its struct fields holds. */
function listIn<Target>(state: EditorState, owner: Owner<Target>, field?: string): ListAccess | null {
if (field === undefined)
return owner.def.kind === 'list'
? access(state, owner.def, owner.title, () => asList(owner.current()), owner.spec)
: null;
if (owner.def.kind !== 'struct') return null;
const declared = owner.def.fields.find((f) => f.key === field);
if (declared?.kind !== 'list') return null;
const fields = owner.def.fields;
const read = (target: Target) => {
const bundle = owner.spec.read(target);
return bundle === undefined ? undefined : asList(structOf(fields, bundle)[field]);
};
// Written back as a whole bundle, rebuilt from what is in the owner at that moment: writing the
// list alone would drop its neighbours, and a snapshot would undo an edit made in between.
const bundleWith = (target: Target | undefined, list: PropertyValue): StructValue => ({
...structOf(fields, target === undefined ? undefined : owner.spec.read(target)),
[field]: asList(list),
});
return access<Target>(
state,
{ key: field, label: field, kind: 'list', default: [] },
`${owner.title} · ${field}`,
() => asList(structOf(fields, owner.current())[field]),
{
target: owner.spec.target,
read,
write: (target, list) => owner.spec.write(target, bundleWith(target, list)),
remove: owner.spec.remove,
commit: (list) => owner.spec.commit(bundleWith(owner.spec.target(), list)),
},
FIELD_KINDS,
);
}
/**
* `current` re-resolves rather than closing over a snapshot: a preview writes into the model, so
* the next keystroke has to build on what is in there now, and the commit on what the restore
* put back. Everything else is the spec the matching inspector section already uses.
*/
function access<Target>(
state: EditorState,
def: ListDef,
title: string,
current: () => ListValue,
spec: Omit<PreviewedValue<Target>, 'toModel'>,
kinds: readonly ListItemKind[] = LIST_ITEM_KINDS,
): ListAccess {
return {
def,
kinds,
title,
value: current(),
itemHandlers: (index) => ({
...previewingHandlers<Target>(
{ state },
{
...spec,
// The row keeps the kind it has: a color item written from its field stays a color.
toModel: (input) => withItem(current(), index, itemLike(current()[index], input)),
},
),
// A struct item is edited field by field, and each write rebuilds the whole item from what
// is in the list at that moment -- the same rule as a row, one level deeper.
structFieldHandlers: (field: { key: string }) =>
previewingHandlers<Target>(
{ state },
{
...spec,
toModel: (input) => {
const item = structItemOf(current()[index]);
return withItem(current(), index, {
...item,
[field.key]: fieldLike(item[field.key], input),
});
},
},
),
}),
// No coercion here: every command behind `commit` runs the value through `coerceValue`.
// A preview does not, which is why `toModel` above has to.
replace: (next) => state.execute(spec.commit(next)),
};
}
function assetOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'asset' }>): Owner<Asset> | null {
const asset = findAsset(state.project, slot.assetId);
const def = asset && schemaForAsset(state.project, asset).find((d) => d.key === slot.key);
if (!asset || !def) return null;
return {
def,
title: `${asset.name} · ${def.label}`,
current: () => {
const now = findAsset(state.project, slot.assetId);
return now && resolveAssetProps(state.project, now)[slot.key];
},
spec: assetValue(state, slot.assetId, slot.key),
};
}
function objectOwner(
state: EditorState,
slot: Extract<ListSlot, { kind: 'object' }>,
): Owner<ObjectRef> | null {
const ref = currentRef(state, slot.holderId, slot.objectId);
const owner = ref && findAsset(state.project, ref.object.assetId);
const def = owner && schemaForObject(state.project, owner, ref.object).find((d) => d.key === slot.key);
if (!ref || !def) return null;
return {
def,
title: `${ref.object.name} · ${def.label}`,
current: () => currentProps(state, slot.holderId, slot.objectId)?.[slot.key],
spec: overrideValue(state, slot.holderId, slot.objectId, slot.key),
};
}
function nodeOwner(state: EditorState, slot: Extract<ListSlot, { kind: 'node' }>): Owner<GraphNode> | null {
const found = currentNode(state.project, slot.assetId, slot.nodeId);
if (!found) return null;
const nodeDef = resolveDef(found.node, found.graph, graphLookup(state.project));
const def =
nodeDef && (nodeDef.params.find((p) => p.key === slot.key) ?? inlineListDef(nodeDef, found, slot.key));
if (!nodeDef || !def) return null;
return {
def,
title: `${nodeDef.label} · ${def.label}`,
current: () =>
currentNode(state.project, slot.assetId, slot.nodeId)?.node.params[slot.key] ?? def.default,
spec: nodeParamValue(state, slot.assetId, slot.nodeId, slot.key),
};
}
/**
* A `list` input, which carries its inline value in the very same params bag a setting does. Once
* an edge feeds the port that value is dead, so the slot stops resolving rather than editing
* something the node no longer reads.
*/
function inlineListDef(def: NodeDef, found: { node: GraphNode; graph: Graph }, key: string): ListDef | null {
const port = inlineInputs(def).find((p) => p.key === key);
if (!port || port.type !== 'list' || edgesInto(found.graph, found.node.id, key).length > 0) return null;
const inline = inlineInputDef(port);
return inline.kind === 'list' ? inline : null;
}