game-maker / suite (push) Failing after 3m59s
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>
213 lines
9.0 KiB
TypeScript
213 lines
9.0 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|