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 = { number: 'Number', boolean: 'Boolean', text: 'Text' }; const VARIABLE_KIND_MARKS: Record = { 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(); 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: ▾" – 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; }