game-maker / suite (push) Successful in 8m23s
The + Property menu of the Properties node offered every shader's params for every sprite. Each shader row now carries its shader name, and a name shared by two shaders lists both: "brightness (Adjust colors, Neon glow)". Sprite-owned rows stay unlabelled. The note hangs off a map keyed by the def object, not by the name -- a sprite property and a shader param may share a name (#63), and keying by name would label the wrong row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
import type { Asset } from '../model/Asset';
|
||
import { type AssetType, isPlaceableType } from '../model/AssetTypes';
|
||
import {
|
||
type FieldValue,
|
||
isList,
|
||
isStruct,
|
||
type ListValue,
|
||
type PropertyBag,
|
||
type PropertyValue,
|
||
type StructValue,
|
||
textOf,
|
||
} from '../model/Properties';
|
||
import {
|
||
coerceAs,
|
||
coerceField,
|
||
emptyOf,
|
||
type FieldKind,
|
||
type FlatKind,
|
||
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
|
||
* properties that are integral by meaning (counts, sizes in whole pixels), not on geometry: dragging
|
||
* produces fractions, and the field rounds the value away rather than hiding it. Without
|
||
* `assetTypes` an asset picker offers every placeable asset.
|
||
*/
|
||
type Extras<K extends FlatKind> = K extends 'number'
|
||
? { min?: number; max?: number; step?: number; decimals?: number }
|
||
: K extends 'enum'
|
||
? { options: { value: string; label: string }[] }
|
||
: K extends 'asset'
|
||
? { assetTypes?: readonly AssetType[] }
|
||
: unknown;
|
||
|
||
/** The definition of a flat kind: its default is a value of that kind, as the table says. */
|
||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||
[P in K]: { key: string; label: string; kind: P; default: ValueOf<P> } & Extras<P>;
|
||
}[K];
|
||
|
||
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
|
||
export type PropertyDef =
|
||
| FlatDef
|
||
/**
|
||
* A list declares nothing about its items: each one carries its own kind (see `listItems.ts`),
|
||
* and the graph types a list as a list, so one `list` port fits them all. There is no `object`
|
||
* item kind: a list is stored in the project file, and an object is a live handle that no reload
|
||
* survives.
|
||
*/
|
||
| { key: string; label: string; kind: 'list'; default: ListValue }
|
||
/**
|
||
* `fields` is the declaration; the value carries exactly those keys, which is what lets a struct
|
||
* keep its shape while single fields are written (`mergeStruct`).
|
||
*/
|
||
| { key: string; label: string; kind: 'struct'; default: StructValue; fields: readonly StructFieldDef[] };
|
||
|
||
export interface PrimitiveFieldDef {
|
||
key: string;
|
||
kind: FieldKind;
|
||
}
|
||
|
||
/** A list held by a struct field. Its items declare nothing either, but they stay flat. */
|
||
export interface ListFieldDef {
|
||
key: string;
|
||
kind: 'list';
|
||
}
|
||
|
||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
|
||
|
||
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
|
||
|
||
export type PropertySchema = readonly PropertyDef[];
|
||
|
||
/**
|
||
* The definition of a field kind, its default read from `value` (empty when nothing is given). The
|
||
* cast is the table's promise that `coerce` yields the kind.
|
||
*/
|
||
export function flatDef<K extends FieldKind>(
|
||
key: string,
|
||
kind: K,
|
||
{ label = key, value }: { label?: string; value?: PropertyValue } = {},
|
||
): FlatDef<K> {
|
||
return { key, label, kind, default: coerceField(value, kind) } as FlatDef<K>;
|
||
}
|
||
|
||
/**
|
||
* Properties every placeable asset has. A disabled object is not drawn, does not collide and its
|
||
* behavior does not run – and the same goes for everything parented below it.
|
||
*/
|
||
export const COMMON_PROPS: PropertySchema = [
|
||
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
|
||
];
|
||
|
||
const COMMON_KEYS = new Set(COMMON_PROPS.map((d) => d.key));
|
||
|
||
export function isCommonProp(key: string): boolean {
|
||
return COMMON_KEYS.has(key);
|
||
}
|
||
|
||
/** A field lives inside its struct, so the reserved property keys do not apply to it. */
|
||
export function isValidFieldName(name: string): boolean {
|
||
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(name);
|
||
}
|
||
|
||
/** Identifier-like, and not a common property key – a variable "enabled" would shadow the switch. */
|
||
export function isValidVariableName(name: string): boolean {
|
||
return isValidFieldName(name) && !isCommonProp(name);
|
||
}
|
||
|
||
/** Position is deliberately absent: it belongs to scene objects, not to asset types. */
|
||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||
camera: [
|
||
// A view is a pixel raster, so half a pixel of it means nothing.
|
||
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1, decimals: 0 },
|
||
{ key: 'height', label: 'View height', kind: 'number', default: 360, min: 16, step: 1, decimals: 0 },
|
||
{ key: 'zoom', label: 'Zoom', kind: 'number', default: 1, min: 0.1, max: 10, step: 0.1 },
|
||
],
|
||
sprite: [
|
||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||
// 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: [
|
||
{
|
||
key: 'filter',
|
||
label: 'Filter',
|
||
kind: 'enum',
|
||
default: 'linear',
|
||
options: [
|
||
{ value: 'linear', label: 'Linear' },
|
||
{ value: 'nearest', label: 'Nearest' },
|
||
],
|
||
},
|
||
],
|
||
// The pictures are intrinsic data on the asset (see AnimationAsset).
|
||
animation: [
|
||
{ key: 'fps', label: 'Frames per second', kind: 'number', default: 10, min: 0.1, max: 120, step: 1 },
|
||
{
|
||
key: 'playback',
|
||
label: 'Playback',
|
||
kind: 'enum',
|
||
default: 'loop',
|
||
options: [
|
||
{ value: 'loop', label: 'Loop' },
|
||
{ value: 'once', label: 'Once' },
|
||
{ value: 'pingpong', label: 'Ping-pong' },
|
||
],
|
||
},
|
||
],
|
||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||
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',
|
||
label: 'Shape',
|
||
kind: 'enum',
|
||
default: 'box',
|
||
options: [
|
||
{ value: 'box', label: 'Box' },
|
||
{ value: 'circle', label: 'Circle' },
|
||
],
|
||
},
|
||
{ key: 'width', label: 'Width', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'height', label: 'Height', kind: 'number', default: 100, min: 1, step: 1 },
|
||
{ key: 'radius', label: 'Radius', kind: 'number', default: 50, min: 1, step: 1 },
|
||
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
|
||
],
|
||
// A container is pure structure: its content is the template, not a property.
|
||
container: [],
|
||
text: [
|
||
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
|
||
{ key: 'fontSize', label: 'Font size', kind: 'number', default: 32, min: 1, step: 1, decimals: 0 },
|
||
{ key: 'color', label: 'Color', kind: 'color', default: 0xffffff },
|
||
{
|
||
key: 'align',
|
||
label: 'Align',
|
||
kind: 'enum',
|
||
default: 'center',
|
||
options: [
|
||
{ value: 'left', label: 'Left' },
|
||
{ value: 'center', label: 'Center' },
|
||
{ value: 'right', label: 'Right' },
|
||
],
|
||
},
|
||
],
|
||
};
|
||
|
||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||
type,
|
||
isPlaceableType(type) ? [...COMMON_PROPS, ...ASSET_SCHEMAS[type]] : ASSET_SCHEMAS[type],
|
||
]),
|
||
) as Record<AssetType, PropertySchema>;
|
||
|
||
export function schemaFor(type: AssetType): PropertySchema {
|
||
return FULL_SCHEMAS[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);
|
||
}
|
||
|
||
/** 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) : [];
|
||
}
|
||
|
||
/** A shader parameter a sprite *may* have, with the shaders that declare it under that name. */
|
||
export interface ShaderParamRow {
|
||
def: PropertyDef;
|
||
shaders: string[];
|
||
}
|
||
|
||
/**
|
||
* The parameters of every shader in the project, one row per name – which sprite wears which shader
|
||
* is only known at runtime, so the picker offers them all and names where each one comes from.
|
||
*/
|
||
export function allShaderParamRows(project: Project): ShaderParamRow[] {
|
||
const rows = new Map<string, ShaderParamRow>();
|
||
for (const asset of project.assets)
|
||
if (asset.type === 'shader')
|
||
for (const def of shaderParamDefs(asset.graph)) {
|
||
const row = rows.get(def.key);
|
||
if (!row) rows.set(def.key, { def, shaders: [asset.name] });
|
||
else if (!row.shaders.includes(asset.name)) row.shaders.push(asset.name);
|
||
}
|
||
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). */
|
||
export function defaultsFor(type: AssetType): PropertyBag {
|
||
return defaultsOf(FULL_SCHEMAS[type]);
|
||
}
|
||
|
||
export function defaultsOf(schema: PropertySchema): PropertyBag {
|
||
const bag: PropertyBag = {};
|
||
for (const def of schema) bag[def.key] = def.default;
|
||
return bag;
|
||
}
|
||
|
||
export function variableDef(
|
||
key: string,
|
||
kind: VariableKind,
|
||
defaultValue?: PropertyValue,
|
||
fields?: readonly StructFieldDef[],
|
||
): PropertyDef {
|
||
switch (kind) {
|
||
case 'list':
|
||
return { key, label: key, kind, default: (isList(defaultValue) ? defaultValue : []).map(listItemOf) };
|
||
case 'struct': {
|
||
const declared = fields ?? [];
|
||
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
|
||
}
|
||
default:
|
||
return flatDef(key, kind, { value: defaultValue });
|
||
}
|
||
}
|
||
|
||
/** The empty value of a kind, so a field that is not there yet still has one. */
|
||
export function emptyFieldValue(field: StructFieldDef): FieldValue {
|
||
return field.kind === 'list' ? [] : emptyOf(field.kind);
|
||
}
|
||
|
||
/**
|
||
* A struct over exactly the declared fields: a declared field missing from `from` starts empty
|
||
* rather than absent, so a struct value always has the shape its declaration promises.
|
||
*/
|
||
export function structOf(fields: readonly StructFieldDef[], from: PropertyValue | undefined): StructValue {
|
||
const source = isStruct(from) ? from : {};
|
||
const value: Record<string, FieldValue> = {};
|
||
for (const field of fields)
|
||
value[field.key] =
|
||
field.key in source ? coerceFieldValue(source[field.key]!, field) : emptyFieldValue(field);
|
||
return value;
|
||
}
|
||
|
||
export function coerceValue(def: PropertyDef, value: PropertyValue): PropertyValue {
|
||
switch (def.kind) {
|
||
case 'number': {
|
||
let n = coerceAs(value, def.kind, def.default);
|
||
if (def.min !== undefined) n = Math.max(def.min, n);
|
||
if (def.max !== undefined) n = Math.min(def.max, n);
|
||
return n;
|
||
}
|
||
case 'enum': {
|
||
const s = textOf(value);
|
||
return def.options.some((o) => o.value === s) ? s : def.default;
|
||
}
|
||
case 'list':
|
||
// Each item keeps the kind it arrived as; there is no declaration to force it into.
|
||
return isList(value) ? value.map(listItemOf) : def.default;
|
||
case 'struct':
|
||
return structOf(def.fields, value);
|
||
default:
|
||
return coerceAs(value, def.kind, def.default);
|
||
}
|
||
}
|
||
|
||
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
|
||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||
return field.kind === 'list' ? flatListOf(value) : coerceField(value, field.kind);
|
||
}
|