Shader assets: graph-built shaders, the Shader tab and sprite rows from the graph (#62)
game-maker / suite (push) Failing after 3m59s
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>
This commit is contained in:
1 parent
6141d07d4f
commit
42cafa7543
62 files changed
+1682
-144
No files matched your search
@@ -42,8 +42,8 @@ export class ApplyOverrideToAssetCommand extends ObjectEditCommand {
|
||||
|
||||
protected override apply(ref: ObjectRef, project: Project): void {
|
||||
const asset = requireAsset(project, ref.object.assetId);
|
||||
requirePropDef(asset, this.key);
|
||||
const value = resolveSceneObjectProps(asset, ref.object)[this.key];
|
||||
requirePropDef(project, asset, this.key, ref.object);
|
||||
const value = resolveSceneObjectProps(project, asset, ref.object)[this.key];
|
||||
this.asset = asset;
|
||||
this.previousAssetValue = asset.props[this.key];
|
||||
asset.props[this.key] = value!;
|
||||
|
||||
@@ -36,6 +36,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'animation', frames: [] };
|
||||
case 'statemachine':
|
||||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||||
case 'shader':
|
||||
return { ...base, type: 'shader', graph: createEmptyGraph() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +59,7 @@ export class CreateAssetCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
for (const [key, value] of Object.entries(this.props))
|
||||
this.asset.props[key] = coerceValue(requirePropDef(this.asset, key), value);
|
||||
this.asset.props[key] = coerceValue(requirePropDef(project, this.asset, key), value);
|
||||
project.assets.push(this.asset);
|
||||
this.previousActiveScene = project.activeSceneId;
|
||||
if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id;
|
||||
@@ -195,8 +197,8 @@ export class SetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
super(assetId, key, `Set ${key}`);
|
||||
}
|
||||
|
||||
protected override computeNext(asset: Asset): PropertyValue {
|
||||
return coerceValue(requirePropDef(asset, this.key), this.value);
|
||||
protected override computeNext(asset: Asset, project: Project): PropertyValue {
|
||||
return coerceValue(requirePropDef(project, asset, this.key), this.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,8 +207,8 @@ export class ResetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
super(assetId, key, `Reset ${key}`);
|
||||
}
|
||||
|
||||
protected override computeNext(asset: Asset): PropertyValue {
|
||||
return requirePropDef(asset, this.key).default;
|
||||
protected override computeNext(asset: Asset, project: Project): PropertyValue {
|
||||
return requirePropDef(project, asset, this.key).default;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ export class AddColliderCommand implements Command {
|
||||
this.assetId,
|
||||
);
|
||||
if (targetAsset.type === 'sprite') {
|
||||
const props = resolveSceneObjectProps(targetAsset, target);
|
||||
const props = resolveSceneObjectProps(project, targetAsset, target);
|
||||
asset.props.width = Number(props.width);
|
||||
asset.props.height = Number(props.height);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Project,
|
||||
} from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, type PropertyDef } from '../properties/PropertySchema';
|
||||
import { type PropertyDef, schemaForAsset, schemaForObject } from '../properties/PropertySchema';
|
||||
import { CommandError } from './Command';
|
||||
|
||||
export function requireAsset(project: Project, assetId: string): Asset {
|
||||
@@ -91,8 +91,15 @@ export function requireNodeDef(type: string): NodeDef {
|
||||
return def;
|
||||
}
|
||||
|
||||
export function requirePropDef(asset: Asset, key: string): PropertyDef {
|
||||
const def = propDef(asset, key);
|
||||
/** The row `key` names on the asset – or, given the placed object, on that object (its shader's rows). */
|
||||
export function requirePropDef(
|
||||
project: Project,
|
||||
asset: Asset,
|
||||
key: string,
|
||||
object?: SceneObject,
|
||||
): PropertyDef {
|
||||
const schema = object ? schemaForObject(project, asset, object) : schemaForAsset(project, asset);
|
||||
const def = schema.find((d) => d.key === key);
|
||||
if (!def) throw new CommandError(`Property "${key}" is not defined for ${asset.type}`);
|
||||
return def;
|
||||
}
|
||||
@@ -189,7 +189,10 @@ export class SetOverrideCommand extends OverrideCommand {
|
||||
}
|
||||
|
||||
protected override nextValue(object: SceneObject, project: Project): PropertyValue {
|
||||
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
|
||||
return coerceValue(
|
||||
requirePropDef(project, requireAsset(project, object.assetId), this.key, object),
|
||||
this.value,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,12 @@ import { type DeclaredPortKind, KIND } from '../properties/kinds';
|
||||
|
||||
export type { DeclaredPortKind } from '../properties/kinds';
|
||||
|
||||
/** `exec` carries control flow, the others values; `any` accepts every value type. */
|
||||
export type PortType = 'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'any';
|
||||
/**
|
||||
* `exec` carries control flow, the others values; `any` accepts every value type. `vec3` is a
|
||||
* colour inside a shader graph, where the values are GLSL and no behavior node ever sees one.
|
||||
*/
|
||||
export type PortType =
|
||||
'exec' | 'number' | 'boolean' | 'string' | 'object' | 'list' | 'struct' | 'vec3' | 'any';
|
||||
|
||||
export interface PortRef {
|
||||
node: string;
|
||||
|
||||
@@ -16,8 +16,11 @@ export interface PortDef {
|
||||
field?: PropertyDef;
|
||||
}
|
||||
|
||||
/** `state` is the one category no behavior graph offers: its nodes are the states of a state machine. */
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state';
|
||||
/**
|
||||
* `state` and `shader` are the categories no behavior graph offers: their nodes are the states of
|
||||
* a state machine and the GLSL of a shader asset.
|
||||
*/
|
||||
export type NodeCategory = 'event' | 'flow' | 'data' | 'variable' | 'action' | 'state' | 'shader';
|
||||
|
||||
/**
|
||||
* How a loop node asks for one of its exec outputs to be run repeatedly: `port` carries the body,
|
||||
@@ -134,13 +137,15 @@ export function hasExecInput(def: NodeDef): boolean {
|
||||
|
||||
/**
|
||||
* Data inputs that may carry an inline value while unconnected. An object is a live handle and a
|
||||
* struct is assembled from its parts, so neither has a value that could be typed on one line.
|
||||
* struct is assembled from its parts, so neither has a value that could be typed on one line; a
|
||||
* shader colour has one only where the node gives it a colour field.
|
||||
*/
|
||||
export function inlineInputs(def: NodeDef): PortDef[] {
|
||||
return def.inputs.filter(isInlinePort);
|
||||
}
|
||||
|
||||
function isInlinePort(port: PortDef): boolean {
|
||||
if (port.type === 'vec3') return port.field !== undefined;
|
||||
return port.type !== 'exec' && port.type !== 'object' && port.type !== 'struct';
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ import { RunBehavior } from './flow/RunBehavior';
|
||||
import { Sequence } from './flow/Sequence';
|
||||
import { SetState } from './flow/SetState';
|
||||
import { StateBranch } from './flow/StateBranch';
|
||||
import { SHADER_NODES } from '../../shader/nodes';
|
||||
import { State } from '../stateMachine';
|
||||
import { Variables } from './variable/Variables';
|
||||
|
||||
@@ -187,5 +188,7 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
];
|
||||
|
||||
for (const def of BUILT_IN_NODES) registerNode(def);
|
||||
// Registered so a machine's graph loads and draws, but not built in: no behavior graph offers it.
|
||||
// Registered so a machine's or a shader's graph loads and draws, but not built in: no behavior
|
||||
// graph offers them.
|
||||
registerNode(State);
|
||||
for (const def of SHADER_NODES) registerNode(def);
|
||||
+13
-3
@@ -91,6 +91,15 @@ export interface StateMachineAsset extends AssetBase {
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph is GLSL in nodes: `Property` nodes declare the sprite rows, the `Output` node ends
|
||||
* the fragment (see `core/shader/compile.ts`). A sprite wears it through its `shader` property.
|
||||
*/
|
||||
export interface ShaderAsset extends AssetBase {
|
||||
type: 'shader';
|
||||
graph: Graph;
|
||||
}
|
||||
|
||||
export type Asset =
|
||||
| SpriteAsset
|
||||
| CameraAsset
|
||||
@@ -101,13 +110,14 @@ export type Asset =
|
||||
| ContainerAsset
|
||||
| TextureAsset
|
||||
| AnimationAsset
|
||||
| StateMachineAsset;
|
||||
| StateMachineAsset
|
||||
| ShaderAsset;
|
||||
|
||||
/** The assets edited as a node graph – what the graph canvas and the graph commands work on. */
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset;
|
||||
export type GraphAsset = BehaviorAsset | StateMachineAsset | ShaderAsset;
|
||||
|
||||
export function isGraphAsset(asset: Asset): asset is GraphAsset {
|
||||
return asset.type === 'behavior' || asset.type === 'statemachine';
|
||||
return asset.type === 'behavior' || asset.type === 'statemachine' || asset.type === 'shader';
|
||||
}
|
||||
|
||||
export function isSceneAsset(asset: Asset): asset is SceneAsset {
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ASSET_TYPES = [
|
||||
'texture',
|
||||
'animation',
|
||||
'statemachine',
|
||||
'shader',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -24,16 +25,28 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
};
|
||||
|
||||
/**
|
||||
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`.
|
||||
* A state machine is not either: a behavior refers to it by id.
|
||||
* Textures and animations are not placeable: they are pictures a sprite wears through `texture`,
|
||||
* as a shader is worn through `shader`. A state machine is not either: a behavior refers to it by id.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<AssetType, 'scene' | 'texture' | 'animation' | 'statemachine'>;
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
'scene' | 'texture' | 'animation' | 'statemachine' | 'shader'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'scene',
|
||||
'texture',
|
||||
'animation',
|
||||
'statemachine',
|
||||
'shader',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
return type !== 'scene' && type !== 'texture' && type !== 'animation' && type !== 'statemachine';
|
||||
return !NOT_PLACEABLE.has(type);
|
||||
}
|
||||
|
||||
export function isAssetType(value: unknown): value is AssetType {
|
||||
|
||||
@@ -12,7 +12,7 @@ export function graphLookup(project: Project): GraphLookup {
|
||||
behavior: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'behavior');
|
||||
if (!asset) return null;
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(asset) };
|
||||
return { name: asset.name, graph: asset.graph, defaults: resolveAssetProps(project, asset) };
|
||||
},
|
||||
machine: (assetId) => {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
|
||||
@@ -48,7 +48,7 @@ export function findPropertyUsages(project: Project, ids: string | readonly stri
|
||||
const refKeys = new Map<string, string[]>();
|
||||
const matches = (value: unknown): boolean => typeof value === 'string' && wanted.has(value);
|
||||
for (const asset of project.assets) {
|
||||
const keys = schemaForAsset(asset)
|
||||
const keys = schemaForAsset(project, asset)
|
||||
.filter((def) => isKindWith('reference', def.kind))
|
||||
.map((def) => def.key);
|
||||
refKeys.set(asset.id, keys);
|
||||
|
||||
@@ -19,7 +19,10 @@ import {
|
||||
type ValueOf,
|
||||
type VariableKind,
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||||
|
||||
/**
|
||||
* What a kind's definition carries beyond key, label and default. `decimals` belongs only on
|
||||
@@ -124,6 +127,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
// The fill while there is no picture, and a tint multiplier once there is one: white = no tint.
|
||||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '' },
|
||||
// The shader's own parameters follow this row – see `schemaForAsset`. Empty is no shader at all.
|
||||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||||
],
|
||||
// The picture itself is intrinsic data on the asset (see TextureAsset), not a property.
|
||||
texture: [
|
||||
@@ -157,6 +162,8 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
// The states are the asset's graph (see StateMachineAsset), not a property.
|
||||
statemachine: [],
|
||||
// The nodes are the asset's graph (see ShaderAsset); its parameters are rows of the sprites wearing it.
|
||||
shader: [],
|
||||
collider: [
|
||||
{
|
||||
key: 'shape',
|
||||
@@ -204,15 +211,43 @@ export function schemaFor(type: AssetType): PropertySchema {
|
||||
return FULL_SCHEMAS[type];
|
||||
}
|
||||
|
||||
/** The type schema, for behaviors followed by their declared variables. */
|
||||
export function schemaForAsset(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior'
|
||||
? [...FULL_SCHEMAS.behavior, ...asset.variables]
|
||||
: FULL_SCHEMAS[asset.type];
|
||||
/**
|
||||
* The type schema, for behaviors followed by their declared variables, for sprites by the
|
||||
* parameters of the shader asset their bag names – which is why the project is needed.
|
||||
*/
|
||||
export function schemaForAsset(project: Project, asset: Asset): PropertySchema {
|
||||
return schemaForBag(project, asset, asset.props);
|
||||
}
|
||||
|
||||
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
|
||||
return schemaForAsset(asset).find((d) => d.key === key);
|
||||
/** A placed object's rows: a sprite overriding its shader has that shader's parameters. */
|
||||
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||||
const shader = object.overrides[SHADER_KEY];
|
||||
return schemaForBag(project, asset, shader === undefined ? asset.props : { [SHADER_KEY]: shader });
|
||||
}
|
||||
|
||||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||||
return FULL_SCHEMAS[asset.type];
|
||||
}
|
||||
|
||||
/** The rows a shader asset adds to the sprites wearing it; a dangling id adds none. */
|
||||
export function shaderParamRows(project: Project, shaderId: string): PropertyDef[] {
|
||||
const shader = shaderId ? findAssetOf(project, shaderId, 'shader') : undefined;
|
||||
return shader ? shaderParamDefs(shader.graph) : [];
|
||||
}
|
||||
|
||||
/** The parameters of every shader in the project, one row per name – what a sprite *may* have. */
|
||||
export function allShaderParamRows(project: Project): PropertyDef[] {
|
||||
const rows = new Map<string, PropertyDef>();
|
||||
for (const asset of project.assets)
|
||||
if (asset.type === 'shader')
|
||||
for (const row of shaderParamDefs(asset.graph)) if (!rows.has(row.key)) rows.set(row.key, row);
|
||||
return [...rows.values()];
|
||||
}
|
||||
|
||||
export function propDef(project: Project, asset: Asset, key: string): PropertyDef | undefined {
|
||||
return schemaForAsset(project, asset).find((d) => d.key === key);
|
||||
}
|
||||
|
||||
/** A fresh bag of the type's default values (callers may mutate it). */
|
||||
|
||||
@@ -3,20 +3,20 @@ import { ancestorIdsOf, type ObjectTree } from '../model/hierarchy';
|
||||
import { instanceTargetOf, isEmptyEdit, resolveObjects } from '../model/instance';
|
||||
import { findAsset, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { type PropertySchema, schemaFor, schemaForAsset } from './PropertySchema';
|
||||
import { type PropertySchema, schemaFor, schemaForAsset, schemaForObject } from './PropertySchema';
|
||||
import { deviatesFromAsset, isModified } from './resolve';
|
||||
|
||||
/**
|
||||
* The rows the asset inspector marks as modified. A behavior's variables are left out: their
|
||||
* default is declared on the variable itself, so the asset value cannot differ from it.
|
||||
*/
|
||||
export function markedAssetSchema(asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(asset);
|
||||
export function markedAssetSchema(project: Project, asset: Asset): PropertySchema {
|
||||
return asset.type === 'behavior' ? schemaFor('behavior') : schemaForAsset(project, asset);
|
||||
}
|
||||
|
||||
/** Whether any asset property the inspector would mark gold differs from its default. */
|
||||
export function assetDeviates(asset: Asset): boolean {
|
||||
return markedAssetSchema(asset).some((def) => isModified(asset, def.key));
|
||||
export function assetDeviates(project: Project, asset: Asset): boolean {
|
||||
return markedAssetSchema(project, asset).some((def) => isModified(project, asset, def.key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,8 +33,9 @@ export function objectDeviates(project: Project, holder: ObjectTree, object: Sce
|
||||
}
|
||||
if (object.editorOffset.x !== 0 || object.editorOffset.y !== 0) return true;
|
||||
const asset = findAsset(project, object.assetId);
|
||||
return (
|
||||
asset !== undefined && schemaForAsset(asset).some((def) => deviatesFromAsset(object, asset, def.key))
|
||||
if (!asset) return false;
|
||||
return schemaForObject(project, asset, object).some((def) =>
|
||||
deviatesFromAsset(project, object, asset, def.key),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,6 @@ export function runsInEditor(props: PropertyBag): boolean {
|
||||
export function hasEditorRun(project: Project, holder: ObjectTree): boolean {
|
||||
return resolveObjects(project, holder).some((object) => {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(asset, object));
|
||||
return asset?.type === 'behavior' && runsInEditor(resolveSceneObjectProps(project, asset, object));
|
||||
});
|
||||
}
|
||||
@@ -1,48 +1,60 @@
|
||||
import type { Asset } from '../model/Asset';
|
||||
import { type PropertyBag, type PropertyValue, sameValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { propDef, schemaForAsset } from './PropertySchema';
|
||||
import { propDef, schemaForAsset, schemaForObject } from './PropertySchema';
|
||||
|
||||
/**
|
||||
* Schema defaults filled with the asset's own values. Keys unknown to the schema are ignored, so
|
||||
* stale data never leaks into the editor.
|
||||
*/
|
||||
export function resolveAssetProps(asset: Asset): PropertyBag {
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
const own = asset.props[def.key];
|
||||
bag[def.key] = own !== undefined ? own : def.default;
|
||||
}
|
||||
return bag;
|
||||
export function resolveAssetProps(project: Project, asset: Asset): PropertyBag {
|
||||
return valuesOver(schemaForAsset(project, asset), asset.props);
|
||||
}
|
||||
|
||||
/** The only place where overrides are merged onto asset values. */
|
||||
export function resolveSceneObjectProps(asset: Asset, object: SceneObject): PropertyBag {
|
||||
const bag = resolveAssetProps(asset);
|
||||
for (const def of schemaForAsset(asset)) {
|
||||
/**
|
||||
* The only place where overrides are merged onto asset values. The rows are the object's: a
|
||||
* sprite overriding its shader has that shader's parameters, not the asset's.
|
||||
*/
|
||||
export function resolveSceneObjectProps(project: Project, asset: Asset, object: SceneObject): PropertyBag {
|
||||
const schema = schemaForObject(project, asset, object);
|
||||
const bag = valuesOver(schema, asset.props);
|
||||
for (const def of schema) {
|
||||
const override = object.overrides[def.key];
|
||||
if (override !== undefined) bag[def.key] = override;
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
function valuesOver(
|
||||
schema: readonly { key: string; default: PropertyValue }[],
|
||||
values: PropertyBag,
|
||||
): PropertyBag {
|
||||
const bag: PropertyBag = {};
|
||||
for (const def of schema) {
|
||||
const own = values[def.key];
|
||||
bag[def.key] = own !== undefined ? own : def.default;
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
/** Whether the asset's own value differs from the schema default – the asset-level override. */
|
||||
export function isModified(asset: Asset, key: string): boolean {
|
||||
const def = propDef(asset, key);
|
||||
export function isModified(project: Project, asset: Asset, key: string): boolean {
|
||||
const def = propDef(project, asset, key);
|
||||
if (!def) return false;
|
||||
const own = asset.props[key];
|
||||
return own !== undefined && !sameValue(own, def.default);
|
||||
}
|
||||
|
||||
/** The value the object would have without its override for `key`. */
|
||||
export function inheritedValue(asset: Asset, key: string): PropertyValue | undefined {
|
||||
return resolveAssetProps(asset)[key];
|
||||
export function inheritedValue(project: Project, asset: Asset, key: string): PropertyValue | undefined {
|
||||
return resolveAssetProps(project, asset)[key];
|
||||
}
|
||||
|
||||
/** An override that happens to carry the asset's own value does not count as a deviation. */
|
||||
export function deviatesFromAsset(object: SceneObject, asset: Asset, key: string): boolean {
|
||||
export function deviatesFromAsset(project: Project, object: SceneObject, asset: Asset, key: string): boolean {
|
||||
const own = object.overrides[key];
|
||||
return own !== undefined && !sameValue(own, inheritedValue(asset, key));
|
||||
return own !== undefined && !sameValue(own, inheritedValue(project, asset, key));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Graph, GraphNode } from '../graph/Graph';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { type FlatDef, isValidFieldName } from './PropertySchema';
|
||||
|
||||
/** The sprite property naming the shader asset it wears; `''` is no shader and no render pass. */
|
||||
export const SHADER_KEY = 'shader';
|
||||
|
||||
/**
|
||||
* The node of a shader graph that declares a parameter: its `name` is a sprite property and a
|
||||
* GLSL uniform, `default`/`min`/`max` are the row the inspector shows.
|
||||
*/
|
||||
export const SHADER_PROPERTY = 'ShaderProperty';
|
||||
export const PROPERTY_NAME_PARAM = 'name';
|
||||
export const PROPERTY_DEFAULT_PARAM = 'default';
|
||||
export const PROPERTY_MIN_PARAM = 'min';
|
||||
export const PROPERTY_MAX_PARAM = 'max';
|
||||
|
||||
export function shaderOf(bag: PropertyBag): string {
|
||||
return textOf(bag[SHADER_KEY]);
|
||||
}
|
||||
|
||||
export function isPropertyNode(node: GraphNode): boolean {
|
||||
return node.type === SHADER_PROPERTY;
|
||||
}
|
||||
|
||||
export function propertyName(node: GraphNode): string {
|
||||
return textOf(node.params[PROPERTY_NAME_PARAM] ?? '');
|
||||
}
|
||||
|
||||
/** A property's name is a bag key and a uniform, so it has to be an identifier. */
|
||||
export function isValidPropertyName(name: string): boolean {
|
||||
return isValidFieldName(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* The properties a shader graph declares, in node order: one number row per `Property` node with
|
||||
* a valid name. A second node with the same name declares nothing new – both read one uniform.
|
||||
*/
|
||||
export function shaderParamDefs(graph: Graph): FlatDef<'number'>[] {
|
||||
const rows: FlatDef<'number'>[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const node of graph.nodes) {
|
||||
if (!isPropertyNode(node)) continue;
|
||||
const name = propertyName(node);
|
||||
if (!isValidPropertyName(name) || seen.has(name)) continue;
|
||||
seen.add(name);
|
||||
rows.push(propertyRow(name, node.params));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function propertyRow(name: string, params: PropertyBag): FlatDef<'number'> {
|
||||
const min = numberParam(params, PROPERTY_MIN_PARAM, 0);
|
||||
const max = numberParam(params, PROPERTY_MAX_PARAM, 1);
|
||||
const row: FlatDef<'number'> = {
|
||||
key: name,
|
||||
label: name,
|
||||
kind: 'number',
|
||||
default: numberParam(params, PROPERTY_DEFAULT_PARAM, 0),
|
||||
min,
|
||||
step: niceStep(max > min ? max - min : 1),
|
||||
};
|
||||
// A max at or below the min would pin the value; treated as "no max" instead.
|
||||
return max > min ? { ...row, max } : row;
|
||||
}
|
||||
|
||||
function numberParam(params: PropertyBag, key: string, fallback: number): number {
|
||||
const n = Number(params[key]);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
/** A twentieth of the range's power of ten: 0.05 for 0-4, 0.05 for 0-1, 5 for 0-100. */
|
||||
function niceStep(range: number): number {
|
||||
return 10 ** Math.floor(Math.log10(range)) / 20;
|
||||
}
|
||||
|
||||
/** What a property placed from the palette is called: `value`, then `value2`, `value3` … */
|
||||
export function nextPropertyName(graph: Graph): string {
|
||||
const taken = new Set(graph.nodes.filter(isPropertyNode).map(propertyName));
|
||||
if (!taken.has('value')) return 'value';
|
||||
for (let n = 2; ; n++) if (!taken.has(`value${n}`)) return `value${n}`;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { edgesInto, findNode, type Graph, type GraphNode } from '../graph/Graph';
|
||||
import { nodeDef, type PortDef } from '../graph/NodeRegistry';
|
||||
import { colorOf } from '../model/Properties';
|
||||
import { isPropertyNode, isValidPropertyName, propertyName, shaderParamDefs } from '../properties/shaders';
|
||||
import {
|
||||
asVec3,
|
||||
type Expr,
|
||||
floatLiteral,
|
||||
isShaderNodeDef,
|
||||
SHADER_OUTPUT,
|
||||
TEXEL_COLOR,
|
||||
uniformName,
|
||||
vec3Literal,
|
||||
} from './nodes';
|
||||
|
||||
export interface ShaderUniform {
|
||||
name: string;
|
||||
default: number;
|
||||
}
|
||||
|
||||
export interface CompiledShader {
|
||||
fragment: string;
|
||||
uniforms: ShaderUniform[];
|
||||
}
|
||||
|
||||
export interface ShaderBuild {
|
||||
/** Null while the graph has a problem – the sprite then draws without a filter. */
|
||||
compiled: CompiledShader | null;
|
||||
/** Per node id, what keeps the graph from compiling. */
|
||||
problems: Record<string, string>;
|
||||
/** One line for the tab: empty once compiled. */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A shader graph to a Pixi filter fragment: every node reachable from the Output becomes one
|
||||
* local per output, in dependency order, and every Property a float uniform. Pixi hands a filter
|
||||
* premultiplied pixels, so the prelude unpremultiplies and the epilogue multiplies back.
|
||||
*/
|
||||
export function compileShader(graph: Graph): ShaderBuild {
|
||||
const problems: Record<string, string> = {};
|
||||
const problem = (node: GraphNode, text: string) => {
|
||||
if (!(node.id in problems)) problems[node.id] = text;
|
||||
};
|
||||
|
||||
const names = new Set<string>();
|
||||
for (const node of graph.nodes) {
|
||||
if (!isPropertyNode(node)) continue;
|
||||
const name = propertyName(node);
|
||||
if (!isValidPropertyName(name)) problem(node, 'Name must be one word: letters, digits, _');
|
||||
else if (names.has(name)) problem(node, `Another property is named "${name}"`);
|
||||
names.add(name);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const emitted = new Map<string, Record<string, Expr>>();
|
||||
const visiting = new Set<string>();
|
||||
let count = 0;
|
||||
|
||||
const emitNode = (node: GraphNode): Record<string, Expr> | null => {
|
||||
const done = emitted.get(node.id);
|
||||
if (done) return done;
|
||||
if (visiting.has(node.id)) {
|
||||
problem(node, 'Feeds itself');
|
||||
return null;
|
||||
}
|
||||
const def = nodeDef(node.type);
|
||||
if (!def || !isShaderNodeDef(def)) {
|
||||
problem(node, 'Not a shader node');
|
||||
return null;
|
||||
}
|
||||
visiting.add(node.id);
|
||||
const inputs: Record<string, Expr> = {};
|
||||
let complete = true;
|
||||
for (const port of def.inputs) {
|
||||
const expr = input(node, port);
|
||||
if (expr) inputs[port.key] = expr;
|
||||
else complete = false;
|
||||
}
|
||||
visiting.delete(node.id);
|
||||
if (!complete) return null;
|
||||
const outputs: Record<string, Expr> = {};
|
||||
for (const [key, expr] of Object.entries(def.emit(inputs, node.params))) {
|
||||
const local = `n${count++}_${key}`;
|
||||
lines.push(`${expr.type} ${local} = ${expr.code};`);
|
||||
outputs[key] = { code: local, type: expr.type };
|
||||
}
|
||||
emitted.set(node.id, outputs);
|
||||
return outputs;
|
||||
};
|
||||
|
||||
/** What feeds an input: the edge's source, the typed-in value, or – with nothing to type – the texel. */
|
||||
const input = (node: GraphNode, port: PortDef): Expr | null => {
|
||||
const edge = edgesInto(graph, node.id, port.key)[0];
|
||||
if (edge) {
|
||||
const source = findNode(graph, edge.from.node);
|
||||
const outputs = source && emitNode(source);
|
||||
const expr = outputs?.[edge.from.port];
|
||||
if (!expr) return null;
|
||||
return conform(node, port, expr);
|
||||
}
|
||||
const typed = node.params[port.key] ?? port.default;
|
||||
if (port.field?.kind === 'color')
|
||||
return { code: vec3Literal(colorOf(typed ?? 0xffffff, 0xffffff)), type: 'vec3' };
|
||||
if (port.field) return { code: floatLiteral(Number(typed ?? 0)), type: 'float' };
|
||||
return TEXEL_COLOR;
|
||||
};
|
||||
|
||||
const conform = (node: GraphNode, port: PortDef, expr: Expr): Expr | null => {
|
||||
if (port.type === 'number' && expr.type === 'vec3') {
|
||||
problem(node, `"${port.key}" needs a number, not a colour`);
|
||||
return null;
|
||||
}
|
||||
if (port.type === 'vec3' && expr.type === 'float') return { code: asVec3(expr), type: 'vec3' };
|
||||
return expr;
|
||||
};
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === SHADER_OUTPUT);
|
||||
if (!output) return { compiled: null, problems, reason: 'No Output node' };
|
||||
const outputDef = nodeDef(SHADER_OUTPUT)!;
|
||||
const color = input(output, outputDef.inputs[0]!);
|
||||
|
||||
const first = graph.nodes.find((n) => n.id in problems);
|
||||
if (first || !color)
|
||||
return { compiled: null, problems, reason: first ? problems[first.id]! : 'Cannot compile' };
|
||||
|
||||
const uniforms = shaderParamDefs(graph).map((row) => ({ name: row.key, default: row.default }));
|
||||
const fragment = [
|
||||
'in vec2 vTextureCoord;',
|
||||
'out vec4 finalColor;',
|
||||
'uniform sampler2D uTexture;',
|
||||
...uniforms.map((u) => `uniform float ${uniformName(u.name)};`),
|
||||
'void main()',
|
||||
'{',
|
||||
' vec4 texel = texture(uTexture, vTextureCoord);',
|
||||
` vec3 ${TEXEL_COLOR.code} = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;`,
|
||||
...lines.map((line) => ` ${line}`),
|
||||
` finalColor = vec4(clamp(${color.code}, 0.0, 1.0) * texel.a, texel.a);`,
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
return { compiled: { fragment, uniforms }, problems, reason: '' };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { flatDef } from '../properties/PropertySchema';
|
||||
import {
|
||||
PROPERTY_DEFAULT_PARAM,
|
||||
PROPERTY_MAX_PARAM,
|
||||
PROPERTY_MIN_PARAM,
|
||||
PROPERTY_NAME_PARAM,
|
||||
propertyName,
|
||||
SHADER_PROPERTY,
|
||||
} from '../properties/shaders';
|
||||
|
||||
export type GlslType = 'float' | 'vec3';
|
||||
|
||||
/** A GLSL expression and what it evaluates to. */
|
||||
export interface Expr {
|
||||
code: string;
|
||||
type: GlslType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A node of a shader graph. It never runs: `emit` writes the GLSL of its outputs from the GLSL of
|
||||
* its inputs. An input typed `number` arrives as a float, one typed `vec3` as a vec3 (a float is
|
||||
* broadcast), one typed `any` as whatever feeds it.
|
||||
*/
|
||||
export interface ShaderNodeDef extends NodeDef {
|
||||
category: 'shader';
|
||||
emit(inputs: Record<string, Expr>, params: PropertyBag): Record<string, Expr>;
|
||||
}
|
||||
|
||||
export function isShaderNodeDef(def: NodeDef): def is ShaderNodeDef {
|
||||
return def.category === 'shader' && 'emit' in def;
|
||||
}
|
||||
|
||||
export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor';
|
||||
export const SHADER_OUTPUT = 'ShaderOutput';
|
||||
export const SHADER_COLOR = 'ShaderColor';
|
||||
|
||||
/** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */
|
||||
export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' };
|
||||
|
||||
const NUMBER_STEP = 0.05;
|
||||
|
||||
const vec3Port = (key: string): PortDef => ({ key, label: key, type: 'vec3' });
|
||||
|
||||
/** A number input with an inline value, so a constant needs no node of its own. */
|
||||
const floatIn = (key: string, value: number): PortDef => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'number',
|
||||
default: value,
|
||||
field: { key, label: key, kind: 'number', default: value, step: NUMBER_STEP },
|
||||
});
|
||||
|
||||
/** An input taking a number or a colour; typed in, it is a number. */
|
||||
const anyIn = (key: string, value: number): PortDef => ({ ...floatIn(key, value), type: 'any' });
|
||||
|
||||
const anyOut = (key: string): PortDef => ({ key, label: key, type: 'any' });
|
||||
|
||||
export const asVec3 = (e: Expr): string => (e.type === 'vec3' ? e.code : `vec3(${e.code})`);
|
||||
|
||||
/** Two operands lifted to one type: a colour on either side makes the result a colour. */
|
||||
function lifted(a: Expr, b: Expr): { a: string; b: string; type: GlslType } {
|
||||
if (a.type === 'vec3' || b.type === 'vec3') return { a: asVec3(a), b: asVec3(b), type: 'vec3' };
|
||||
return { a: a.code, b: b.code, type: 'float' };
|
||||
}
|
||||
|
||||
function arithmetic(
|
||||
type: string,
|
||||
label: string,
|
||||
op: string,
|
||||
description: string,
|
||||
bDefault: number,
|
||||
): ShaderNodeDef {
|
||||
return {
|
||||
type,
|
||||
label,
|
||||
category: 'shader',
|
||||
description,
|
||||
inputs: [anyIn('a', bDefault === 1 ? 1 : 0), anyIn('b', bDefault)],
|
||||
outputs: [anyOut('result')],
|
||||
params: [],
|
||||
emit: ({ a, b }) => {
|
||||
const l = lifted(a!, b!);
|
||||
return { result: { code: `(${l.a} ${op} ${l.b})`, type: l.type } };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ShaderTextureColor: ShaderNodeDef = {
|
||||
type: SHADER_TEXTURE_COLOR,
|
||||
label: 'Texture color',
|
||||
category: 'shader',
|
||||
description: "The sprite's own pixel, before the shader: its texture tinted by its color.",
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [],
|
||||
emit: () => ({ color: TEXEL_COLOR }),
|
||||
};
|
||||
|
||||
/** The one node the compiler starts from; an unconnected color leaves the pixel as it is. */
|
||||
export const ShaderOutput: ShaderNodeDef = {
|
||||
type: SHADER_OUTPUT,
|
||||
label: 'Output',
|
||||
category: 'shader',
|
||||
description: 'The colour the sprite is drawn with. Its alpha stays the texture’s.',
|
||||
inputs: [vec3Port('color')],
|
||||
outputs: [],
|
||||
params: [],
|
||||
emit: () => ({}),
|
||||
};
|
||||
|
||||
/** `name` is the sprite property and the uniform; the numbers are the row the inspector shows. */
|
||||
export const ShaderProperty: ShaderNodeDef = {
|
||||
type: SHADER_PROPERTY,
|
||||
label: 'Property',
|
||||
category: 'shader',
|
||||
description: 'A number the sprite sets: a property row below the Shader dropdown, and a value here.',
|
||||
inputs: [],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [
|
||||
flatDef(PROPERTY_NAME_PARAM, 'text', { label: 'Name', value: 'value' }),
|
||||
{ key: PROPERTY_DEFAULT_PARAM, label: 'Default', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MIN_PARAM, label: 'Min', kind: 'number', default: 0, step: NUMBER_STEP },
|
||||
{ key: PROPERTY_MAX_PARAM, label: 'Max', kind: 'number', default: 1, step: NUMBER_STEP },
|
||||
],
|
||||
derivePorts: ({ node }) => ({ label: propertyName(node) || ShaderProperty.label }),
|
||||
emit: (_inputs, params) => ({
|
||||
value: { code: uniformName(textOf(params[PROPERTY_NAME_PARAM] ?? '')), type: 'float' },
|
||||
}),
|
||||
};
|
||||
|
||||
export function uniformName(property: string): string {
|
||||
return `u_${property}`;
|
||||
}
|
||||
|
||||
export const ShaderColor: ShaderNodeDef = {
|
||||
type: SHADER_COLOR,
|
||||
label: 'Color',
|
||||
category: 'shader',
|
||||
description: 'A constant colour.',
|
||||
inputs: [],
|
||||
outputs: [vec3Port('color')],
|
||||
params: [flatDef('value', 'color', { label: 'Value', value: 0xffffff })],
|
||||
emit: (_inputs, params) => ({
|
||||
color: { code: vec3Literal(Number(params.value ?? 0xffffff)), type: 'vec3' },
|
||||
}),
|
||||
};
|
||||
|
||||
export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0);
|
||||
export const ShaderSubtract = arithmetic(
|
||||
'ShaderSubtract',
|
||||
'Subtract',
|
||||
'-',
|
||||
'a - b, per channel for colours.',
|
||||
0,
|
||||
);
|
||||
export const ShaderMultiply = arithmetic(
|
||||
'ShaderMultiply',
|
||||
'Multiply',
|
||||
'*',
|
||||
'a × b, per channel for colours.',
|
||||
1,
|
||||
);
|
||||
export const ShaderDivide = arithmetic('ShaderDivide', 'Divide', '/', 'a / b, per channel for colours.', 1);
|
||||
|
||||
export const ShaderMix: ShaderNodeDef = {
|
||||
type: 'ShaderMix',
|
||||
label: 'Mix',
|
||||
category: 'shader',
|
||||
description: 'Blends from a to b: t = 0 is a, t = 1 is b, beyond that it extrapolates.',
|
||||
inputs: [anyIn('a', 0), anyIn('b', 1), floatIn('t', 0.5)],
|
||||
outputs: [anyOut('result')],
|
||||
params: [],
|
||||
emit: ({ a, b, t }) => {
|
||||
const l = lifted(a!, b!);
|
||||
return { result: { code: `mix(${l.a}, ${l.b}, ${t!.code})`, type: l.type } };
|
||||
},
|
||||
};
|
||||
|
||||
export const ShaderLuminance: ShaderNodeDef = {
|
||||
type: 'ShaderLuminance',
|
||||
label: 'Luminance',
|
||||
category: 'shader',
|
||||
description: 'How bright a colour is (Rec. 709 weights), as one number.',
|
||||
inputs: [vec3Port('color')],
|
||||
outputs: [{ key: 'value', label: 'value', type: 'number' }],
|
||||
params: [],
|
||||
emit: ({ color }) => ({
|
||||
value: { code: `dot(${color!.code}, vec3(0.2126, 0.7152, 0.0722))`, type: 'float' },
|
||||
}),
|
||||
};
|
||||
|
||||
/** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */
|
||||
export const SHADER_NODES: readonly ShaderNodeDef[] = [
|
||||
ShaderTextureColor,
|
||||
ShaderOutput,
|
||||
ShaderProperty,
|
||||
ShaderColor,
|
||||
ShaderAdd,
|
||||
ShaderSubtract,
|
||||
ShaderMultiply,
|
||||
ShaderDivide,
|
||||
ShaderMix,
|
||||
ShaderLuminance,
|
||||
];
|
||||
|
||||
/** A GLSL float literal: always with a decimal point, so `1` does not become an int. */
|
||||
export function floatLiteral(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0.0';
|
||||
return Number.isInteger(n) ? n.toFixed(1) : String(n);
|
||||
}
|
||||
|
||||
export function vec3Literal(packed: number): string {
|
||||
const channel = (shift: number) => ((packed >> shift) & 0xff) / 255;
|
||||
return `vec3(${channel(16).toFixed(4)}, ${channel(8).toFixed(4)}, ${channel(0).toFixed(4)})`;
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
type GraphNode,
|
||||
isValidPortName,
|
||||
fixedPortKeys,
|
||||
type NodeDef,
|
||||
nodeDef,
|
||||
STATE,
|
||||
} from '../graph';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import type { Asset, TextureFrame } from '../model/Asset';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { emptyEdit, type InstanceEdit, isEmptyEdit } from '../model/instance';
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
type StructFieldDef,
|
||||
variableDef,
|
||||
} from '../properties/PropertySchema';
|
||||
import { SHADER_KEY, shaderParamDefs } from '../properties/shaders';
|
||||
|
||||
/**
|
||||
* Turns untrusted JSON (server file, import) into a well-formed Project: unknown keys are dropped,
|
||||
@@ -57,16 +60,33 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
// Pass 1: asset ids and their effective schemas, needed to coerce overrides in pass 2.
|
||||
const schemas = new Map<string, PropertySchema>();
|
||||
const variables = new Map<string, PropertyDef[]>();
|
||||
const rawProps = new Map<string, Record<string, unknown>>();
|
||||
const shaderGraphs = new Map<string, Graph>();
|
||||
const shaderRows = new Map<string, PropertyDef[]>();
|
||||
for (const a of rawAssets) {
|
||||
if (typeof a.id !== 'string' || !a.id || !isAssetType(a.type)) continue;
|
||||
rawProps.set(a.id, asRecord(a.props));
|
||||
if (a.type === 'behavior') {
|
||||
const vars = normalizeVariables(a.variables);
|
||||
variables.set(a.id, vars);
|
||||
schemas.set(a.id, [...COMMON_PROPS, ...vars]);
|
||||
} else {
|
||||
if (a.type === 'shader') {
|
||||
const graph = normalizeGraph(a.graph, isShaderNodeDef);
|
||||
shaderGraphs.set(a.id, graph);
|
||||
shaderRows.set(a.id, shaderParamDefs(graph));
|
||||
}
|
||||
schemas.set(a.id, schemaFor(a.type));
|
||||
}
|
||||
}
|
||||
/** A sprite's rows end with the parameters of the shader its bag names – an override's, else the asset's. */
|
||||
const schemaOf = (assetId: string, bag: Record<string, unknown>): PropertySchema | undefined => {
|
||||
const schema = schemas.get(assetId);
|
||||
if (!schema || schema !== schemaFor('sprite')) return schema;
|
||||
const own = bag[SHADER_KEY];
|
||||
const shader = typeof own === 'string' ? own : rawProps.get(assetId)?.[SHADER_KEY];
|
||||
return [...schema, ...(shaderRows.get(typeof shader === 'string' ? shader : '') ?? [])];
|
||||
};
|
||||
|
||||
// Pass 2: build assets. Frame ids share the reference space with asset ids, so `normalizeTexture`
|
||||
// is handed the ids already spoken for.
|
||||
@@ -74,13 +94,13 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
const takenIds = new Set(schemas.keys());
|
||||
for (const a of rawAssets) {
|
||||
const id = typeof a.id === 'string' ? a.id : '';
|
||||
const schema = schemas.get(id);
|
||||
const schema = schemaOf(id, asRecord(a.props));
|
||||
if (!schema || !isAssetType(a.type)) continue;
|
||||
const type = a.type;
|
||||
const name = typeof a.name === 'string' ? a.name : type;
|
||||
const props = normalizeBag(a.props, schema, false);
|
||||
if (type === 'scene' || type === 'container') {
|
||||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
|
||||
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemaOf) });
|
||||
} else if (type === 'behavior') {
|
||||
assets.push({
|
||||
id,
|
||||
@@ -91,7 +111,9 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
graph: normalizeGraph(a.graph),
|
||||
});
|
||||
} else if (type === 'statemachine') {
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, STATE) });
|
||||
assets.push({ id, type, name, props, graph: normalizeGraph(a.graph, (def) => def.type === STATE) });
|
||||
} else if (type === 'shader') {
|
||||
assets.push({ id, type, name, props, graph: shaderGraphs.get(id) ?? createEmptyGraph() });
|
||||
} else if (type === 'texture') {
|
||||
assets.push({ id, type, name, props, ...normalizeTexture(a, takenIds) });
|
||||
} else if (type === 'animation') {
|
||||
@@ -130,12 +152,15 @@ export function serializeProject(project: Project): unknown {
|
||||
return JSON.parse(JSON.stringify(project));
|
||||
}
|
||||
|
||||
function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): SceneObject[] {
|
||||
function normalizeObjects(
|
||||
raw: unknown,
|
||||
schemaOf: (assetId: string, overrides: Record<string, unknown>) => PropertySchema | undefined,
|
||||
): 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 schema = schemas.get(o.assetId);
|
||||
const schema = schemaOf(o.assetId, asRecord(o.overrides));
|
||||
if (!schema || schema === schemaFor('scene')) continue; // dangling reference or illegal scene-in-scene
|
||||
const p = asRecord(o.position);
|
||||
const offset = asRecord(o.editorOffset);
|
||||
@@ -245,8 +270,8 @@ function normalizePorts(raw: unknown, fixed: readonly string[], writable: boolea
|
||||
return ports;
|
||||
}
|
||||
|
||||
/** `only` restricts the node types kept: a state machine's graph holds states and nothing else. */
|
||||
function normalizeGraph(raw: unknown, only?: string): Graph {
|
||||
/** `keep` restricts the node types kept: a state machine's graph holds states, a shader's shader nodes, and nothing else. */
|
||||
function normalizeGraph(raw: unknown, keep?: (def: NodeDef) => boolean): Graph {
|
||||
const g = asRecord(raw);
|
||||
const graph = createEmptyGraph();
|
||||
const nodeIds = new Set<string>();
|
||||
@@ -255,7 +280,7 @@ function normalizeGraph(raw: unknown, only?: string): Graph {
|
||||
const id = typeof n.id === 'string' ? n.id : '';
|
||||
const type = typeof n.type === 'string' ? n.type : '';
|
||||
const def = nodeDef(type);
|
||||
if (!id || nodeIds.has(id) || !def || (only !== undefined && type !== only)) continue;
|
||||
if (!id || nodeIds.has(id) || !def || (keep !== undefined && !keep(def))) continue;
|
||||
nodeIds.add(id);
|
||||
// Params carry both settings and inline input values, so there is no schema to coerce against.
|
||||
const params: PropertyBag = {};
|
||||
|
||||
Reference in new issue
Block a user