Files
feuerwerk/src/editor/inspector/fieldFactory.ts
T
admin 1e9d21cd5d
game-maker / suite (push) Failing after 11m6s
A game that is more than one scene needs a way from one to the next, so the graph can load one (#39)
Load scene sets a flag on the World like Stop and Restart do; the swap
happens after the tick, and the last wish of a tick wins. Exactly one
thing crosses over: the SharedState, so global variables and global
machines keep what they know while everything local is built anew.
Loading the scene that is already running rebuilds it, which is what
"reset the level" means when the score must survive.

A run starts either from the start scene (a new project field, set from
the asset panel and marked with an arrow in the list) or from whatever
scene is open. The toggle next to Play says which question is asked and
remembers the answer in localStorage: it is a way of working, not part
of the game.

Sounds no longer end because a scene ends. A voice belongs to the
behavior that started it and stops right after On destroy, so the scene
swap is no longer a special case at all. For the enemy that plays its
explosion and destroys itself in the same breath there is now a
detached input on Play sound.

The scene selector stays usable while a run plays, because a double
click in the asset panel already did the same thing unguarded. It moves
what the editor looks at, not what the run plays.
2026-09-15 13:24:26 +02:00

437 lines
15 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 { isList, isStruct, type PropertyValue, textOf } from '../../core/model/Properties';
import type { Project } from '../../core/model/Project';
import {
assetChoiceLabel,
assetRefChoices,
assetRefLabel,
NO_ASSET_LABEL,
} from '../../core/properties/assetRef';
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
import {
emptyFieldValue,
flatDef,
type PropertyDef,
type StructFieldDef,
} from '../../core/properties/PropertySchema';
import { NO_TEXTURE_LABEL, textureRefChoices, textureRefLabel } from '../../core/properties/textureRef';
import {
Button,
Checkbox,
ColorField,
Dropdown,
HStack,
Label,
Menu,
type MenuItem,
NumberField,
Spacer,
TextField,
Theme,
ui,
VStack,
type Widget,
} from '../../ui';
/**
* What a field needs beyond its own value: the project, to resolve an asset reference to a name
* and to list what the picker may offer. A getter rather than the project itself – loading and
* entering play mode replace that object.
*/
export interface FieldContext {
project(): Project;
}
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
onInput: (value: PropertyValue) => void;
/** Final value – commit as a command. */
onChange: (value: PropertyValue) => void;
/** Sends a list to the List tab; only the caller knows where the value it edits lives. */
openList?: () => void;
/**
* Handlers for one field of a struct. Writing a field means writing the whole bundle, so only
* the caller – which knows where that bundle lives – can build them. Without them a struct row
* can only show what it holds.
*/
structFieldHandlers?: (field: StructFieldDef) => FieldHandlers;
}
export interface FieldRow {
row: Widget;
/** Push a model value into the widget (no events fired). */
setValue(value: PropertyValue): void;
/**
* Colour the label as deviating from its base value (an override on scene objects, a
* non-default value on assets) and re-evaluate which actions currently apply.
*/
setModified(modified: boolean): void;
}
/**
* The inspector's one column grid. A variable row is mark + name, a plain row a label spanning
* both, so every value starts at the same x. The mark column fits `≡Aa` (24 px measured) with air.
*/
export const MARK_WIDTH = 28;
export const NAME_WIDTH = 90;
export const FIELD_INDENT = 12;
const FIELD_LABEL_WIDTH = MARK_WIDTH + Theme.gap + NAME_WIDTH;
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
export interface FieldAction {
label: string;
onSelect: () => void;
}
/**
* The actions available *right now* – asked again on every refresh and on every click.
* Anything that would not change the value is left out, so a row never offers a dead entry.
*/
export type FieldActions = () => FieldAction[];
export const actionIf = (offered: unknown, label: string, onSelect: () => void): FieldAction[] =>
offered ? [{ label, onSelect }] : [];
/** One trailing button of a row: its glyph, and what it currently offers. */
export interface FieldActionGroup {
glyph: string;
available: FieldActions;
}
export interface RowActions {
reset?: FieldActions;
apply?: FieldActions;
/** Row-specific buttons, drawn left of ↑/↺ – they act on the value rather than on its origin. */
extra?: readonly FieldActionGroup[];
}
/** A row starting with the fixed-width, muted label column. */
export function labelledRow(text: string): { row: HStack; label: Label } {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label(text, { style: 'muted' });
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
return { row, label };
}
/** Adds the trailing ↺/↑/extra buttons and returns their refresh; `buildVariable` uses it for its first row too. */
export function attachRowActions(row: HStack, rowActions: RowActions): () => void {
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
const addActions = (glyph: string, available: FieldActions): void => {
// A floor, not a width: the same button also carries the two-glyph menu form "↺ ▾".
const button = new Button(glyph, { variant: 'ghost', minWidth: Theme.controlHeight });
// Asked again on click: the menu must not act on a state that has moved on since the
// last refresh (an undo between refreshes, a value edited in the viewport).
button.onClick = () => {
const items = available();
if (items.length === 0) return;
if (items.length === 1) return items[0]!.onSelect();
new Menu(items.map((a) => ({ label: a.label, onSelect: a.onSelect }))).openBelow(button);
};
button.visible = false;
actions.push({ button, glyph, available });
row.add(button);
};
for (const group of rowActions.extra ?? []) addActions(group.glyph, group.available);
if (rowActions.apply) addActions('↑', rowActions.apply);
if (rowActions.reset) addActions('↺', rowActions.reset);
const refreshActions = (): void => {
let changed = false;
for (const { button, glyph, available } of actions) {
const count = available().length;
const text = count > 1 ? `${glyph} ▾` : glyph;
const visible = count > 0;
if (button.visible !== visible) {
button.visible = visible;
changed = true;
}
if (visible && button.text !== text) {
button.setText(text);
changed = true;
}
}
if (changed) row.relayout();
};
refreshActions();
return refreshActions;
}
/**
* Maps one schema entry to a labelled row. Action groups are asked again on each refresh: none means
* no button, one a button, several a menu.
*/
export function createFieldRow(
ctx: FieldContext,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
rowActions: RowActions = {},
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(ctx, def, value, handlers);
addValue(row, field);
const refreshActions = attachRowActions(row, rowActions);
return {
row,
setValue: field.setValue,
setModified(modified) {
label.setStyle(modified ? 'override' : 'muted');
refreshActions();
},
};
}
interface Field {
widget: Widget;
/** Whether the widget should take the remaining row width. */
grow: boolean;
setValue(value: PropertyValue): void;
/** The per-field widgets of a struct, so a caller can address one of them. */
parts?: ReadonlyMap<string, Widget>;
}
/** A widget that does not grow gets a spacer after it, so whatever follows sits at the row's end. */
export function addValue(row: HStack, field: Field): (value: PropertyValue) => void {
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
return field.setValue;
}
export function removeButton(onClick: () => void): Button {
return new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick });
}
export const READ_ONLY: FieldHandlers = { onInput: () => {}, onChange: () => {} };
/** A struct field as a property definition – a list field becomes the same row a list property has. */
export function fieldDef(field: StructFieldDef, label = field.key): PropertyDef {
return field.kind === 'list'
? { key: field.key, label, kind: 'list', default: [] }
: flatDef(field.key, field.kind, { label });
}
/** What a reference kind names in the project: how an id reads, and what its picker may offer. */
interface Reference {
none: string;
label(project: Project | null, id: string): string;
choices(project: Project, def: PropertyDef): { id: string; label: string }[];
}
const REFERENCES: Record<ReferenceKind, Reference> = {
asset: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project, def) =>
assetRefChoices(project, def.kind === 'asset' ? def.assetTypes : undefined).map((asset) => ({
id: asset.id,
label: assetChoiceLabel(asset),
})),
},
texture: { none: NO_TEXTURE_LABEL, label: textureRefLabel, choices: textureRefChoices },
statemachine: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) =>
assetRefChoices(project, ['statemachine']).map((a) => ({ id: a.id, label: a.name })),
},
sound: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['sound']).map((a) => ({ id: a.id, label: a.name })),
},
scene: {
none: NO_ASSET_LABEL,
label: assetRefLabel,
choices: (project) => assetRefChoices(project, ['scene']).map((a) => ({ id: a.id, label: a.name })),
},
};
/** An id in the words of its reference kind; nothing for a kind that names nothing. */
export function referenceLabel(def: PropertyDef, project: Project | null, id: string): string | undefined {
return isKindWith('reference', def.kind) ? REFERENCES[def.kind].label(project, id) : undefined;
}
function referenceField(
ctx: FieldContext,
def: PropertyDef,
ref: Reference,
value: PropertyValue,
handlers: FieldHandlers,
): Field {
// The current id is kept here rather than read back off the label: the label is a name, two
// assets may share one (a frame's label repeats its sheet's), and a dangling reference has none.
let current = textOf(value);
const button = new Dropdown(ref.label(ctx.project(), current));
button.onClick = () => {
const mark = (id: string, text: string) => (id === current ? `● ${text}` : text);
const items: MenuItem[] = [
{ label: mark('', ref.none), onSelect: () => handlers.onChange('') },
// Built on click, not once per row: assets are created, renamed and deleted while the
// inspector stands. Searchable: one sliced sheet alone contributes an entry per frame.
...ref.choices(ctx.project(), def).map((choice) => ({
label: mark(choice.id, choice.label),
onSelect: () => handlers.onChange(choice.id),
})),
];
new Menu(items, { searchable: true }).openBelow(button);
};
return {
widget: button,
grow: true,
setValue: (v) => {
current = textOf(v);
button.setText(ref.label(ctx.project(), current));
},
};
}
/** One field out of a bundle; a field the value does not carry reads as the empty one of its kind. */
export function structFieldValue(value: PropertyValue, field: StructFieldDef): PropertyValue {
return (isStruct(value) ? value[field.key] : undefined) ?? emptyFieldValue(field);
}
/** The bare widget for a property definition (no label, no reset). */
export function createField(
ctx: FieldContext,
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
): Field {
switch (def.kind) {
case 'number': {
const field = new NumberField(Number(value), {
min: def.min,
max: def.max,
step: def.step ?? 1,
decimals: def.decimals,
onInput: handlers.onInput,
onChange: handlers.onChange,
});
return { widget: field, grow: true, setValue: (v) => field.setNumber(Number(v)) };
}
case 'text': {
const field = new TextField(textOf(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(textOf(v)) };
}
case 'color': {
const field = new ColorField(Number(value), { onInput: handlers.onInput, onChange: handlers.onChange });
return { widget: field, grow: true, setValue: (v) => field.setValue(Number(v)) };
}
case 'list': {
// A board is hundreds of entries: the row says how many and hands the editing to the
// List tab, where a row per item has the space for it.
const count = (v: PropertyValue) => `${(isList(v) ? v : []).length} items`;
const button = new Button(count(value), { variant: 'ghost', onClick: handlers.openList });
return { widget: button, grow: true, setValue: (v) => button.setText(count(v)) };
}
case 'struct': {
// A bundle has few fields, so unlike a list they fit under each other right here. Editing
// one writes the whole bundle, which is why the handlers come per field from the caller.
const per = handlers.structFieldHandlers ?? (() => READ_ONLY);
const stack = new VStack({ gap: Theme.gap });
const fields = new Map<string, Field>();
for (const field of def.fields) {
const { row } = labelledRow(field.key);
const sub = createField(ctx, fieldDef(field), structFieldValue(value, field), per(field));
addValue(row, sub);
fields.set(field.key, sub);
stack.add(row);
}
return {
widget: stack,
grow: true,
setValue: (v) => {
for (const field of def.fields) fields.get(field.key)?.setValue(structFieldValue(v, field));
},
parts: new Map([...fields].map(([key, sub]) => [key, sub.widget])),
};
}
case 'boolean': {
const field = new Checkbox(Boolean(value));
field.onChange = handlers.onChange;
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
}
case 'enum': {
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === textOf(v))?.label ?? (textOf(v) || '—');
const button = new Dropdown(labelOf(value));
button.onClick = () => {
new Menu(
def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) })),
).openBelow(button);
};
return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) };
}
case 'key': {
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
}
case 'asset':
case 'texture':
case 'statemachine':
case 'sound':
case 'scene':
return referenceField(ctx, def, REFERENCES[def.kind], value, handlers);
}
}
/** Shows a key name; click, then press a key to change it (Escape cancels). */
class KeyCaptureButton extends Button {
private key: string;
private capturing = false;
constructor(
key: string,
private readonly onCapture: (key: string) => void,
) {
super(keyLabel(key));
this.key = key;
this.onClick = () => {
this.capturing = true;
this.setText('Press a key…');
ui().focus.focus(this);
};
}
setKey(key: string): void {
this.key = key;
if (!this.capturing) this.setText(keyLabel(key));
}
override onKeyDown(event: KeyboardEvent): boolean {
if (!this.capturing) return super.onKeyDown(event);
event.preventDefault();
this.capturing = false;
if (event.key === 'Escape') {
this.setText(keyLabel(this.key));
return true;
}
if (['Shift', 'Control', 'Alt', 'Meta'].includes(event.key)) {
this.capturing = true;
return true;
}
this.key = event.key;
this.setText(keyLabel(this.key));
this.onCapture(this.key);
return true;
}
override onBlur(): void {
super.onBlur();
if (this.capturing) {
this.capturing = false;
this.setText(keyLabel(this.key));
}
}
}
/** Display name of a `KeyboardEvent.key` value: "Space", "Left arrow", "—" for none. */
export function keyLabel(key: string): string {
if (key === ' ') return 'Space';
if (key === '') return '—';
return key.replace(/^Arrow/, '') + (key.startsWith('Arrow') ? ' arrow' : '');
}