Files
feuerwerk/src/core/properties/kinds.ts
T
admin 5671a38044
game-maker / suite (push) Successful in 12m1s
A game that stays silent is only half a game, so a sound is an asset like any other (#45)
Sounds follow the road fonts already paved: the file goes to the server
content-addressed under its sha256, the project keeps a named asset that
points at it, and nothing is ever re-encoded. The format is read from the
file's own signature, not from what the browser claims - the same .m4a
arrives as audio/mp4, audio/x-m4a or as nothing at all.

Playing is two nodes. "Play sound" takes the asset and may raise or lower
its volume; every call is its own voice, so two jumps in a row are two
jumps heard. Its loop input is a choice of three - from the asset, on,
off - because a checkbox has to spend one of its two positions on "not
set" and would lose a case: an asset that loops could then never be
played once.

Below the graph core stays browser-free: NodeContext hands out an asset
id, BehaviorSystem resolves it against the project and folds the asset's
own volume and loop into the call, and only viewport/SoundLibrary knows
about Web Audio. A run without a sink plays nothing instead of throwing.

The CI browser is muted, so the ear is no witness: the debug hook counts
started and stopped voices, and step 34 reads that counter.
2026-09-15 07:54:28 +02:00

206 lines
7.1 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.
/**
* Every kind a value can be, in one table: what it is called, how it is marked, what it travels as
* on a data edge, its empty value, how any value is read as it, and where it may be picked. A new
* kind is a row here – the kind lists, the menus, the tags and the coercions follow from it.
*
* A `tagged` kind is one JSON cannot show: a color is a number, a texture id a text. In a list,
* where nothing is declared, such an item wears its tag (`tagOf`), and a port of that kind carries
* the field that edits it. A `reference` kind names something in the project.
*/
import type { PortType } from '../graph/Graph';
import { booleanOf, colorOf, type PropertyValue, textOf } from '../model/Properties';
interface KindRow {
readonly kind: string;
readonly label: string;
readonly mark?: string;
readonly travels: PortType;
readonly empty?: number | string | boolean;
readonly coerce?: (value: unknown, fallback: never) => number | string | boolean;
readonly tagged?: true;
readonly reference?: true;
readonly variable?: true;
readonly field?: true;
readonly port?: true;
}
const numberOf = (value: unknown, fallback: number): number => {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n : fallback;
};
const text = (value: unknown): string => textOf(value as PropertyValue | undefined);
const bool = (value: unknown): boolean => booleanOf(value);
const color = (value: unknown, fallback: number): number => colorOf(value as PropertyValue, fallback);
export const KINDS = [
{
kind: 'number',
label: 'Number',
mark: '#',
travels: 'number',
empty: 0,
coerce: numberOf,
variable: true,
field: true,
port: true,
},
{
kind: 'text',
label: 'Text',
mark: 'Aa',
travels: 'string',
empty: '',
coerce: text,
variable: true,
field: true,
port: true,
},
{
kind: 'boolean',
label: 'Boolean',
mark: '✓',
travels: 'boolean',
empty: false,
coerce: bool,
variable: true,
field: true,
port: true,
},
{
kind: 'color',
label: 'Color',
mark: '◧',
travels: 'number',
empty: 0,
coerce: color,
tagged: true,
variable: true,
field: true,
port: true,
},
/** A texture, one of its frames or an animation – all one id, which is why it is not an `asset`. */
{
kind: 'texture',
label: 'Texture',
mark: '▦',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** A state machine asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'statemachine',
label: 'State machine',
mark: '◈',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
/** A sound asset's id – a text, so it is tagged in a list like a texture. */
{
kind: 'sound',
label: 'Sound',
mark: '♪',
travels: 'string',
empty: '',
coerce: text,
tagged: true,
reference: true,
variable: true,
field: true,
port: true,
},
{ kind: 'object', label: 'Object', mark: '◇', travels: 'object', port: true },
{ kind: 'list', label: 'List', mark: '≡', travels: 'list', variable: true, port: true },
{ kind: 'struct', label: 'Struct', mark: '⊞', travels: 'struct', variable: true, port: true },
// Schema-only kinds: a setting has them, nothing a user declares does.
{ kind: 'enum', label: 'Choice', travels: 'string', empty: '', coerce: text },
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
{ kind: 'key', label: 'Key', travels: 'string', empty: '', coerce: text },
/** An asset id, `''` = none. Ids are not validated on load, so a reference can dangle. */
{ kind: 'asset', label: 'Asset', travels: 'string', empty: '', coerce: text, reference: true },
] as const satisfies readonly KindRow[];
type Row = (typeof KINDS)[number];
export type Kind = Row['kind'];
type RowOf<K extends Kind> = Extract<Row, { kind: K }>;
type With<Role extends string> = Extract<Row, Record<Role, true>>['kind'];
export type VariableKind = With<'variable'>;
export type FieldKind = With<'field'>;
export type DeclaredPortKind = With<'port'>;
export type TaggedKind = With<'tagged'>;
export type ReferenceKind = With<'reference'>;
/** The kinds whose value is one primitive – the ones with an empty value. */
export type FlatKind = Extract<Row, { empty: unknown }>['kind'];
/** What one item of a list may be – a primitive plus `struct`, so a bundle can sit in a list. */
export type ListItemKind = FieldKind | 'struct';
/** What a field of a struct holds – a primitive plus `list`. No `struct`: that bounds the depth. */
export type StructFieldKind = FieldKind | 'list';
type Widen<T> = T extends number ? number : T extends string ? string : T extends boolean ? boolean : never;
export type ValueOf<K extends FlatKind> = Widen<RowOf<K>['empty']>;
export const KIND = Object.fromEntries(KINDS.map((row) => [row.kind, row])) as { [K in Kind]: RowOf<K> };
const rowOf = (kind: string): Row | undefined => (KIND as Partial<Record<string, Row>>)[kind];
export function isKindWith<Role extends string>(role: Role, kind: string): kind is With<Role> {
return (rowOf(kind) as Partial<Record<string, unknown>> | undefined)?.[role] === true;
}
const kindsWith = <Role extends string>(role: Role): readonly With<Role>[] =>
KINDS.map((row) => row.kind).filter((kind): kind is With<Role> => isKindWith(role, kind));
export const VARIABLE_KINDS = kindsWith('variable');
export const FIELD_KINDS = kindsWith('field');
export const DECLARED_PORT_KINDS = kindsWith('port');
export const LIST_ITEM_KINDS: readonly ListItemKind[] = [...FIELD_KINDS, 'struct'];
export const STRUCT_FIELD_KINDS: readonly StructFieldKind[] = [...FIELD_KINDS, 'list'];
export const isTaggedKind = (kind: string): kind is TaggedKind => isKindWith('tagged', kind);
export function kindLabel(kind: string): string {
return rowOf(kind)?.label ?? kind;
}
export function kindMark(kind: string): string {
const row = rowOf(kind);
return row && 'mark' in row ? row.mark : '?';
}
/** A flat row as its kind's value type – what the table promises and a generic `K` cannot see. */
const flatRow = <K extends FlatKind>(kind: K) =>
KIND[kind] as unknown as {
empty: ValueOf<K>;
coerce: (value: unknown, fallback: ValueOf<K>) => ValueOf<K>;
};
export function emptyOf<K extends FlatKind>(kind: K): ValueOf<K> {
return flatRow(kind).empty;
}
/** `value` read as `kind`, `fallback` where it cannot be. */
export function coerceAs<K extends FlatKind>(value: unknown, kind: K, fallback: ValueOf<K>): ValueOf<K> {
return flatRow(kind).coerce(value, fallback);
}
export function coerceField<K extends FlatKind>(value: unknown, kind: K): ValueOf<K> {
return coerceAs(value, kind, emptyOf(kind));
}
/** The kind a bare value travelling as `type` reads as: the untagged kind of that port type. */
export function plainKindOf(type: string): VariableKind | undefined {
return VARIABLE_KINDS.find((kind) => !isTaggedKind(kind) && KIND[kind].travels === type);
}