game-maker / suite (push) Successful in 12m1s
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.
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|