482 lines
21 KiB
TypeScript
482 lines
21 KiB
TypeScript
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;
|
||
}
|