Files
feuerwerk/src/core/properties/PropertySchema.ts
T
rootandClaude Opus 5 1135bf4562 Play sprite animations from an animation asset worn through texture
An animation asset is a list of pictures (whole textures or frames) with
frames per second and Loop/Once/Ping-pong playback. A sprite wears it
through its texture property, so the picker, instance overrides and the
Properties node work unchanged; there is no dedicated play node.

A different animation starts from its first frame, the same one set again
runs on, so a graph may choose Run or Idle every frame. The clock lives on
the entity, not in props, which the world writes back as overrides. The
AnimationSystem ticks before behaviors, and On animation finished fires
once when a Once animation reaches its end.

The Animation tab plays a preview above a frame strip: reorder by dragging,
Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer
with pan and Fit. Scene and Container tabs show the first frame; edit mode
has no clock. Right-clicking a texture creates an animation from its frames.

Button now measures its label with the bold font it draws in, which cut
the Animation tab and red dialog buttons short.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 23:52:34 +02:00

356 lines
13 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.
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,
booleanOf,
colorOf,
} from '../model/Properties';
/** The inspector is generated from these entries, so a new property is one line in a schema below. */
export type PropertyDef =
/**
* `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.
*/
| {
key: string;
label: string;
kind: 'number';
default: number;
min?: number;
max?: number;
step?: number;
decimals?: number;
}
| { key: string; label: string; kind: 'text'; default: string }
| { key: string; label: string; kind: 'color'; default: number }
| { key: string; label: string; kind: 'boolean'; default: boolean }
| { key: string; label: string; kind: 'enum'; default: string; options: { value: string; label: string }[] }
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
| { key: string; label: string; kind: 'key'; default: string }
/**
* An asset id (`''` = none); without `assetTypes` the picker offers every placeable asset.
* Ids are not validated on load, so a reference can dangle and readers must cope.
*/
| { key: string; label: string; kind: 'asset'; default: string; assetTypes?: readonly AssetType[] }
/**
* The id of a texture asset or of one of its frames. Its own kind rather than an `asset`
* reference, because a frame is not an asset and the picker has to offer both. May dangle.
*/
| { key: string; label: string; kind: 'texture'; default: string }
/**
* `items` only decides how the inspector reads and writes the line; 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;
items: ListItemKind;
/** The shape of every item, and only for `items: 'struct'` – a list of structs is homogeneous. */
fields?: readonly PrimitiveFieldDef[];
}
/**
* `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[] };
/** The primitives. No `struct` anywhere below: that is the rule that bounds the nesting depth. */
export const FIELD_KINDS = ['number', 'text', 'boolean', 'color'] as const;
export type FieldKind = (typeof FIELD_KINDS)[number];
/** What a list holds – a primitive plus `struct`, so a list of bundles is one declaration. */
export const LIST_ITEM_KINDS = [...FIELD_KINDS, 'struct'] as const;
export type ListItemKind = (typeof LIST_ITEM_KINDS)[number];
/** What a field of a struct property holds – a primitive plus `list`. */
export const STRUCT_FIELD_KINDS = [...FIELD_KINDS, 'list'] as const;
export type StructFieldKind = (typeof STRUCT_FIELD_KINDS)[number];
export interface PrimitiveFieldDef {
key: string;
kind: FieldKind;
}
/** A list held by a struct field; its items are primitives only, which keeps storable shapes finite. */
export interface ListFieldDef {
key: string;
kind: 'list';
items: FieldKind;
}
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef;
export type StructDef = Extract<PropertyDef, { kind: 'struct' }>;
export type PropertySchema = readonly PropertyDef[];
export const VARIABLE_KINDS = ['number', 'boolean', 'text', 'list', 'struct'] as const;
export type VariableKind = (typeof VARIABLE_KINDS)[number];
/**
* 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 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: 'Smooth' },
{ value: 'nearest', label: 'Pixel art' },
],
},
],
// 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 }],
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. */
export function schemaForAsset(asset: Asset): PropertySchema {
return asset.type === 'behavior'
? [...FULL_SCHEMAS.behavior, ...asset.variables]
: FULL_SCHEMAS[asset.type];
}
export function propDef(asset: Asset, key: string): PropertyDef | undefined {
return schemaForAsset(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,
items?: ListItemKind,
fields?: readonly StructFieldDef[],
): PropertyDef {
switch (kind) {
case 'number':
return { key, label: key, kind, default: typeof defaultValue === 'number' ? defaultValue : 0 };
case 'boolean':
return { key, label: key, kind, default: Boolean(defaultValue ?? false) };
case 'text':
return { key, label: key, kind, default: defaultValue === undefined ? '' : textOf(defaultValue) };
case 'list': {
const item = items ?? 'number';
const shape = item === 'struct' ? primitiveFieldsOf(fields ?? []) : undefined;
const raw = isList(defaultValue) ? defaultValue : [];
return {
key,
label: key,
kind,
default: raw.map((entry) => coerceListItem(entry, item, shape)),
items: item,
...(shape ? { fields: shape } : {}),
};
}
case 'struct': {
const declared = fields ?? [];
return { key, label: key, kind, fields: declared, default: structOf(declared, defaultValue) };
}
}
}
export function primitiveFieldsOf(fields: readonly StructFieldDef[]): PrimitiveFieldDef[] {
return fields.filter((f): f is PrimitiveFieldDef => f.kind !== 'list');
}
/** The empty value of a kind, so a field that is not there yet still has one. */
const EMPTY_FIELD: Record<FieldKind, number | string | boolean> = {
number: 0,
text: '',
boolean: false,
color: 0,
};
export function emptyFieldValue(field: StructFieldDef): FieldValue {
return field.kind === 'list' ? [] : EMPTY_FIELD[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 = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(n)) n = 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 'color':
return colorOf(value, def.default);
case 'boolean':
return booleanOf(value);
case 'text':
case 'key':
case 'asset':
case 'texture':
return textOf(value);
case 'enum': {
const s = textOf(value);
return def.options.some((o) => o.value === s) ? s : def.default;
}
case 'list':
return isList(value) ? value.map((item) => coerceListItem(item, def.items, def.fields)) : def.default;
case 'struct':
return structOf(def.fields, value);
}
}
/** `fields` is only read for `items: 'struct'`, where it is the shape every item is forced into. */
export function coerceListItem(
value: PropertyValue,
items: ListItemKind,
fields?: readonly StructFieldDef[],
): number | string | boolean | StructValue {
return items === 'struct' ? structOf(fields ?? [], value) : coerceField(value, items);
}
/** A struct field as its declaration promises. A list field takes only a list – see `ListFieldDef`. */
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
if (field.kind !== 'list') return coerceField(value, field.kind);
return isList(value) ? value.map((item) => coerceField(item, field.items)) : [];
}
/** The primitive half of `coerceListItem`, split off because a struct field is never a struct. */
export function coerceField(value: PropertyValue, items: FieldKind): number | string | boolean {
if (items === 'text') return textOf(value);
if (items === 'boolean') return typeof value === 'boolean' ? value : booleanOf(textOf(value));
if (items === 'color') return colorOf(value, 0);
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : 0;
}