Files
feuerwerk/src/editor/panels/Inspector.ts
T

482 lines
21 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 { Container } from 'pixi.js';
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/graphCommands';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
RenameSceneObjectCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import { AddVariableCommand, nextVariableName, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import { edgesInto, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
import type { Asset, BehaviorAsset, SceneAsset } from '../../core/model/Asset';
import { isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Position, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, findSceneObject } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
/**
* Shows the properties of the current selection. The field list is generated from the
* PropertySchema; scene objects additionally get Position, Owner and per-property
* overrides; behaviors get their variables; graph nodes get their settings and inline inputs.
*/
export class Inspector extends Panel {
private readonly scroll = new ScrollContainer();
private body = new VStack({ padding: Theme.padding, gap: Theme.gap });
private fields = new Map<string, FieldRow>();
private nameField: TextField | null = null;
private builtFor: Selection = null;
/** Describes the structure the fields were built for; a change means a rebuild. */
private signature = '';
/** Last live refresh (play mode), in ms – the world ticks far more often than the eye needs. */
private lastLiveRefresh = 0;
constructor(private readonly state: EditorState) {
super({ title: 'Inspector', padding: 0, gap: 0 });
this.minWidth = 220;
this.scroll.setContent(this.body);
this.content.add(this.scroll, 1);
state.on('selection', () => this.rebuild());
state.on('load', () => this.rebuild());
state.on('project', () => this.refreshValues());
state.on('runtime', () => this.refreshLive());
this.rebuild();
}
/**
* Play mode: show what the behaviors are doing to the selected object, ~10× a second.
* Skipped while a field in here is being edited so the typed text is not overwritten.
*/
private refreshLive(): void {
const now = performance.now();
if (now - this.lastLiveRefresh < 100) return;
for (let node: Container | null = ui().focus.focused; node; node = node.parent) if (node === this) return;
this.lastLiveRefresh = now;
this.refreshValues();
}
private rebuild(): void {
this.builtFor = this.state.selection;
this.body.destroy({ children: true });
this.body = new VStack({ padding: Theme.padding, gap: Theme.gap });
this.fields = new Map();
this.nameField = null;
this.signature = this.currentSignature();
const s = this.state.selection;
if (!s) {
this.body.add(new Label('Select an asset, a scene object or a node.', { style: 'muted' }));
} else if (s.kind === 'asset') {
this.buildAsset(s.assetId);
} else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
} else {
this.buildNode(s.assetId, s.nodeId);
}
this.scroll.setContent(this.body);
}
/** Structure-defining facts of the selection: schema keys, connected inputs. */
private currentSignature(): string {
const s = this.state.selection;
if (!s) return '';
if (s.kind === 'asset') {
const asset = findAsset(this.state.project, s.assetId);
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
if (s.kind === 'object') {
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
const asset = findAsset(this.state.project, s.assetId);
if (!asset || asset.type !== 'behavior') return 'missing';
const node = asset.graph.nodes.find((n) => n.id === s.nodeId);
if (!node) return 'missing';
const def = nodeDef(node.type);
const connected = def ? inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length > 0).map((p) => p.key) : [];
return `${node.type}|${connected.join(',')}|${asset.variables.map((v) => v.key).join(',')}`;
}
// --- assets ------------------------------------------------------------------------------
private buildAsset(assetId: string): void {
const asset = findAsset(this.state.project, assetId);
if (!asset) return;
this.body.add(new Label(`${asset.type.toUpperCase()} ASSET`, { style: 'title' }));
this.nameField = new TextField(asset.name, {
onChange: (name) => {
if (name.trim()) this.state.execute(new RenameAssetCommand(assetId, name.trim()));
else this.nameField?.setValue(asset.name);
},
});
this.body.add(this.labelled('Name', this.nameField));
if (asset.type === 'behavior') {
this.buildVariables(asset);
return;
}
// Asset properties: values that differ from the schema default are marked and resettable.
const values = resolveAssetProps(asset);
for (const def of schemaForAsset(asset)) {
const row = createFieldRow(def, values[def.key] ?? def.default, this.assetHandlers(assetId, def), () => this.state.execute(new ResetAssetPropertyCommand(assetId, def.key)));
row.setModified(isModified(asset, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
}
private assetHandlers(assetId: string, def: PropertyDef) {
let original: PropertyValue | undefined;
return {
onInput: (value: PropertyValue) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original === undefined) original = target.props[def.key];
target.props[def.key] = value;
this.state.preview();
},
onChange: (value: PropertyValue) => {
const target = findAsset(this.state.project, assetId);
if (!target) return;
if (original !== undefined) target.props[def.key] = original;
original = undefined;
this.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
},
};
}
/** Variables of a behavior: name, kind mark, default value, remove – plus an "add" menu. */
private buildVariables(asset: BehaviorAsset): void {
const header = new HStack({ gap: Theme.gap, align: 'center' });
header.add(new Label('VARIABLES', { style: 'title' }), 1);
const add = new Button('+ Variable ▾', { variant: 'ghost' });
add.onClick = () => {
const g = add.getGlobalPosition();
new Menu(
VARIABLE_KINDS.map((kind) => ({
label: VARIABLE_KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(this.state.project, asset.id);
if (current?.type === 'behavior') this.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
})),
).open(g.x, g.y + add.h + 2);
};
header.add(add);
this.body.add(header);
if (asset.variables.length === 0) this.body.add(new Label('No variables yet.', { style: 'muted' }));
const values = resolveAssetProps(asset);
for (const def of asset.variables) {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const mark = new Label(VARIABLE_KIND_MARKS[def.kind as VariableKind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
const name = new TextField(def.key, {
width: 90,
onChange: (next) => {
if (!this.state.execute(new RenameVariableCommand(asset.id, def.key, next.trim()))) this.rebuild();
},
});
row.add(name);
const field = createField(def, values[def.key] ?? def.default, this.assetHandlers(asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Label(''), 1);
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => this.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
this.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
this.body.add(row);
}
}
// --- scene objects -------------------------------------------------------------------
private buildObject(sceneId: string, objectId: string): void {
const scene = findScene(this.state.project, sceneId);
const object = scene && findSceneObject(scene, objectId);
const asset = object && findAsset(this.state.project, object.assetId);
if (!scene || !object || !asset) return;
this.body.add(new Label(`${asset.type.toUpperCase()} OBJECT`, { style: 'title' }));
this.nameField = new TextField(object.name, {
placeholder: asset.name,
onChange: (name) => this.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
});
this.body.add(this.labelled('Name', this.nameField));
const assetRow = new HStack({ gap: Theme.gap, align: 'center' });
const assetLabel = new Label('Asset', { style: 'muted' });
assetLabel.minWidth = FIELD_LABEL_WIDTH;
assetRow.add(assetLabel);
assetRow.add(new Button(asset.name, { variant: 'ghost', onClick: () => this.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
this.body.add(assetRow);
this.body.add(this.parentRow(scene, object, sceneId));
// Position – belongs to the scene object only, local to the parent. Z is the draw order (higher = on top).
const parent = object.parentId ? findSceneObject(scene, object.parentId) : undefined;
this.body.add(new Label(parent ? 'POSITION (LOCAL TO PARENT)' : 'POSITION', { style: 'title' }));
for (const axis of ['x', 'y', 'z'] as const) {
let original: Position | undefined;
const row = createFieldRow({ key: axis, label: axis.toUpperCase(), kind: 'number', default: 0, step: 1 }, object.position[axis], {
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (!original) original = { ...target.position };
target.position[axis] = Number(value);
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
const previous = original ?? { ...target.position };
if (original) target.position = { ...original };
original = undefined;
const next = { ...previous, [axis]: Number(value) };
this.state.execute(new MoveSceneObjectCommand(sceneId, objectId, next, previous));
},
});
this.fields.set(axis, row);
this.body.add(row.row);
}
// Asset properties (or behavior variables) with overrides.
const schema = schemaForAsset(asset);
if (schema.length > 0 || asset.type === 'behavior') this.body.add(new Label(asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES', { style: 'title' }));
if (schema.length === 0 && asset.type === 'behavior') this.body.add(new Label('The behavior declares no variables.', { style: 'muted' }));
const values = resolveSceneObjectProps(asset, object);
for (const def of schema) {
let original: PropertyValue | undefined;
let hadOverride = false;
const row = createFieldRow(
def,
values[def.key] ?? def.default,
{
onInput: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original === undefined) {
hadOverride = isOverridden(target, def.key);
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
}
target.overrides[def.key] = value;
this.state.preview();
},
onChange: (value) => {
const target = this.currentObject(sceneId, objectId);
if (!target) return;
if (original !== undefined) {
if (hadOverride) target.overrides[def.key] = original;
else delete target.overrides[def.key];
}
original = undefined;
this.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
},
},
() => this.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
);
row.setModified(isOverridden(object, def.key));
this.fields.set(def.key, row);
this.body.add(row.row);
}
// Sprites (and other bodies) get their collider with one click: a child sized like the object.
if (asset.type !== 'collider') {
this.body.add(new Label(''));
this.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => {
const command = new AddColliderCommand(sceneId, objectId);
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId, objectId: command.objectId });
},
}),
);
}
}
/**
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner.
*/
private parentRow(scene: SceneAsset, object: SceneObject, sceneId: string): HStack {
const row = new HStack({ gap: Theme.gap, align: 'center' });
row.minHeight = Theme.rowHeight;
const label = new Label('Parent', { style: 'muted' });
label.minWidth = FIELD_LABEL_WIDTH;
row.add(label);
const nameOf = (o: SceneObject) => o.name || findAsset(this.state.project, o.assetId)?.name || '?';
const parent = scene.objects.find((o) => o.id === object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => {
const g = button.getGlobalPosition();
const items: MenuItem[] = [{ label: 'None', onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, '')) }];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({ label: candidate.id === object.parentId ? `● ${name}` : name, onSelect: () => this.state.execute(new SetParentCommand(sceneId, object.id, candidate.id)) });
}
new Menu(items).open(g.x, g.y + button.h + 2);
};
row.add(button, 1);
return row;
}
// --- graph nodes ---------------------------------------------------------------------
private buildNode(assetId: string, nodeId: string): void {
const asset = findAsset(this.state.project, assetId);
if (!asset || asset.type !== 'behavior') return;
const node = asset.graph.nodes.find((n) => n.id === nodeId);
const def = node && nodeDef(node.type);
if (!node || !def) return;
this.body.add(new Label(`${def.category.toUpperCase()} NODE`, { style: 'title' }));
this.body.add(new Label(def.label, { style: 'bodyBold' }));
this.body.add(new Label(def.description, { style: 'muted' }));
const handlers = (key: string) => {
let original: PropertyValue | undefined;
let had = false;
return {
onInput: (value: PropertyValue) => {
const target = this.currentNode(assetId, nodeId);
if (!target) return;
if (original === undefined) {
had = target.params[key] !== undefined;
original = target.params[key] ?? value;
}
target.params[key] = value;
this.state.preview();
},
onChange: (value: PropertyValue) => {
const target = this.currentNode(assetId, nodeId);
if (!target) return;
if (original !== undefined) {
if (had) target.params[key] = original;
else delete target.params[key];
}
original = undefined;
this.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value));
},
};
};
if (def.params.length > 0) this.body.add(new Label('SETTINGS', { style: 'title' }));
for (const param of def.params) {
const effective = this.resolveParamDef(param, asset);
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, handlers(param.key));
this.fields.set(`param:${param.key}`, row);
this.body.add(row.row);
}
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
if (free.length > 0) this.body.add(new Label('INPUTS (unconnected)', { style: 'title' }));
for (const port of free) {
const inline = inlineInputDef(port);
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, handlers(port.key));
this.fields.set(`input:${port.key}`, row);
this.body.add(row.row);
}
this.body.add(new Label(''));
this.body.add(new Button('Delete node', { variant: 'danger', onClick: () => this.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
}
/** Variable params pick from the asset's variables; everything else is used as declared. */
private resolveParamDef(param: PropertyDef, asset: Asset): PropertyDef {
if (param.kind === 'enum' && param.key === 'variable' && asset.type === 'behavior') {
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
return param;
}
// --- refresh ---------------------------------------------------------------------------
/** Push current model values into the widgets (after undo, drag in viewport, …). */
private refreshValues(): void {
const s = this.state.selection;
if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
this.rebuild();
return;
}
if (!s) return;
if (s.kind === 'asset') {
const asset = findAsset(this.state.project, s.assetId);
if (!asset) return this.rebuild();
this.nameField?.setValue(asset.name);
const values = resolveAssetProps(asset);
for (const [key, row] of this.fields) {
row.setValue(values[key] ?? 0);
row.setModified(isModified(asset, key));
}
return;
}
if (s.kind === 'node') {
const node = this.currentNode(s.assetId, s.nodeId);
const def = node && nodeDef(node.type);
if (!node || !def) return this.rebuild();
for (const [key, row] of this.fields) {
const [scope, name] = key.split(':') as ['param' | 'input', string];
const fallback = scope === 'param' ? def.params.find((p) => p.key === name)?.default : inlineInputDef(def.inputs.find((p) => p.key === name)!).default;
row.setValue(node.params[name] ?? fallback ?? '');
}
return;
}
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
if (!object || !asset) return this.rebuild();
this.nameField?.setValue(object.name);
this.fields.get('x')?.setValue(object.position.x);
this.fields.get('y')?.setValue(object.position.y);
this.fields.get('z')?.setValue(object.position.z);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) {
const row = this.fields.get(def.key);
row?.setValue(values[def.key] ?? def.default);
row?.setModified(isOverridden(object, def.key));
}
}
private currentObject(sceneId: string, objectId: string) {
const scene = findScene(this.state.project, sceneId);
return scene && findSceneObject(scene, objectId);
}
private currentNode(assetId: string, nodeId: string) {
const asset = findAsset(this.state.project, assetId);
return asset?.type === 'behavior' ? asset.graph.nodes.find((n) => n.id === nodeId) : undefined;
}
private labelled(text: string, field: TextField): HStack {
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);
row.add(field, 1);
return row;
}
}
function sameTarget(a: Selection, b: Selection): boolean {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}