A composition is a timeline of scene objects for cuts and cutscenes. This first step brings the model and the pure evaluation behind it. The tab and placing it in a scene follow in the next steps. - CompositionAsset holds objects, tracks, items and clips. Each item owns exactly one object with overrides and container edits. - The composition is a third object holder next to scene and container. Delete cascade, undo, usages and references work with it. - Duplicating gives the copy new object ids and moves the item ids along with them. - compositionAt() tells which items live at a frame. The top track is drawn in front. - A clip acts on the item of its own track that it overlaps. After its end it holds the last value until the item ends. The animation's play mode is ignored, a clip is one pass. - Clip targets are found by template path, else by name path, like the runtime's animation system. - The root sprite of an item plays its video driven at offset plus time. Children follow unless they are already driven by hand. - fittedLength() gives the video or clip length minus the offset, or 3 s without media. - fps (60) and loop live in their own properties file. Keeping them in PropertySchema would close an import cycle. - The type is listed under Visuals with its own icon. It is not placeable yet. Tests: the gate passed with 300 files and 4625 tests. That is 25 new tests in tests/core/composition.test.ts and 2 icon checks that run for every icon. Four counter-checks (track order, overlap rule, child videos, ids on duplicate) each turned exactly their test red. e2e 04 passed with 67 checks and now counts 33 asset types. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1301 lines
46 KiB
TypeScript
1301 lines
46 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,
|
||
NO_OBJECT,
|
||
objectPlace,
|
||
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 { ALL_LAYERS, COLLIDES_WITH_KEY, COLLISION_LAYER_KEY } from '../model/collisionLayers';
|
||
import { COMPOSITION_FPS_KEY, COMPOSITION_LOOP_KEY, DEFAULT_COMPOSITION_FPS } from './compositions';
|
||
import { INHERIT_LAYER, OVERLAY_KEY, RENDER_LAYER_KEY, SEES_KEY } from '../model/renderLayers';
|
||
import {
|
||
TILE_BOUNCINESS_KEY,
|
||
TILE_COLLISION_SCALE_KEY,
|
||
TILE_COLLISION_SCALE_MAX,
|
||
TILE_COLLISION_SCALE_MIN,
|
||
TILE_FPS_KEY,
|
||
TILE_FRICTION_KEY,
|
||
TILE_KIND_KEY,
|
||
TILE_ONE_WAY_ANGLE_KEY,
|
||
TILE_ONE_WAY_KEY,
|
||
TILE_PLAYBACK_KEY,
|
||
TILE_SORT_OFFSET_KEY,
|
||
TILE_SORT_OFFSET_LIMIT,
|
||
TILE_SOLID_KEY,
|
||
TILE_TRIGGER_KEY,
|
||
} from '../model/tile';
|
||
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||
import {
|
||
AXIS_TYPES,
|
||
JOINT_ANCHOR_X_KEY,
|
||
JOINT_ANCHOR_Y_KEY,
|
||
JOINT_COLLIDE_KEY,
|
||
JOINT_CONNECTED_KEY,
|
||
JOINT_DAMPING_KEY,
|
||
JOINT_LENGTH_KEY,
|
||
JOINT_LIMIT_DISTANCE_KEY,
|
||
JOINT_LIMIT_KEY,
|
||
JOINT_MAX_ANGLE_KEY,
|
||
JOINT_MAX_DISTANCE_KEY,
|
||
JOINT_MAX_FORCE_KEY,
|
||
JOINT_MIN_ANGLE_KEY,
|
||
JOINT_MIN_DISTANCE_KEY,
|
||
JOINT_MOTOR_KEY,
|
||
JOINT_MOTOR_LABELS,
|
||
JOINT_MOTOR_SPEED_KEY,
|
||
JOINT_MOTOR_TARGET_KEY,
|
||
JOINT_MOTORS,
|
||
type JointMotor,
|
||
JOINT_STIFFNESS_KEY,
|
||
JOINT_TYPE_KEY,
|
||
JOINT_TYPE_LABELS,
|
||
JOINT_TYPES,
|
||
type JointType,
|
||
TWO_ANCHOR_TYPES,
|
||
} from '../model/joint';
|
||
import { PALETTE_KEY } from '../model/pixelart';
|
||
import { SLICE_SCALE_KEY } from '../model/texture';
|
||
import {
|
||
VECTOR_CANVAS_DEFAULT,
|
||
VECTOR_CANVAS_MAX,
|
||
VECTOR_HEIGHT_KEY,
|
||
VECTOR_WIDTH_KEY,
|
||
} from '../model/vectorart';
|
||
import type { SceneObject } from '../model/SceneObject';
|
||
import { listItemOf, listOf } from './listItems';
|
||
import { FONT_KEY } from './fonts';
|
||
import {
|
||
BURST_COUNT_KEY,
|
||
EMITTING_KEY,
|
||
FIELD_HEIGHT_KEY,
|
||
FIELD_SHADER_KEY,
|
||
FIELD_SIZE_DEFAULT,
|
||
FIELD_WIDTH_KEY,
|
||
fieldShaderOf,
|
||
LIFETIME_KEY,
|
||
MAX_PARTICLES_KEY,
|
||
MODE_KEY,
|
||
MODE_OPTIONS,
|
||
MOVE_WITH_SOURCE_KEY,
|
||
PARTICLE_COLOR_KEY,
|
||
PARTICLE_SIZE_KEY,
|
||
PARTICLE_TEXTURE_KEY,
|
||
particleParamDefs,
|
||
PREWARM_KEY,
|
||
RATE_KEY,
|
||
TIME_SCALE_KEY,
|
||
} from './particles';
|
||
import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
|
||
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
|
||
import {
|
||
LOOP_KEY as SOUND_LOOP_KEY,
|
||
STREAM_KEY as SOUND_STREAM_KEY,
|
||
VOLUME_KEY as SOUND_VOLUME_KEY,
|
||
} from './sounds';
|
||
import {
|
||
DEFAULT_BARS,
|
||
DEFAULT_GRID,
|
||
GRID_OPTIONS,
|
||
MAX_BARS,
|
||
PATTERN_BARS_KEY,
|
||
PATTERN_GRID_KEY,
|
||
PATTERN_INSTRUMENT_KEY,
|
||
PATTERN_SIGN_KEY,
|
||
PATTERN_TEMPO_KEY,
|
||
} from '../music/pattern';
|
||
import {
|
||
DEFAULT_SONG_GRID,
|
||
SONG_GRID_KEY,
|
||
SONG_GRID_OPTIONS,
|
||
SONG_SIGN_KEY,
|
||
SONG_TEMPO_KEY,
|
||
} from '../music/song';
|
||
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
|
||
import { ASSET_FX_KEY } from './assetFx';
|
||
import { BUS_FX_KEY, BUS_VOLUME_KEY } from './buses';
|
||
import { SPRITE_VIDEO_ROWS, VIDEO_ROWS } from './videos';
|
||
import { findVideo } from '../model/video';
|
||
import { Theme } from '../../ui';
|
||
|
||
/**
|
||
* 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; `none` is what it calls an empty pick,
|
||
* `master` offers the master bus as a choice of its own, `noFx` "None" as one where empty means
|
||
* "from the asset" (#352); `uploadedOnly` keeps drawn pictures out
|
||
* of a texture picker (#186), `moving` lets videos and their clips in (#402). `renderLayers` makes a layer row pick from the project's render layers
|
||
* instead of its collision layers (#302), with "Inherit" as the first choice of a single layer.
|
||
*/
|
||
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[];
|
||
none?: string;
|
||
master?: boolean;
|
||
noFx?: boolean;
|
||
}
|
||
: K extends 'texture'
|
||
? { uploadedOnly?: boolean; moving?: boolean }
|
||
: K extends 'layer' | 'layers'
|
||
? { renderLayers?: boolean }
|
||
: unknown;
|
||
|
||
/**
|
||
* The definition of a flat kind: its default is a value of that kind, as the table says. Not an
|
||
* object's: that default is no value of the kind but its tagged place (`ObjectDef`).
|
||
* `shownWith` names a switch on the same bag; the inspector shows the row only while it is on
|
||
* (see `isRowShown`), everything else – graph, defaults, files – always has it. `shownFor` does the
|
||
* same by a choice: the row is shown while the enum under `key` holds one of `values` (#154); a
|
||
* list of such choices must all hold. `shownWhenSet` names a text or asset row the row belongs to:
|
||
* shown while that one is not empty (#170). `shownForVideo` names a texture row: shown while it
|
||
* holds a video or a clip of one (#404), which only a check that knows the project can tell.
|
||
*/
|
||
export type FlatDef<K extends FlatKind = FlatKind> = {
|
||
[P in Exclude<K, 'object'>]: {
|
||
key: string;
|
||
label: string;
|
||
kind: P;
|
||
default: ValueOf<P>;
|
||
/** What a new asset starts with when it differs from `default`, the value a missing key reads as. */
|
||
initial?: ValueOf<P>;
|
||
shownWith?: string;
|
||
shownFor?: ShownFor | readonly ShownFor[];
|
||
shownWhenSet?: string;
|
||
shownForVideo?: string;
|
||
} & Extras<P>;
|
||
}[Exclude<K, 'object'>];
|
||
|
||
/** The value a new asset starts with, which is also what Reset goes back to. */
|
||
export function initialOf(def: PropertyDef): PropertyValue {
|
||
return 'initial' in def && def.initial !== undefined ? def.initial : def.default;
|
||
}
|
||
|
||
interface ShownFor {
|
||
key: string;
|
||
values: readonly string[];
|
||
}
|
||
|
||
/** Whether the inspector shows the row, given the values of the bag it is on. */
|
||
export function isRowShown(def: PropertyDef, values: PropertyBag, project?: Project): boolean {
|
||
if ('noRow' in def && def.noRow) return false;
|
||
if ('shownForVideo' in def && def.shownForVideo && project)
|
||
if (!findVideo(project, textOf(values[def.shownForVideo] ?? ''))) return false;
|
||
const choices: readonly ShownFor[] =
|
||
'shownFor' in def && def.shownFor ? ('key' in def.shownFor ? [def.shownFor] : def.shownFor) : [];
|
||
if (choices.some(({ key, values: wanted }) => !wanted.includes(textOf(values[key] ?? '')))) return false;
|
||
if ('shownWhenSet' in def && def.shownWhenSet && textOf(values[def.shownWhenSet] ?? '') === '')
|
||
return false;
|
||
return !('shownWith' in def) || def.shownWith === undefined || values[def.shownWith] === true;
|
||
}
|
||
|
||
/** How far a contact normal may lean from a one-way side and still land on it, in degrees (#155, #159). */
|
||
export const ONE_WAY_ANGLE_DEFAULT = 45;
|
||
export const ONE_WAY_ANGLE_MAX = 90;
|
||
|
||
const ONE_WAY_ANGLE_ROW: FlatDef<'number'> = {
|
||
key: 'oneWayAngle',
|
||
label: 'One-way angle',
|
||
kind: 'number',
|
||
default: ONE_WAY_ANGLE_DEFAULT,
|
||
min: 0,
|
||
max: ONE_WAY_ANGLE_MAX,
|
||
step: 5,
|
||
shownWith: 'oneWay',
|
||
};
|
||
|
||
/** The surface a collider or a solid tile (#193) offers, read by the physics engine and by `Move` (#146, #150). */
|
||
const FRICTION_ROW: FlatDef<'number'> = {
|
||
key: 'friction',
|
||
label: 'Friction',
|
||
kind: 'number',
|
||
default: 0.5,
|
||
min: 0,
|
||
step: 0.05,
|
||
};
|
||
const BOUNCINESS_ROW: FlatDef<'number'> = {
|
||
key: 'bounciness',
|
||
label: 'Bounciness',
|
||
kind: 'number',
|
||
default: 0,
|
||
min: 0,
|
||
max: 1,
|
||
step: 0.05,
|
||
};
|
||
|
||
/**
|
||
* Which colliders may meet (#152). Both rows sit on colliders and on tilemaps, so a map can be
|
||
* ground that only some things walk on. The defaults are layer 0 against every layer, which is
|
||
* what a project without any named layer does – nothing collides differently than before.
|
||
*/
|
||
const COLLISION_LAYER_ROWS: readonly FlatDef[] = [
|
||
{ key: COLLISION_LAYER_KEY, label: 'Layer', kind: 'layer', default: 0 },
|
||
{ key: COLLIDES_WITH_KEY, label: 'Collides with', kind: 'layers', default: ALL_LAYERS },
|
||
];
|
||
|
||
const jointRows = (...values: JointType[]) => ({ shownFor: { key: JOINT_TYPE_KEY, values } });
|
||
const JOINT_ENDS = jointRows(...TWO_ANCHOR_TYPES);
|
||
const ONLY_SPRING = jointRows('spring');
|
||
const ONLY_HINGE = jointRows('hinge');
|
||
const ONLY_SLIDER = jointRows('slider');
|
||
const ON_AXIS = jointRows(...AXIS_TYPES);
|
||
const motorRows = (...values: JointMotor[]) => ({
|
||
shownFor: [ON_AXIS.shownFor, { key: JOINT_MOTOR_KEY, values }],
|
||
});
|
||
|
||
/** 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. An `itemType` makes
|
||
* every item a struct of that Struct asset (#254), each marked with it (`ITEM_TYPE_KEY`).
|
||
* `noRow` keeps it out of the inspector: its items are edited somewhere else (#295).
|
||
*/
|
||
| { key: string; label: string; kind: 'list'; default: ListValue; itemType?: string; noRow?: boolean }
|
||
/**
|
||
* `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`). With a `structType` the fields
|
||
* are a copy of that Struct asset's (#253), kept in step by `syncStructTypes`.
|
||
*/
|
||
| {
|
||
key: string;
|
||
label: string;
|
||
kind: 'struct';
|
||
default: StructValue;
|
||
fields: readonly StructFieldDef[];
|
||
structType?: string;
|
||
}
|
||
/** An object variable holds a live handle during a run, and nothing at rest (`NO_OBJECT`). */
|
||
| ObjectDef;
|
||
|
||
export interface ObjectDef {
|
||
key: string;
|
||
label: string;
|
||
kind: 'object';
|
||
default: typeof NO_OBJECT;
|
||
}
|
||
|
||
export { NO_OBJECT };
|
||
|
||
export interface PrimitiveFieldDef {
|
||
key: string;
|
||
kind: FieldKind;
|
||
}
|
||
|
||
/**
|
||
* A list held by a struct field. Its items declare nothing either, and may nest like any list –
|
||
* unless `itemType` names the Struct asset every item is (#254), as on a list variable.
|
||
*/
|
||
export interface ListFieldDef {
|
||
key: string;
|
||
kind: 'list';
|
||
itemType?: string;
|
||
}
|
||
|
||
/** A struct held by a struct field declares its fields like a struct variable does (#239). */
|
||
export interface StructFieldStructDef {
|
||
key: string;
|
||
kind: 'struct';
|
||
fields: readonly StructFieldDef[];
|
||
structType?: string;
|
||
}
|
||
|
||
export type StructFieldDef = PrimitiveFieldDef | ListFieldDef | StructFieldStructDef;
|
||
|
||
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>;
|
||
}
|
||
|
||
export const KEEP_ON_SCENE_LOAD_KEY = 'keepOnSceneLoad';
|
||
|
||
/**
|
||
* 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. A kept one travels
|
||
* through a `Load scene` with everything below it (#165), which only a top-level object can.
|
||
*/
|
||
export const COMMON_PROPS: PropertySchema = [
|
||
{ key: 'enabled', label: 'Enabled', kind: 'boolean', default: true },
|
||
{ key: KEEP_ON_SCENE_LOAD_KEY, label: 'Keep on scene load', kind: 'boolean', default: false },
|
||
];
|
||
|
||
/**
|
||
* How a drawn or grouping object is placed inside its parent, on top of its position. They cascade
|
||
* like a position does (see `properties/transform.ts`), so a child turns and grows with its parent.
|
||
*/
|
||
export const TRANSFORM_PROPS: PropertySchema = [
|
||
{ key: 'scale', label: 'Scale', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||
{ key: 'rotation', label: 'Rotation', kind: 'number', default: 0, step: 1 },
|
||
{ key: 'mirrorX', label: 'Mirror X', kind: 'boolean', default: false },
|
||
{ key: 'mirrorY', label: 'Mirror Y', kind: 'boolean', default: false },
|
||
];
|
||
|
||
/**
|
||
* Cameras and behaviors are placed by the chain but have no look of their own to turn or size. A
|
||
* joint turns for a slider's sake, whose rail is its x axis (#154).
|
||
*/
|
||
const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||
'sprite',
|
||
'text',
|
||
'collider',
|
||
'container',
|
||
'clip',
|
||
'particles',
|
||
'tilemap',
|
||
'joint',
|
||
]);
|
||
|
||
export function hasTransformProps(type: AssetType): boolean {
|
||
return TRANSFORM_TYPES.has(type);
|
||
}
|
||
|
||
const TRANSFORM_KEYS = new Set(TRANSFORM_PROPS.map((d) => d.key));
|
||
|
||
export function isTransformProp(key: string): boolean {
|
||
return TRANSFORM_KEYS.has(key);
|
||
}
|
||
|
||
export const SORT_WITH_PARENT_KEY = 'sortWithParent';
|
||
|
||
/**
|
||
* Whether a child is drawn in its parent's place instead of on its own (#232, see `drawSlots`).
|
||
* A project stored before it reads as off, so it draws as it did; a new asset starts with it on.
|
||
*/
|
||
const SORT_PROPS: PropertySchema = [
|
||
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false, initial: true },
|
||
];
|
||
|
||
/**
|
||
* A container is not drawn, but with the switch on it passes its content on to its own parent
|
||
* (#243). It starts off, so content placed before draws as it did.
|
||
*/
|
||
const CONTAINER_SORT_PROPS: PropertySchema = [
|
||
{ key: SORT_WITH_PARENT_KEY, label: 'Sort with parent', kind: 'boolean', default: false },
|
||
];
|
||
|
||
/** A tilemap has no single place in the draw order, and the other types are not drawn at all. */
|
||
const SORT_TYPES: ReadonlySet<AssetType> = new Set(['sprite', 'text', 'particles', 'container']);
|
||
|
||
function hasSortProps(type: AssetType): boolean {
|
||
return SORT_TYPES.has(type);
|
||
}
|
||
|
||
function sortPropsOf(type: AssetType): PropertySchema {
|
||
if (!hasSortProps(type)) return [];
|
||
return type === 'container' ? CONTAINER_SORT_PROPS : SORT_PROPS;
|
||
}
|
||
|
||
export function isSortProp(key: string): boolean {
|
||
return key === SORT_WITH_PARENT_KEY;
|
||
}
|
||
|
||
/**
|
||
* Which cameras see a drawn object (#302). Without a choice of its own it is on its parent's layer,
|
||
* so a HUD container on `UI` takes everything in it along.
|
||
*/
|
||
const RENDER_LAYER_ROW: FlatDef<'layer'> = {
|
||
key: RENDER_LAYER_KEY,
|
||
label: 'Render layer',
|
||
kind: 'layer',
|
||
default: INHERIT_LAYER,
|
||
renderLayers: true,
|
||
};
|
||
|
||
const RENDER_LAYER_TYPES: ReadonlySet<AssetType> = new Set([
|
||
'sprite',
|
||
'text',
|
||
'particles',
|
||
'container',
|
||
'tilemap',
|
||
]);
|
||
|
||
/** Whether objects of that type are drawn and so sit on a render layer. */
|
||
export function hasRenderLayer(type: AssetType): boolean {
|
||
return RENDER_LAYER_TYPES.has(type);
|
||
}
|
||
|
||
function renderLayerPropsOf(type: AssetType): PropertySchema {
|
||
return hasRenderLayer(type) ? [RENDER_LAYER_ROW] : [];
|
||
}
|
||
|
||
const COMMON_KEYS = new Set(
|
||
[...COMMON_PROPS, ...TRANSFORM_PROPS, ...SORT_PROPS, RENDER_LAYER_ROW].map((d) => d.key),
|
||
);
|
||
|
||
/** The switches, the transform and sort rows: reserved names, shown apart from the type's own properties. */
|
||
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);
|
||
}
|
||
|
||
/** How a picture is magnified: smoothed, or sharp for pixels. Pictures and typefaces share it (#169). */
|
||
function filterRow(smoothByDefault: boolean): PropertyDef {
|
||
return {
|
||
key: 'filter',
|
||
label: 'Filter',
|
||
kind: 'enum',
|
||
default: smoothByDefault ? 'linear' : 'nearest',
|
||
options: [
|
||
{ value: 'linear', label: 'Linear' },
|
||
{ value: 'nearest', label: 'Nearest' },
|
||
],
|
||
};
|
||
}
|
||
|
||
/** How big the borders of a sliced picture are drawn: 2 makes a 16 px border 32 units wide (#96). */
|
||
const SLICE_SCALE_ROW: PropertyDef = {
|
||
key: SLICE_SCALE_KEY,
|
||
label: 'Nine-slice scale',
|
||
kind: 'number',
|
||
default: 1,
|
||
min: 0.01,
|
||
step: 0.1,
|
||
};
|
||
|
||
/** The sound FX a sound, an instrument, a pattern or a song goes through, before its bus (#352). */
|
||
const FX_ROW: PropertyDef = {
|
||
key: ASSET_FX_KEY,
|
||
label: 'Sound FX',
|
||
kind: 'asset',
|
||
default: '',
|
||
assetTypes: ['soundfx'],
|
||
};
|
||
|
||
/** 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: Theme.colors.graphBackground },
|
||
// Pulls on every dynamic rigidbody, in world units per second squared: 980 is Earth at 100 px = 1 m.
|
||
{ key: 'gravityX', label: 'Gravity X', kind: 'number', default: 0, step: 10 },
|
||
{ key: 'gravityY', label: 'Gravity Y', kind: 'number', default: 980, step: 10 },
|
||
],
|
||
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 },
|
||
{ key: SEES_KEY, label: 'Sees', kind: 'layers', default: ALL_LAYERS, renderLayers: true },
|
||
// Off: a candidate for the main camera (#5). On: drawn over the main one, see-through (#302).
|
||
{ key: OVERLAY_KEY, label: 'Overlay', kind: 'boolean', default: false },
|
||
// Draws on the game view's pixel grid at a fixed scale; fit and the scene view stay exact.
|
||
{ key: 'pixelPerfect', label: 'Pixel perfect', kind: 'boolean', default: false },
|
||
// Laid over the camera's whole picture (#299); its parameters follow, as on a sprite.
|
||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||
{
|
||
key: SHADER_RESOLUTION_KEY,
|
||
label: 'Shader resolution',
|
||
kind: 'enum',
|
||
default: 'screen',
|
||
options: SHADER_RESOLUTION_OPTIONS,
|
||
shownWhenSet: SHADER_KEY,
|
||
},
|
||
],
|
||
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 },
|
||
// A video or a clip of one plays on the sprite (#402).
|
||
{ key: 'texture', label: 'Texture', kind: 'texture', default: '', moving: true },
|
||
...SPRITE_VIDEO_ROWS,
|
||
// 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: [filterRow(true), SLICE_SCALE_ROW],
|
||
// The shapes are intrinsic data on the asset (see VectorArtAsset); these three say how big the
|
||
// picture they are drawn onto is and how it is sampled – the same `filter` row a texture has,
|
||
// because a generated picture goes through the same path (#185).
|
||
vectorart: [
|
||
{
|
||
key: VECTOR_WIDTH_KEY,
|
||
label: 'Canvas width',
|
||
kind: 'number',
|
||
default: VECTOR_CANVAS_DEFAULT,
|
||
min: 1,
|
||
max: VECTOR_CANVAS_MAX,
|
||
step: 1,
|
||
decimals: 0,
|
||
},
|
||
{
|
||
key: VECTOR_HEIGHT_KEY,
|
||
label: 'Canvas height',
|
||
kind: 'number',
|
||
default: VECTOR_CANVAS_DEFAULT,
|
||
min: 1,
|
||
max: VECTOR_CANVAS_MAX,
|
||
step: 1,
|
||
decimals: 0,
|
||
},
|
||
filterRow(true),
|
||
SLICE_SCALE_ROW,
|
||
],
|
||
// Layers, frames and the canvas size are intrinsic data on the asset (see PixelArtAsset). A palette
|
||
// is shared, so the asset names one; the filter is the texture's row, sharp by default for pixels.
|
||
pixelart: [
|
||
{ key: PALETTE_KEY, label: 'Palette', kind: 'asset', default: '', assetTypes: ['palette'] },
|
||
filterRow(false),
|
||
SLICE_SCALE_ROW,
|
||
],
|
||
// The colours are intrinsic data on the asset (see PaletteAsset).
|
||
palette: [],
|
||
// The points are intrinsic data on the asset (see CurveAsset).
|
||
curve: [],
|
||
// The root and the rows 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 (hold)' },
|
||
{ value: 'once-reset', label: 'Once (reset)' },
|
||
{ value: 'pingpong', label: 'Ping-pong' },
|
||
],
|
||
},
|
||
],
|
||
// Tracks, items and clips are intrinsic data on the asset (see CompositionAsset). Whole frames
|
||
// per second, so a cut lands on a frame.
|
||
composition: [
|
||
{
|
||
key: COMPOSITION_FPS_KEY,
|
||
label: 'Frames per second',
|
||
kind: 'number',
|
||
default: DEFAULT_COMPOSITION_FPS,
|
||
min: 1,
|
||
max: 120,
|
||
step: 1,
|
||
decimals: 0,
|
||
},
|
||
{ key: COMPOSITION_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||
],
|
||
// The pictures are intrinsic data on the asset (see TileAsset); these three say what the
|
||
// block holds and how its steps run. There is no tile size: the block's width in tiles is known
|
||
// from the kind, so the picture already says it (see `readTile`). A new tile is a plain one; the
|
||
// autotile blocks are chosen on purpose (#131).
|
||
tile: [
|
||
{
|
||
key: TILE_KIND_KEY,
|
||
label: 'Kind',
|
||
kind: 'enum',
|
||
default: 'none',
|
||
options: [
|
||
{ value: 'none', label: 'None (1 × 1)' },
|
||
{ value: 'floor', label: 'Floor (2 × 3)' },
|
||
{ value: 'wall', label: 'Wall (2 × 2)' },
|
||
{ value: 'terrain', label: 'Terrain (6 × 4)' },
|
||
],
|
||
},
|
||
{ key: TILE_FPS_KEY, label: 'Frames per second', kind: 'number', default: 4, min: 0.1, max: 120 },
|
||
{
|
||
key: TILE_PLAYBACK_KEY,
|
||
label: 'Playback',
|
||
kind: 'enum',
|
||
default: 'loop',
|
||
options: [
|
||
{ value: 'loop', label: 'Loop' },
|
||
{ value: 'pingpong', label: 'Ping-pong' },
|
||
],
|
||
},
|
||
// Solid is what the tile *is* – water, a wall – so it holds wherever the tile lies (#132).
|
||
{ key: TILE_SOLID_KEY, label: 'Solid', kind: 'boolean', default: false },
|
||
// A trigger tile blocks nothing and tells the collision events what lies on it (#160).
|
||
{ key: TILE_TRIGGER_KEY, label: 'Trigger (reports, never blocks)', kind: 'boolean', default: false },
|
||
// A solid tile that only blocks from above, like a platform one jumps through (#155).
|
||
{ key: TILE_ONE_WAY_KEY, label: 'One-way (from above)', kind: 'boolean', default: false },
|
||
{ ...ONE_WAY_ANGLE_ROW, key: TILE_ONE_WAY_ANGLE_KEY, shownWith: TILE_ONE_WAY_KEY },
|
||
// No density: a map never moves, so what its cells would weigh counts for nothing.
|
||
{ ...FRICTION_ROW, key: TILE_FRICTION_KEY, shownWith: TILE_SOLID_KEY },
|
||
{ ...BOUNCINESS_ROW, key: TILE_BOUNCINESS_KEY, shownWith: TILE_SOLID_KEY },
|
||
// Below 1 a cell blocks less than its square, above 1 more – but only on the sides without a
|
||
// blocking neighbour, so a wall of such cells stays closed (#140).
|
||
{
|
||
key: TILE_COLLISION_SCALE_KEY,
|
||
label: 'Collision scale',
|
||
kind: 'number',
|
||
default: 1,
|
||
min: TILE_COLLISION_SCALE_MIN,
|
||
max: TILE_COLLISION_SCALE_MAX,
|
||
step: 0.05,
|
||
},
|
||
{
|
||
key: TILE_SORT_OFFSET_KEY,
|
||
label: 'Sort offset',
|
||
kind: 'number',
|
||
default: 0,
|
||
min: -TILE_SORT_OFFSET_LIMIT,
|
||
max: TILE_SORT_OFFSET_LIMIT,
|
||
step: 1,
|
||
decimals: 0,
|
||
},
|
||
],
|
||
// The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say
|
||
// how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto
|
||
// that cell, so one tilemap can mix tiles drawn at different resolutions.
|
||
tilemap: [
|
||
{
|
||
key: TILEMAP_COLUMNS_KEY,
|
||
label: 'Columns',
|
||
kind: 'number',
|
||
default: 20,
|
||
min: 1,
|
||
max: TILEMAP_MAX_CELLS,
|
||
step: 1,
|
||
},
|
||
{
|
||
key: TILEMAP_ROWS_KEY,
|
||
label: 'Rows',
|
||
kind: 'number',
|
||
default: 15,
|
||
min: 1,
|
||
max: TILEMAP_MAX_CELLS,
|
||
step: 1,
|
||
},
|
||
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
|
||
...COLLISION_LAYER_ROWS,
|
||
],
|
||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||
behavior: [
|
||
{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false },
|
||
{ key: 'runWhilePaused', label: 'Run while paused', kind: 'boolean', default: false },
|
||
],
|
||
// Every row is declared on the asset itself – see `schemaForBag`.
|
||
variables: [],
|
||
struct: [],
|
||
// 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: [],
|
||
shaderfunction: [],
|
||
// The typeface itself is intrinsic data on the asset (see FontAsset), not a property. Nearest draws
|
||
// a pixel font a pixel per unit, magnified sharp.
|
||
font: [filterRow(true)],
|
||
// The file is intrinsic data (see SoundAsset); volume and loop say how it is played by default, and
|
||
// a `Play sound` node scales the one and can add to the other.
|
||
// The sound is the whole file; a clip of it is a stretch with a cut of its own (#357, `CLIP_ROWS`).
|
||
sound: [
|
||
{ key: SOUND_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||
{ key: SOUND_LOOP_KEY, label: 'Loop', kind: 'boolean', default: false },
|
||
// A long one is streamed from a copy as Opus rather than decoded whole (#349).
|
||
{ key: SOUND_STREAM_KEY, label: 'Stream', kind: 'boolean', default: false },
|
||
FX_ROW,
|
||
],
|
||
// The effects are the asset's graph (see SoundFxAsset), not a property.
|
||
soundfx: [],
|
||
// What a note sounds like is the asset's graph (see InstrumentAsset), not a property.
|
||
instrument: [FX_ROW],
|
||
// The notes and the loop are intrinsic data on the asset (see PatternAsset). The meter and the
|
||
// tempo are for drawing and hearing it on its own; a song plays it at its own tempo (#334).
|
||
pattern: [
|
||
{
|
||
key: PATTERN_INSTRUMENT_KEY,
|
||
label: 'Instrument',
|
||
kind: 'asset',
|
||
default: '',
|
||
assetTypes: ['instrument'],
|
||
},
|
||
{
|
||
key: PATTERN_BARS_KEY,
|
||
label: 'Length (bars)',
|
||
kind: 'number',
|
||
default: DEFAULT_BARS,
|
||
min: 1,
|
||
max: MAX_BARS,
|
||
step: 1,
|
||
decimals: 0,
|
||
},
|
||
{ key: PATTERN_GRID_KEY, label: 'Grid', kind: 'enum', default: DEFAULT_GRID, options: GRID_OPTIONS },
|
||
{ key: PATTERN_SIGN_KEY, label: 'Sign', kind: 'enum', default: DEFAULT_SIGN, options: SIGN_OPTIONS },
|
||
{
|
||
key: PATTERN_TEMPO_KEY,
|
||
label: 'Tempo (BPM)',
|
||
kind: 'number',
|
||
default: DEFAULT_TEMPO,
|
||
min: MIN_TEMPO,
|
||
max: MAX_TEMPO,
|
||
step: 1,
|
||
},
|
||
FX_ROW,
|
||
],
|
||
// The tracks, items and loop are intrinsic data on the asset (see SongAsset).
|
||
song: [
|
||
{
|
||
key: SONG_TEMPO_KEY,
|
||
label: 'Tempo (BPM)',
|
||
kind: 'number',
|
||
default: DEFAULT_TEMPO,
|
||
min: MIN_TEMPO,
|
||
max: MAX_TEMPO,
|
||
step: 1,
|
||
},
|
||
{ key: SONG_SIGN_KEY, label: 'Sign', kind: 'enum', default: DEFAULT_SIGN, options: SIGN_OPTIONS },
|
||
{
|
||
key: SONG_GRID_KEY,
|
||
label: 'Grid',
|
||
kind: 'enum',
|
||
default: DEFAULT_SONG_GRID,
|
||
options: SONG_GRID_OPTIONS,
|
||
},
|
||
FX_ROW,
|
||
],
|
||
// Where sounds are mixed (#325): how loud and through which sound FX. Where it goes on is the
|
||
// master routing's (#351).
|
||
video: [...VIDEO_ROWS],
|
||
bus: [
|
||
{ key: BUS_VOLUME_KEY, label: 'Volume', kind: 'number', default: 1, min: 0, max: 1, step: 0.05 },
|
||
{ key: BUS_FX_KEY, label: 'Sound FX', kind: 'asset', default: '', assetTypes: ['soundfx'] },
|
||
],
|
||
// How many particles and for how long; what one of them does is the asset's graph, whose Property
|
||
// nodes add their rows after these – see `schemaForBag`.
|
||
particles: [
|
||
{ key: EMITTING_KEY, label: 'Emitting', kind: 'boolean', default: true },
|
||
{ key: MODE_KEY, label: 'Mode', kind: 'enum', default: 'continuous', options: MODE_OPTIONS },
|
||
{ key: RATE_KEY, label: 'Rate', kind: 'number', default: 20, min: 0, step: 1 },
|
||
{ key: BURST_COUNT_KEY, label: 'Burst count', kind: 'number', default: 20, min: 0, step: 1, decimals: 0 },
|
||
{ key: LIFETIME_KEY, label: 'Lifetime', kind: 'number', default: 1, min: 0.01, step: 0.1 },
|
||
{ key: TIME_SCALE_KEY, label: 'Time scale', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||
{ key: PREWARM_KEY, label: 'Prewarm', kind: 'boolean', default: false },
|
||
{
|
||
key: MAX_PARTICLES_KEY,
|
||
label: 'Max particles',
|
||
kind: 'number',
|
||
default: 500,
|
||
min: 1,
|
||
step: 50,
|
||
decimals: 0,
|
||
},
|
||
{ key: PARTICLE_SIZE_KEY, label: 'Size', kind: 'number', default: 16, min: 0.1, step: 1 },
|
||
{ key: PARTICLE_TEXTURE_KEY, label: 'Texture', kind: 'texture', default: '' },
|
||
{ key: PARTICLE_COLOR_KEY, label: 'Color', kind: 'color', default: 0xffffff },
|
||
{ key: MOVE_WITH_SOURCE_KEY, label: 'Move with source', kind: 'boolean', default: false },
|
||
// Worn by every particle of the source, not by the field as a whole – see `schemaForBag`.
|
||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||
// Laid over the whole field at once (#170), and the frame it covers: shown only with one set.
|
||
{ key: FIELD_SHADER_KEY, label: 'Field shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||
{
|
||
key: FIELD_WIDTH_KEY,
|
||
label: 'Field width',
|
||
kind: 'number',
|
||
default: FIELD_SIZE_DEFAULT,
|
||
min: 1,
|
||
step: 8,
|
||
shownWhenSet: FIELD_SHADER_KEY,
|
||
},
|
||
{
|
||
key: FIELD_HEIGHT_KEY,
|
||
label: 'Field height',
|
||
kind: 'number',
|
||
default: FIELD_SIZE_DEFAULT,
|
||
min: 1,
|
||
step: 8,
|
||
shownWhenSet: FIELD_SHADER_KEY,
|
||
},
|
||
],
|
||
collider: [
|
||
{
|
||
key: 'shape',
|
||
label: 'Shape',
|
||
kind: 'enum',
|
||
default: 'box',
|
||
options: [
|
||
{ value: 'box', label: 'Box' },
|
||
{ value: 'circle', label: 'Circle' },
|
||
{ value: 'capsule', label: 'Capsule' },
|
||
{ value: 'polygon', label: 'Polygon' },
|
||
{ value: 'frame', label: 'Frame collision' },
|
||
],
|
||
},
|
||
// A box and a capsule fill width × height; the capsule is rounded along the longer side.
|
||
{ 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 },
|
||
// A polygon's corners in the collider's own units, edited on the outline in the scene (#151) –
|
||
// and only there: the inspector does not list them (#295).
|
||
{
|
||
key: 'points',
|
||
label: 'Points',
|
||
kind: 'list',
|
||
noRow: true,
|
||
default: [
|
||
{ x: 0, y: -50 },
|
||
{ x: 50, y: 50 },
|
||
{ x: -50, y: 50 },
|
||
],
|
||
},
|
||
// The frame whose collision outlines (#141) the "Frame collision" shape wears, kept linked (#193).
|
||
{ key: 'frame', label: 'Frame', kind: 'texture', default: '' },
|
||
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
|
||
// The material, read by the physics engine where a rigidbody takes part (#146).
|
||
FRICTION_ROW,
|
||
BOUNCINESS_ROW,
|
||
{ key: 'density', label: 'Density', kind: 'number', default: 1, min: 0, step: 0.1 },
|
||
// Blocks only what comes from that side, turned with the object; the rest passes through (#155).
|
||
{ key: 'oneWay', label: 'One-way', kind: 'boolean', default: false },
|
||
{
|
||
key: 'oneWayDirection',
|
||
label: 'Blocks from',
|
||
kind: 'enum',
|
||
default: 'up',
|
||
options: [
|
||
{ value: 'up', label: 'Above' },
|
||
{ value: 'down', label: 'Below' },
|
||
{ value: 'left', label: 'Left' },
|
||
{ value: 'right', label: 'Right' },
|
||
],
|
||
shownWith: 'oneWay',
|
||
},
|
||
// How far a contact may lean from that side and still land (#159).
|
||
ONE_WAY_ANGLE_ROW,
|
||
...COLLISION_LAYER_ROWS,
|
||
],
|
||
rigidbody: [
|
||
{
|
||
key: 'bodyType',
|
||
label: 'Body type',
|
||
kind: 'enum',
|
||
default: 'dynamic',
|
||
options: [
|
||
{ value: 'dynamic', label: 'Dynamic' },
|
||
{ value: 'static', label: 'Static' },
|
||
{ value: 'kinematic', label: 'Kinematic' },
|
||
],
|
||
},
|
||
// 0 leaves the mass to the colliders' density and size; anything else is the mass itself.
|
||
{ key: 'mass', label: 'Mass (0 = auto)', kind: 'number', default: 0, min: 0, step: 0.1 },
|
||
{ key: 'gravityScale', label: 'Gravity scale', kind: 'number', default: 1, step: 0.1 },
|
||
{ key: 'linearDamping', label: 'Linear damping', kind: 'number', default: 0, min: 0, step: 0.1 },
|
||
{ key: 'angularDamping', label: 'Angular damping', kind: 'number', default: 0, min: 0, step: 0.1 },
|
||
{ key: 'fixedRotation', label: 'Fixed rotation', kind: 'boolean', default: false },
|
||
{ key: 'ccd', label: 'Continuous (CCD)', kind: 'boolean', default: false },
|
||
],
|
||
// What it ties its object to, and how (#154). Lengths are world units, angles degrees counted
|
||
// from how the two were placed, stiffness a force per 100 units as the engine weighs things.
|
||
joint: [
|
||
{
|
||
key: JOINT_TYPE_KEY,
|
||
label: 'Type',
|
||
kind: 'enum',
|
||
default: 'hinge',
|
||
options: JOINT_TYPES.map((value) => ({ value, label: JOINT_TYPE_LABELS[value] })),
|
||
},
|
||
// Empty is a point of the scene that never moves.
|
||
{ key: JOINT_CONNECTED_KEY, label: 'Connected object', kind: 'object', default: NO_OBJECT },
|
||
// Where a spring or a rope ends on the other object, in that object's own units – or, with no
|
||
// object, in the scene's. A hinge and a fixed joint have one point only: where the joint sits.
|
||
{
|
||
key: JOINT_ANCHOR_X_KEY,
|
||
label: 'Connected anchor X',
|
||
kind: 'number',
|
||
default: 0,
|
||
step: 1,
|
||
...JOINT_ENDS,
|
||
},
|
||
{
|
||
key: JOINT_ANCHOR_Y_KEY,
|
||
label: 'Connected anchor Y',
|
||
kind: 'number',
|
||
default: 0,
|
||
step: 1,
|
||
...JOINT_ENDS,
|
||
},
|
||
{
|
||
key: JOINT_LENGTH_KEY,
|
||
label: 'Length (0 = as placed)',
|
||
kind: 'number',
|
||
default: 0,
|
||
min: 0,
|
||
step: 1,
|
||
...JOINT_ENDS,
|
||
},
|
||
{
|
||
key: JOINT_STIFFNESS_KEY,
|
||
label: 'Stiffness',
|
||
kind: 'number',
|
||
default: 50,
|
||
min: 0,
|
||
step: 1,
|
||
...ONLY_SPRING,
|
||
},
|
||
{
|
||
key: JOINT_DAMPING_KEY,
|
||
label: 'Damping',
|
||
kind: 'number',
|
||
default: 1,
|
||
min: 0,
|
||
step: 0.1,
|
||
...ONLY_SPRING,
|
||
},
|
||
{ key: JOINT_LIMIT_KEY, label: 'Limit angle', kind: 'boolean', default: false, ...ONLY_HINGE },
|
||
{
|
||
key: JOINT_MIN_ANGLE_KEY,
|
||
label: 'Min angle',
|
||
kind: 'number',
|
||
default: -45,
|
||
max: 0,
|
||
step: 1,
|
||
shownWith: JOINT_LIMIT_KEY,
|
||
...ONLY_HINGE,
|
||
},
|
||
{
|
||
key: JOINT_MAX_ANGLE_KEY,
|
||
label: 'Max angle',
|
||
kind: 'number',
|
||
default: 45,
|
||
min: 0,
|
||
step: 1,
|
||
shownWith: JOINT_LIMIT_KEY,
|
||
...ONLY_HINGE,
|
||
},
|
||
// A slider glides along the joint's own x axis: turning the joint turns the rail.
|
||
{
|
||
key: JOINT_LIMIT_DISTANCE_KEY,
|
||
label: 'Limit distance',
|
||
kind: 'boolean',
|
||
default: false,
|
||
...ONLY_SLIDER,
|
||
},
|
||
{
|
||
key: JOINT_MIN_DISTANCE_KEY,
|
||
label: 'Min distance',
|
||
kind: 'number',
|
||
default: -100,
|
||
max: 0,
|
||
step: 1,
|
||
shownWith: JOINT_LIMIT_DISTANCE_KEY,
|
||
...ONLY_SLIDER,
|
||
},
|
||
{
|
||
key: JOINT_MAX_DISTANCE_KEY,
|
||
label: 'Max distance',
|
||
kind: 'number',
|
||
default: 100,
|
||
min: 0,
|
||
step: 1,
|
||
shownWith: JOINT_LIMIT_DISTANCE_KEY,
|
||
...ONLY_SLIDER,
|
||
},
|
||
// Degrees for a hinge, world units for a slider, both counted from how it was placed.
|
||
{
|
||
key: JOINT_MOTOR_KEY,
|
||
label: 'Motor',
|
||
kind: 'enum',
|
||
default: 'off',
|
||
options: JOINT_MOTORS.map((value) => ({ value, label: JOINT_MOTOR_LABELS[value] })),
|
||
...ON_AXIS,
|
||
},
|
||
{
|
||
key: JOINT_MOTOR_SPEED_KEY,
|
||
label: 'Motor speed',
|
||
kind: 'number',
|
||
default: 90,
|
||
step: 1,
|
||
...motorRows('speed', 'target'),
|
||
},
|
||
{
|
||
key: JOINT_MOTOR_TARGET_KEY,
|
||
label: 'Motor target',
|
||
kind: 'number',
|
||
default: 0,
|
||
step: 1,
|
||
...motorRows('target'),
|
||
},
|
||
{
|
||
key: JOINT_MAX_FORCE_KEY,
|
||
label: 'Max force (0 = no limit)',
|
||
kind: 'number',
|
||
default: 0,
|
||
min: 0,
|
||
step: 10,
|
||
...motorRows('speed', 'target'),
|
||
},
|
||
{ key: JOINT_COLLIDE_KEY, label: 'Collide with connected', kind: 'boolean', default: false },
|
||
],
|
||
// A container is pure structure: its content is the template, not a property.
|
||
container: [],
|
||
// What its children are cut to (#260). Units, not pixels: it turns and grows with its parent.
|
||
clip: [
|
||
{ key: 'width', label: 'Width', kind: 'number', default: 200, min: 1, step: 1 },
|
||
{ key: 'height', label: 'Height', kind: 'number', default: 150, min: 1, step: 1 },
|
||
],
|
||
text: [
|
||
{ key: 'text', label: 'Text', kind: 'text', default: 'Text' },
|
||
// Empty is the editor's own typeface, the one every text was drawn in before fonts existed.
|
||
{ key: FONT_KEY, label: 'Font', kind: 'asset', default: '', assetTypes: ['font'] },
|
||
{ 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' },
|
||
],
|
||
},
|
||
// Worn by the rectangle of the rendered text, as by a sprite's picture (#169).
|
||
{ key: SHADER_KEY, label: 'Shader', kind: 'asset', default: '', assetTypes: ['shader'] },
|
||
],
|
||
};
|
||
|
||
const FULL_SCHEMAS: Record<AssetType, PropertySchema> = Object.fromEntries(
|
||
(Object.keys(ASSET_SCHEMAS) as AssetType[]).map((type) => [
|
||
type,
|
||
isPlaceableType(type)
|
||
? [
|
||
...COMMON_PROPS,
|
||
...(hasTransformProps(type) ? TRANSFORM_PROPS : []),
|
||
...sortPropsOf(type),
|
||
...renderLayerPropsOf(type),
|
||
...ASSET_SCHEMAS[type],
|
||
]
|
||
: ASSET_SCHEMAS[type],
|
||
]),
|
||
) as Record<AssetType, PropertySchema>;
|
||
|
||
export function schemaFor(type: AssetType): PropertySchema {
|
||
return FULL_SCHEMAS[type];
|
||
}
|
||
|
||
const SPRITE_KEYS = new Set(FULL_SCHEMAS.sprite.map((d) => d.key));
|
||
|
||
/**
|
||
* A row every sprite has already: the switch, the transform and the sprite's own. A shader
|
||
* parameter under one of these names would be a second row on the same bag key, and its uniform
|
||
* would read the sprite's value instead of the default the Property node declares (#63).
|
||
*/
|
||
export function isSpriteProp(key: string): boolean {
|
||
return SPRITE_KEYS.has(key);
|
||
}
|
||
|
||
/**
|
||
* 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: an object overriding its shader has that shader's parameters. */
|
||
export function schemaForObject(project: Project, asset: Asset, object: SceneObject): PropertySchema {
|
||
const bag: PropertyBag = {};
|
||
for (const key of [SHADER_KEY, FIELD_SHADER_KEY]) {
|
||
const value = object.overrides[key] ?? asset.props[key];
|
||
if (value !== undefined) bag[key] = value;
|
||
}
|
||
return schemaForBag(project, asset, bag);
|
||
}
|
||
|
||
function schemaForBag(project: Project, asset: Asset, bag: PropertyBag): PropertySchema {
|
||
if (asset.type === 'behavior') return [...FULL_SCHEMAS.behavior, ...asset.variables];
|
||
// Nothing but declarations: the starting values live in `props` under the very same keys.
|
||
if (asset.type === 'variables' || asset.type === 'struct') return asset.variables;
|
||
if (asset.type === 'sprite') return [...FULL_SCHEMAS.sprite, ...shaderParamRows(project, shaderOf(bag))];
|
||
// Unlike a sprite's shader, a source's graph is its own asset's, so no id has to be looked up.
|
||
if (asset.type === 'particles')
|
||
return sourceRows(
|
||
[...FULL_SCHEMAS.particles, ...particleParamDefs(asset.graph)],
|
||
shaderParamRows(project, shaderOf(bag)),
|
||
shaderParamRows(project, fieldShaderOf(bag)),
|
||
);
|
||
if (asset.type === 'text' || asset.type === 'camera')
|
||
return [
|
||
...FULL_SCHEMAS[asset.type],
|
||
...rowsNotClaimedBy(shaderParamRows(project, shaderOf(bag)), FULL_SCHEMAS[asset.type]),
|
||
];
|
||
return FULL_SCHEMAS[asset.type];
|
||
}
|
||
|
||
/**
|
||
* A source draws its rows from two graphs, so one name can arrive twice: `speed` on the particle
|
||
* graph and `speed` on the shader it wears. The source's own row wins and the uniform keeps its
|
||
* declared default – the shader cannot be blamed for the collision, the same one is legitimate on a
|
||
* sprite, so nothing is rejected on its side. A text's own rows (`fontSize`, `align` …) win the same way.
|
||
*/
|
||
export function rowsNotClaimedBy(rows: readonly PropertyDef[], taken: readonly PropertyDef[]): PropertyDef[] {
|
||
const claimed = new Set(taken.map((d) => d.key));
|
||
return rows.filter((row) => !claimed.has(row.key));
|
||
}
|
||
|
||
/**
|
||
* A source's rows: its own, then its particles' shader's, then its field shader's (#170). A name
|
||
* both shaders declare is one row, and both uniforms read it.
|
||
*/
|
||
export function sourceRows(
|
||
own: readonly PropertyDef[],
|
||
shaderRows: readonly PropertyDef[],
|
||
fieldRows: readonly PropertyDef[],
|
||
): PropertyDef[] {
|
||
const worn = [...own, ...rowsNotClaimedBy(shaderRows, own)];
|
||
return [...worn, ...rowsNotClaimedBy(fieldRows, worn)];
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** The bag a new asset of the type is created with. */
|
||
export function initialPropsFor(type: AssetType): PropertyBag {
|
||
const bag: PropertyBag = {};
|
||
for (const def of FULL_SCHEMAS[type]) bag[def.key] = initialOf(def);
|
||
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) };
|
||
}
|
||
case 'object':
|
||
return { key, label: key, kind, default: NO_OBJECT };
|
||
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 {
|
||
if (field.kind === 'object') return NO_OBJECT;
|
||
if (field.kind === 'struct') return structOf(field.fields, undefined);
|
||
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);
|
||
case 'object':
|
||
return objectPlace(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`),
|
||
* a struct field is brought into its declared shape, however deep it sits.
|
||
*/
|
||
export function coerceFieldValue(value: PropertyValue, field: StructFieldDef): FieldValue {
|
||
if (field.kind === 'object') return objectPlace(value);
|
||
if (field.kind === 'struct') return structOf(field.fields, value);
|
||
return field.kind === 'list' ? listOf(value) : coerceField(value, field.kind);
|
||
}
|