Add ESLint and Prettier, and close the node test gaps

Tooling:
- Prettier as the single formatting authority (110 columns, single quotes,
  measured against the existing style); Markdown excluded because table-cell
  padding turns the README glossary into 1000-character lines.
- Type-aware ESLint ending in eslint-config-prettier, so a rule always means
  "this is a bug", never "this looks different".
- .editorconfig and an `npm run check` that runs typecheck, lint, format and
  tests in one go.

Findings fixed on the first run: three more duplicate imports, four
String(unknown) calls (parameter types narrowed instead of casts), two dead
initialisers, two redundant casts, a console.log for a real error. Two
warnings were false here and are disabled with a reason in place.

Tests 132 -> 269:
- node-catalog.test.ts covers every node with a run(), with a guard that fails
  when a new node has no row.
- behavior-events.test.ts covers the ten event nodes through a running World,
  checking timing rather than existence.
- Input's state machine (press/release/releaseAll) is reachable without DOM
  events now; the DOM handlers are thin adapters.

Compare fell back to == without a configured operator although the schema
default is <; aligned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 10:02:22 +02:00
1 parent f140c2fd77
commit d54a620ebd
113 files changed
+5049 -666

No files matched your search

+31 -4
View File
@@ -18,7 +18,17 @@ import type { EditorState } from './state';
/** Facts about the running world for the check scripts. */
export interface WorldSnapshot {
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
entities: {
objectId: string;
assetId: string;
name: string;
x: number;
y: number;
worldX: number;
worldY: number;
parent: string;
props: Record<string, unknown>;
}[];
log: string[];
}
@@ -37,7 +47,10 @@ export class PlaySession {
private behaviors: BehaviorSystem | null = null;
private readonly input = new Input();
constructor(private readonly state: EditorState, private readonly host: PlayHost) {}
constructor(
private readonly state: EditorState,
private readonly host: PlayHost,
) {}
get isPlaying(): boolean {
return this.world !== null;
@@ -76,7 +89,11 @@ export class PlaySession {
world.addSystem(this.behaviors);
this.host.gameView.setWorld(world);
this.host.showGame();
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
this.state.setNotice(
world.firstOfType('camera')
? 'Playing · edits are not saved · Esc stops'
: 'This scene has no camera – nothing is rendered · Esc stops',
);
if (world.stopRequested) this.stop();
}
@@ -125,7 +142,17 @@ export class PlaySession {
return {
entities: world.entities.map((e) => {
const w = worldPositionOf(e);
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
return {
objectId: e.objectId,
assetId: e.assetId,
name: e.name,
x: e.x,
y: e.y,
worldX: w.x,
worldY: w.y,
parent: e.parent?.objectId ?? '',
props: { ...e.props },
};
}),
log: [...world.log],
};
+18 -7
View File
@@ -13,7 +13,11 @@ export class ProjectSession {
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
* a run in progress has to end first, since it holds a copy of the project being replaced.
*/
constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {}
constructor(
private readonly state: EditorState,
private readonly store: ProjectStore,
private readonly beforeSwitch: () => void,
) {}
/** Opens the project named in the URL, else the first one on the server, else a new one. */
async boot(): Promise<void> {
@@ -25,7 +29,9 @@ export class ProjectSession {
}
await this.openFirstOrCreate();
} catch (error) {
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
this.state.setNotice(
`Could not load project: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -69,10 +75,14 @@ export class ProjectSession {
label: 'Delete this project…',
danger: true,
onSelect: () =>
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
]).openCentered(),
new Dialog(
`Delete project "${this.state.project.name}"?`,
'All assets and scenes are removed permanently.',
[
{ label: 'Cancel' },
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
],
).openCentered(),
},
];
new Menu(items).openBelow(anchor);
@@ -85,7 +95,8 @@ export class ProjectSession {
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
}));
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
if (items.length === 0)
items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
new Menu(items).openBelow(anchor);
}
}
+5 -1
View File
@@ -7,7 +7,11 @@ import type { EditorState } from './state';
* and never overlap; a change that arrives while a save is in flight triggers another save
* afterwards.
*/
export function attachAutosave(state: EditorState, store: ProjectStore, delayMs = 1000): { flush: () => Promise<void> } {
export function attachAutosave(
state: EditorState,
store: ProjectStore,
delayMs = 1000,
): { flush: () => Promise<void> } {
let timer = 0;
let saving: Promise<void> | null = null;
let pending = false;
+5 -1
View File
@@ -24,6 +24,9 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
for (const child of node.children) walk(child);
};
walk(app.stage);
// TypeScript does not track the assignment inside `walk`, so it believes `found` is still
// `null` here – hence both the disable and the cast below.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!found) return null;
const node = found as Text;
const p = node.getGlobalPosition();
@@ -43,7 +46,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
gameScreen: (id: string) => gameView.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
portAt: (nodeId: string, port: string, isOutput: boolean) =>
graphEditor.portGlobal(nodeId, port, isOutput),
nodeAt: (nodeId: string) => graphEditor.nodeGlobal(nodeId),
center: () => centreOf(graphEditor),
},
+92 -19
View File
@@ -1,10 +1,22 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
import { AddNodeCommand, ConnectCommand, DisconnectCommand, MoveNodeCommand, RemoveNodeCommand } from '../../core/commands/graphCommands';
import { edgesInto, edgesOutOf, type GraphEdge, type NodeDef, nodeDef, portsCompatible } from '../../core/graph';
import {
AddNodeCommand,
ConnectCommand,
DisconnectCommand,
MoveNodeCommand,
RemoveNodeCommand,
} from '../../core/commands/graphCommands';
import {
edgesInto,
edgesOutOf,
type GraphEdge,
type NodeDef,
nodeDef,
portsCompatible,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset } from '../../core/model/Project';
import type { Trace } from '../../runtime/systems/BehaviorSystem';
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { type Trace, TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
import type { EditorState } from '../state';
import { NodePalette } from './NodePalette';
@@ -47,7 +59,12 @@ export class GraphEditor extends Widget {
private pan = { x: 0, y: 0 };
private zoom = 1;
private panning: { startX: number; startY: number; panX: number; panY: number } | null = null;
private dragging: { nodeId: string; start: { x: number; y: number }; pointer: { x: number; y: number }; moved: boolean } | null = null;
private dragging: {
nodeId: string;
start: { x: number; y: number };
pointer: { x: number; y: number };
moved: boolean;
} | null = null;
private connecting: { from: PortAnchor; pointer: { x: number; y: number } } | null = null;
/** Fit the view once the editor has a real size (it may be hidden behind the Scene tab). */
private needsFit = true;
@@ -78,7 +95,10 @@ export class GraphEditor extends Widget {
});
state.on('selection', () => this.followSelection());
state.on('load', () => { this.assetId = null; this.followSelection(); });
state.on('load', () => {
this.assetId = null;
this.followSelection();
});
state.on('project', () => this.sync());
state.on('mode', () => this.redraw());
}
@@ -183,11 +203,13 @@ export class GraphEditor extends Widget {
if (e.target !== this) return;
const local = this.toLocal(e.global);
if (e.button === 2) {
if (this.assetId && this.state.mode === 'edit') this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
if (this.assetId && this.state.mode === 'edit')
this.openPalette(e.global.x, e.global.y, this.screenToGraph(local.x, local.y));
return;
}
if (e.button === 0 || e.button === 1) {
if (e.button === 0 && this.assetId && this.state.mode === 'edit') this.state.select({ kind: 'asset', assetId: this.assetId });
if (e.button === 0 && this.assetId && this.state.mode === 'edit')
this.state.select({ kind: 'asset', assetId: this.assetId });
this.panning = { startX: e.global.x, startY: e.global.y, panX: this.pan.x, panY: this.pan.y };
}
}
@@ -205,13 +227,21 @@ export class GraphEditor extends Widget {
if (e.button !== 0) return;
if (hit) {
const p = this.toLocal(e.global);
this.connecting = { from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput }, pointer: this.screenToGraph(p.x, p.y) };
this.connecting = {
from: { nodeId: view.node.id, port: hit.port.key, isOutput: hit.isOutput },
pointer: this.screenToGraph(p.x, p.y),
};
this.redraw();
return;
}
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: view.node.id });
const p = this.toLocal(e.global);
this.dragging = { nodeId: view.node.id, start: { x: view.node.x, y: view.node.y }, pointer: this.screenToGraph(p.x, p.y), moved: false };
this.dragging = {
nodeId: view.node.id,
start: { x: view.node.x, y: view.node.y },
pointer: this.screenToGraph(p.x, p.y),
moved: false,
};
this.nodeLayer.addChild(view); // raise
}
@@ -261,7 +291,13 @@ export class GraphEditor extends Widget {
if (target && target.isOutput !== connecting.from.isOutput) {
const out = connecting.from.isOutput ? connecting.from : target;
const inp = connecting.from.isOutput ? target : connecting.from;
this.state.execute(new ConnectCommand(this.assetId, { node: out.nodeId, port: out.port }, { node: inp.nodeId, port: inp.port }));
this.state.execute(
new ConnectCommand(
this.assetId,
{ node: out.nodeId, port: out.port },
{ node: inp.nodeId, port: inp.port },
),
);
}
}
this.redraw();
@@ -282,7 +318,8 @@ export class GraphEditor extends Widget {
new NodePalette((def: NodeDef) => {
if (!this.assetId) return;
const command = new AddNodeCommand(this.assetId, def.type, at.x, at.y);
if (this.state.execute(command)) this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
if (this.state.execute(command))
this.state.select({ kind: 'node', assetId: this.assetId, nodeId: command.node.id });
}).open(globalX, globalY);
}
@@ -290,15 +327,37 @@ export class GraphEditor extends Widget {
const assetId = this.assetId;
if (!assetId) return;
this.state.select({ kind: 'node', assetId, nodeId: view.node.id });
new Menu([{ label: 'Delete node', danger: true, onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)) }]).open(globalX, globalY);
new Menu([
{
label: 'Delete node',
danger: true,
onSelect: () => this.state.execute(new RemoveNodeCommand(assetId, view.node.id)),
},
]).open(globalX, globalY);
}
private openPortMenu(view: NodeView, port: string, isOutput: boolean, globalX: number, globalY: number): void {
private openPortMenu(
view: NodeView,
port: string,
isOutput: boolean,
globalX: number,
globalY: number,
): void {
const asset = this.asset();
if (!asset) return;
const edges = isOutput ? edgesOutOf(asset.graph, view.node.id, port) : edgesInto(asset.graph, view.node.id, port);
const edges = isOutput
? edgesOutOf(asset.graph, view.node.id, port)
: edgesInto(asset.graph, view.node.id, port);
if (edges.length === 0) return;
new Menu([{ label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`, danger: true, onSelect: () => { for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id)); } }]).open(globalX, globalY);
new Menu([
{
label: edges.length === 1 ? 'Disconnect' : `Disconnect ${edges.length} links`,
danger: true,
onSelect: () => {
for (const edge of edges) this.state.execute(new DisconnectCommand(asset.id, edge.id));
},
},
]).open(globalX, globalY);
}
// --- drawing -----------------------------------------------------------------------------
@@ -320,7 +379,10 @@ export class GraphEditor extends Widget {
return;
}
const playing = this.state.mode === 'play';
this.hint.text = asset.graph.nodes.length === 0 ? `${asset.name} · right-click to add a node` : `${asset.name}${playing ? ' · live' : ''}`;
this.hint.text =
asset.graph.nodes.length === 0
? `${asset.name} · right-click to add a node`
: `${asset.name}${playing ? ' · live' : ''}`;
const trace = playing ? this.traceProvider(asset.id) : null;
const now = this.timeProvider();
@@ -362,7 +424,11 @@ export class GraphEditor extends Widget {
const inType = to.def.inputs.find((p) => p.key === edge.to.port)?.type ?? 'any';
const valid = portsCompatible(type, inType);
bezier(this.edges, from.x + a.x, from.y + a.y, to.x + b.x, to.y + b.y);
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
this.edges.stroke({
width: hot ? 4 : type === 'exec' ? 2.5 : 2,
color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger,
alpha: type === 'exec' || hot ? 1 : 0.85,
});
}
private syncGrid(): void {
@@ -371,7 +437,14 @@ export class GraphEditor extends Widget {
if (step < MIN_GRID_STEP) return;
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
// marking, so no axis lines.
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
drawGrid(this.grid, {
originX: this.pan.x,
originY: this.pan.y,
step,
w: this.w,
h: this.h,
alpha: 0.04,
});
}
// --- test hooks -----------------------------------------------------------------------------
+12 -2
View File
@@ -47,8 +47,18 @@ export class NodePalette extends Popup {
private refresh(): void {
const query = this.search.value.trim().toLowerCase();
this.filtered = allNodeDefs()
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
.filter(
(d) =>
!query ||
d.label.toLowerCase().includes(query) ||
d.type.toLowerCase().includes(query) ||
d.description.toLowerCase().includes(query),
)
.sort(
(a, b) =>
CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) ||
a.label.localeCompare(b.label),
);
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
this.scroll.relayoutContent();
}
+112 -29
View File
@@ -1,7 +1,20 @@
import { CanvasTextMetrics, Graphics, Rectangle, Text } from 'pixi.js';
import { edgesInto, type Graph, type GraphNode, inlineInputs, inlineValue, type NodeCategory, type NodeDef, type PortDef, type PortType } from '../../core/graph';
import {
asString,
edgesInto,
type Graph,
type GraphNode,
inlineInputs,
inlineValue,
type NodeCategory,
type NodeDef,
type PortDef,
type PortType,
type Value,
} from '../../core/graph';
import type { BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
import { keyLabel } from '../inspector/fieldFactory';
@@ -22,23 +35,34 @@ const PAD = 6;
export function categoryColor(category: NodeCategory): number {
const c = Theme.colors;
switch (category) {
case 'event': return c.categoryEvent;
case 'flow': return c.categoryFlow;
case 'data': return c.categoryData;
case 'variable': return c.categoryVariable;
case 'action': return c.categoryAction;
case 'event':
return c.categoryEvent;
case 'flow':
return c.categoryFlow;
case 'data':
return c.categoryData;
case 'variable':
return c.categoryVariable;
case 'action':
return c.categoryAction;
}
}
export function portColor(type: PortType): number {
const c = Theme.colors;
switch (type) {
case 'exec': return c.portExec;
case 'number': return c.portNumber;
case 'boolean': return c.portBoolean;
case 'string': return c.portString;
case 'object': return c.portObject;
case 'any': return c.portAny;
case 'exec':
return c.portExec;
case 'number':
return c.portNumber;
case 'boolean':
return c.portBoolean;
case 'string':
return c.portString;
case 'object':
return c.portObject;
case 'any':
return c.portAny;
}
}
@@ -62,7 +86,10 @@ export class NodeView extends Widget {
/** Data inputs that currently have a connection (their inline value is hidden). */
private connected = new Set<string>();
constructor(readonly node: GraphNode, readonly def: NodeDef) {
constructor(
readonly node: GraphNode,
readonly def: NodeDef,
) {
super();
this.cursor = 'grab';
this.addChild(this.box, this.ports);
@@ -77,9 +104,16 @@ export class NodeView extends Widget {
/** Re-reads params and connections; rebuilds the text lines. `project` resolves asset references (Spawn, Is asset). */
sync(node: GraphNode, graph: Graph, asset: BehaviorAsset | null, project: Project | null): void {
this.paramLines = this.def.params.map((p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`);
this.connected = new Set(inlineInputs(this.def).filter((p) => edgesInto(graph, node.id, p.key).length > 0).map((p) => p.key));
const height = TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.paramLines = this.def.params.map(
(p) => `${p.label}: ${formatParam(p, node.params[p.key] ?? p.default, asset, project)}`,
);
this.connected = new Set(
inlineInputs(this.def)
.filter((p) => edgesInto(graph, node.id, p.key).length > 0)
.map((p) => p.key),
);
const height =
TITLE_HEIGHT + PAD + this.paramLines.length * ROW_HEIGHT + this.rowCount() * ROW_HEIGHT + PAD;
this.rebuildTexts();
this.resize(NODE_WIDTH, height);
this.position.set(node.x, node.y);
@@ -103,8 +137,12 @@ export class NodeView extends Widget {
/** Every port with its local centre – inputs on the left edge, outputs on the right. */
private *portCentres(): Iterable<{ port: PortDef; isOutput: boolean; x: number; y: number }> {
for (const [list, isOutput] of [[this.def.inputs, false], [this.def.outputs, true]] as const) {
for (let i = 0; i < list.length; i++) yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
for (const [list, isOutput] of [
[this.def.inputs, false],
[this.def.outputs, true],
] as const) {
for (let i = 0; i < list.length; i++)
yield { port: list[i]!, isOutput, x: isOutput ? this.w : 0, y: this.rowY(i) };
}
}
@@ -119,7 +157,14 @@ export class NodeView extends Widget {
private rebuildTexts(): void {
for (const t of this.texts) t.destroy();
this.texts.length = 0;
const add = (text: string, style: TextStyleToken, x: number, y: number, align: 'left' | 'right' = 'left', maxWidth = NODE_WIDTH - PAD * 2) => {
const add = (
text: string,
style: TextStyleToken,
x: number,
y: number,
align: 'left' | 'right' = 'left',
maxWidth = NODE_WIDTH - PAD * 2,
) => {
const node = new Text({ text: fit(text, style, maxWidth), style: textStyle(style) });
node.position.set(align === 'left' ? x : x - node.width, y);
this.addChild(node);
@@ -141,9 +186,24 @@ export class NodeView extends Widget {
const value = inlineValue(this.node, input);
if (value !== null && value !== '') label += ` ${formatValue(value)}`;
}
add(label, input.type === 'exec' ? 'body' : 'muted', PAD + PORT_RADIUS + 4, rowTop, 'left', NODE_WIDTH / 2 + 20);
add(
label,
input.type === 'exec' ? 'body' : 'muted',
PAD + PORT_RADIUS + 4,
rowTop,
'left',
NODE_WIDTH / 2 + 20,
);
}
if (output) add(output.label, output.type === 'exec' ? 'body' : 'muted', this.w - PAD - PORT_RADIUS - 4, rowTop, 'right', NODE_WIDTH / 2 - 10);
if (output)
add(
output.label,
output.type === 'exec' ? 'body' : 'muted',
this.w - PAD - PORT_RADIUS - 4,
rowTop,
'right',
NODE_WIDTH / 2 - 10,
);
}
}
@@ -153,16 +213,27 @@ export class NodeView extends Widget {
this.box.roundRect(2, 3, this.w, this.h, Theme.radius).fill({ color: 0x000000, alpha: 0.35 });
this.box.roundRect(0, 0, this.w, this.h, Theme.radius).fill(c.node);
this.box.roundRect(0, 0, this.w, TITLE_HEIGHT, Theme.radius).fill(categoryColor(this.def.category));
this.box.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius).fill(categoryColor(this.def.category));
this.box
.rect(0, TITLE_HEIGHT - Theme.radius, this.w, Theme.radius)
.fill(categoryColor(this.def.category));
const border = this.hot ? c.hot : this.selected ? c.nodeSelected : c.nodeBorder;
this.box.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius).stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.box
.roundRect(0.5, 0.5, this.w - 1, this.h - 1, Theme.radius)
.stroke({ width: this.selected || this.hot ? 2 : 1, color: border });
this.ports.clear();
const r = PORT_RADIUS;
for (const { port, x, y } of this.portCentres()) {
if (port.type === 'exec') {
// A pentagon pointing right: the Blueprint-style exec pin.
this.ports.moveTo(x - r, y - r).lineTo(x + 1, y - r).lineTo(x + r + 1, y).lineTo(x + 1, y + r).lineTo(x - r, y + r).closePath().fill(portColor('exec'));
this.ports
.moveTo(x - r, y - r)
.lineTo(x + 1, y - r)
.lineTo(x + r + 1, y)
.lineTo(x + 1, y + r)
.lineTo(x - r, y + r)
.closePath()
.fill(portColor('exec'));
} else {
this.ports.circle(x, y, r).fill(portColor(port.type));
this.ports.circle(x, y, r).stroke({ width: 1, color: c.node });
@@ -172,21 +243,32 @@ export class NodeView extends Widget {
}
/** The value of a setting as shown on the node; references that no longer resolve get a "?". */
function formatParam(param: PropertyDef, value: unknown, asset: BehaviorAsset | null, project: Project | null): string {
function formatParam(
param: PropertyDef,
value: PropertyValue | undefined,
asset: BehaviorAsset | null,
project: Project | null,
): string {
if (value === undefined || value === '') return '—';
if (param.kind === 'key') return keyLabel(String(value));
if (param.kind === 'enum' && param.key === 'asset') {
const target = project ? findAsset(project, String(value)) : undefined;
return target ? target.name : `${value}?`;
}
if (param.kind === 'enum' && param.key === 'variable' && asset && !asset.variables.some((v) => v.key === value)) return `${value}?`;
if (
param.kind === 'enum' &&
param.key === 'variable' &&
asset &&
!asset.variables.some((v) => v.key === value)
)
return `${value}?`;
return formatValue(value);
}
function formatValue(value: unknown): string {
function formatValue(value: Value): string {
if (typeof value === 'number') return Number.isInteger(value) ? String(value) : value.toFixed(2);
if (typeof value === 'boolean') return value ? 'true' : 'false';
return String(value);
return asString(value);
}
/** Truncates with an ellipsis so the text fits `maxWidth`. */
@@ -194,6 +276,7 @@ function fit(text: string, style: TextStyleToken, maxWidth: number): string {
const s = textStyle(style);
if (CanvasTextMetrics.measureText(text, s).width <= maxWidth) return text;
let cut = text;
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth) cut = cut.slice(0, -1);
while (cut.length > 1 && CanvasTextMetrics.measureText(cut + '…', s).width > maxWidth)
cut = cut.slice(0, -1);
return cut + '…';
}
+17 -5
View File
@@ -4,7 +4,12 @@ import { type EditorState, sameSelection, type Selection } from '../state';
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
import type { InspectorHost } from './host';
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
import { buildObjectSection, buildObjectsSection, objectSignature, refreshObjectSection } from './objectSection';
import {
buildObjectSection,
buildObjectsSection,
objectSignature,
refreshObjectSection,
} from './objectSection';
/**
* Shows the properties of the current selection. The rows are generated from the
@@ -33,7 +38,12 @@ export class Inspector extends Panel {
}
private emptyHost(): InspectorHost {
return { state: this.state, body: new VStack({ padding: Theme.padding, gap: Theme.gap }), fields: new Map(), nameField: null };
return {
state: this.state,
body: new VStack({ padding: Theme.padding, gap: Theme.gap }),
fields: new Map(),
nameField: null,
};
}
/**
@@ -80,9 +90,11 @@ export class Inspector extends Panel {
}
if (!s) return;
const ok =
s.kind === 'asset' ? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node' ? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
s.kind === 'asset'
? refreshAssetSection(this.host, s.assetId)
: s.kind === 'node'
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
if (!ok) this.rebuild();
}
}
+59 -14
View File
@@ -1,22 +1,50 @@
import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import {
RenameAssetCommand,
ResetAssetPropertyCommand,
SetAssetPropertyCommand,
} from '../../core/commands/assetCommands';
import { nextVariableName } from '../../core/commands/naming';
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
import {
AddVariableCommand,
RemoveVariableCommand,
RenameVariableCommand,
} from '../../core/commands/variableCommands';
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
import { findAsset, type Project } from '../../core/model/Project';
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import {
COMMON_PROPS,
type PropertyDef,
schemaForAsset,
VARIABLE_KINDS,
type VariableKind,
} from '../../core/properties/PropertySchema';
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
import { addNote, addTitle, type InspectorHost } from './host';
import { previewingHandlers } from './previewHandlers';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = {
number: 'Number',
boolean: 'Boolean',
text: 'Text',
};
// Keyed by the wide `PropertyDef['kind']` on purpose: `variables` is a `PropertyDef[]`, so a
// document could carry a kind that is not a VariableKind – that renders as '?' rather than blank.
const VARIABLE_KIND_MARKS: Partial<Record<PropertyDef['kind'], string>> = {
number: '#',
boolean: '✓',
text: 'Aa',
};
/** Structure-defining facts of an asset selection: the schema keys and kinds. */
export function assetSignature(project: Project, assetId: string): string {
const asset = findAsset(project, assetId);
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
return asset
? schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
: 'missing';
}
/** Name, properties (asset values marked when they differ from the default) and, for behaviors, the variables. */
@@ -58,11 +86,21 @@ export function refreshAssetSection(host: InspectorHost, assetId: string): boole
function buildProps(host: InspectorHost, asset: Asset, schema: readonly PropertyDef[]): void {
const values = resolveAssetProps(asset);
for (const def of schema) {
const row = createFieldRow(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def), () => {
const target = findAsset(host.state.project, asset.id);
if (!target || !isModified(target, def.key)) return [];
return [{ label: 'Reset to default', onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)) }];
});
const row = createFieldRow(
def,
values[def.key] ?? def.default,
assetHandlers(host, asset.id, def),
() => {
const target = findAsset(host.state.project, asset.id);
if (!target || !isModified(target, def.key)) return [];
return [
{
label: 'Reset to default',
onSelect: () => host.state.execute(new ResetAssetPropertyCommand(asset.id, def.key)),
},
];
},
);
row.setModified(isModified(asset, def.key));
host.fields.set(def.key, row);
host.body.add(row.row);
@@ -90,7 +128,8 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
label: VARIABLE_KIND_LABELS[kind],
onSelect: () => {
const current = findAsset(host.state.project, asset.id);
if (current?.type === 'behavior') host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
if (current?.type === 'behavior')
host.state.execute(new AddVariableCommand(asset.id, nextVariableName(current), kind));
},
}));
new Menu(items).openBelow(add);
@@ -103,7 +142,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
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' });
const mark = new Label(VARIABLE_KIND_MARKS[def.kind] ?? '?', { style: 'small', align: 'center' });
mark.minWidth = 18;
row.add(mark);
row.add(
@@ -117,7 +156,13 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
row.add(field.widget, field.grow ? 1 : 0);
if (!field.grow) row.add(new Spacer(), 1);
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
row.add(
new Button('×', {
variant: 'ghost',
width: Theme.controlHeight,
onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)),
}),
);
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
host.body.add(row);
}
+30 -5
View File
@@ -1,6 +1,19 @@
import type { PropertyValue } from '../../core/model/Properties';
import type { PropertyDef } from '../../core/properties/PropertySchema';
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui';
import {
Button,
Checkbox,
ColorField,
HStack,
Label,
Menu,
NumberField,
Spacer,
TextField,
Theme,
ui,
type Widget,
} from '../../ui';
export interface FieldHandlers {
/** Live value while dragging/typing – apply as a preview, no undo entry. */
@@ -54,7 +67,13 @@ export function labelledRow(text: string): { row: HStack; label: Label } {
* its source. Which of them exist is asked from `reset`/`apply` on every refresh: no entry
* means no button, one entry means a direct button, several mean a menu (↺ ▾ / ↑ ▾).
*/
export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers: FieldHandlers, reset?: FieldActions, apply?: FieldActions): FieldRow {
export function createFieldRow(
def: PropertyDef,
value: PropertyValue,
handlers: FieldHandlers,
reset?: FieldActions,
apply?: FieldActions,
): FieldRow {
const { row, label } = labelledRow(def.label);
const field = createField(def, value, handlers);
row.add(field.widget, field.grow ? 1 : 0);
@@ -141,10 +160,13 @@ export function createField(def: PropertyDef, value: PropertyValue, handlers: Fi
return { widget: field, grow: false, setValue: (v) => field.setChecked(Boolean(v)) };
}
case 'enum': {
const labelOf = (v: PropertyValue) => def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const labelOf = (v: PropertyValue) =>
def.options.find((o) => o.value === String(v))?.label ?? (String(v) || '—');
const button = new Button(`${labelOf(value)} ▾`);
button.onClick = () => {
new Menu(def.options.map((o) => ({ label: o.label, onSelect: () => handlers.onChange(o.value) }))).openBelow(button);
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)} ▾`) };
}
@@ -160,7 +182,10 @@ class KeyCaptureButton extends Button {
private key: string;
private capturing = false;
constructor(key: string, private readonly onCapture: (key: string) => void) {
constructor(
key: string,
private readonly onCapture: (key: string) => void,
) {
super(keyLabel(key));
this.key = key;
this.onClick = () => {
+31 -6
View File
@@ -16,7 +16,11 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
const node = asset.graph.nodes.find((n) => n.id === 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) : [];
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(',')}`;
}
@@ -35,7 +39,11 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (def.params.length > 0) addTitle(host, 'SETTINGS');
for (const param of def.params) {
const effective = resolveParamDef(host.state.project, param, asset);
const row = createFieldRow(effective, node.params[param.key] ?? effective.default, paramHandlers(host, assetId, nodeId, param.key));
const row = createFieldRow(
effective,
node.params[param.key] ?? effective.default,
paramHandlers(host, assetId, nodeId, param.key),
);
host.fields.set(`param:${param.key}`, row);
host.body.add(row.row);
}
@@ -44,13 +52,22 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
for (const port of free) {
const inline = inlineInputDef(port);
const row = createFieldRow(inline, node.params[port.key] ?? inline.default, paramHandlers(host, assetId, nodeId, port.key));
const row = createFieldRow(
inline,
node.params[port.key] ?? inline.default,
paramHandlers(host, assetId, nodeId, port.key),
);
host.fields.set(`input:${port.key}`, row);
host.body.add(row.row);
}
addGap(host);
host.body.add(new Button('Delete node', { variant: 'danger', onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)) }));
host.body.add(
new Button('Delete node', {
variant: 'danger',
onClick: () => host.state.execute(new RemoveNodeCommand(assetId, nodeId)),
}),
);
}
/** Pushes the node's current params into the rows. False when the node is gone (rebuild). */
@@ -60,7 +77,10 @@ export function refreshNodeSection(host: InspectorHost, assetId: string, nodeId:
if (!node || !def) return false;
for (const [key, row] of host.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;
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 true;
@@ -76,7 +96,12 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
return { ...param, options: asset.variables.map((v) => ({ value: v.key, label: v.key })) };
}
if (param.key === 'asset') {
return { ...param, options: project.assets.filter((a) => isPlaceableType(a.type)).map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })) };
return {
...param,
options: project.assets
.filter((a) => isPlaceableType(a.type))
.map((a) => ({ value: a.id, label: `${a.name} (${a.type})` })),
};
}
return param;
}
+173 -29
View File
@@ -20,8 +20,17 @@ import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierar
import type { Axis, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve';
import {
COMMON_PROPS,
isCommonProp,
type PropertyDef,
schemaForAsset,
} from '../../core/properties/PropertySchema';
import {
deviatesFromAsset,
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Menu, type MenuItem, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
@@ -34,13 +43,23 @@ const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(project: Project, sceneId: string, objectIds: readonly string[], objectId: string): string {
export function objectSignature(
project: Project,
sceneId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return `${object.parentId}|${object.templateId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',');
return (
`${object.parentId}|${object.templateId}|` +
schemaForAsset(asset)
.map((d) => `${d.key}:${d.kind}`)
.join(',')
);
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -57,11 +76,18 @@ export function buildObjectsSection(host: InspectorHost, sceneId: string, object
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
addGap(host);
host.body.add(new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(host.state, sceneId, objectIds) }));
host.body.add(
new Button('Remove from scene', {
variant: 'danger',
onClick: () => removeObjects(host.state, sceneId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
@@ -81,7 +107,13 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(nameRow.row);
const assetRow = labelledRow('Asset');
assetRow.row.add(new Button(asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }) }), 1);
assetRow.row.add(
new Button(asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: asset.id }),
}),
1,
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, scene, object));
@@ -90,12 +122,19 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const link = templateLinkOf(host.state.project, scene, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(new Button(link.asset.name, { variant: 'ghost', onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }) }), 1);
linkRow.row.add(
new Button(link.asset.name, {
variant: 'ghost',
onClick: () => host.state.select({ kind: 'asset', assetId: link.asset.id }),
}),
1,
);
host.body.add(linkRow.row);
}
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS) propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
@@ -107,8 +146,26 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
read: (o) => o.position,
write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
reset: () => (moved() ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)) }] : []),
apply: () => (moved() ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)) }] : []),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
},
]
: [],
apply: () =>
moved()
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
},
]
: [],
});
}
@@ -126,7 +183,15 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
reset: () => {
const current = currentObject(host.state, sceneId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [{ label: 'Reset to 0', onSelect: () => host.state.execute(new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 })) }];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
},
});
}
@@ -135,7 +200,8 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
const schema = schemaForAsset(asset);
const values = resolveSceneObjectProps(asset, object);
const own = schema.filter((def) => !isCommonProp(def.key));
if (own.length > 0 || asset.type === 'behavior') addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, def, values);
markObjectRows(host, object, asset, link);
@@ -149,7 +215,12 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (asset.type !== 'collider') {
addGap(host);
host.body.add(new Button('+ Add collider', { variant: 'ghost', onClick: () => addCollider(host.state, sceneId, objectId) }));
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => addCollider(host.state, sceneId, objectId),
}),
);
}
}
@@ -184,7 +255,13 @@ interface ComponentSpec<V extends object> {
* The row edits one axis, the command carries the whole vector: the other components come
* from the target after the preview was taken back, so they are the pre-preview ones.
*/
function componentRow<V extends object>(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec<V>): void {
function componentRow<V extends object>(
host: InspectorHost,
sceneId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
@@ -214,7 +291,13 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
* override is what makes the object follow the asset again: the template is only read when
* the container is placed, never at draw time.
*/
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
function propertyRow(
host: InspectorHost,
sceneId: string,
objectId: string,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const row = createFieldRow(
def,
@@ -229,15 +312,46 @@ function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Reset to container', onSelect: () => host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Reset to asset', onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Reset to asset',
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
() => {
const { template, asset } = bases();
return [
...(template ? [{ label: 'Apply to container', onSelect: () => host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)) }] : []),
...(asset ? [{ label: 'Apply to asset', onSelect: () => host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)) }] : []),
...(template
? [
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
},
]
: []),
...(asset
? [
{
label: 'Apply to asset',
onSelect: () =>
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
},
]
: []),
];
},
);
@@ -256,11 +370,16 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
const parent = findObject(scene, object.parentId);
const button = new Button(`${parent ? nameOf(parent) : 'None'} ▾`);
button.onClick = () => {
const items: MenuItem[] = [{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) }];
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, 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: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)) });
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
});
}
new Menu(items).openBelow(button);
};
@@ -273,11 +392,22 @@ function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject)
* the template (position per axis, overrides), for any other object one from the asset. The
* editor offset has one base only – no offset at all.
*/
function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset, link: TemplateLink | undefined): void {
for (const axis of POSITION_AXES) host.fields.get(axis)?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
function markObjectRows(
host: InspectorHost,
object: SceneObject,
asset: Asset,
link: TemplateLink | undefined,
): void {
for (const axis of POSITION_AXES)
host.fields
.get(axis)
?.setModified(Boolean(link) && object.position[axis] !== link!.template.position[axis]);
for (const axis of OFFSET_AXES)
host.fields.get(offsetKey(axis))?.setModified(object.editorOffset[axis] !== 0);
for (const def of schemaForAsset(asset)) {
const deviates = link ? deviatesFromTemplate(object, link.template, def.key) : deviatesFromAsset(object, asset, def.key);
const deviates = link
? deviatesFromTemplate(object, link.template, def.key)
: deviatesFromAsset(object, asset, def.key);
host.fields.get(def.key)?.setModified(deviates);
}
}
@@ -287,7 +417,12 @@ function markObjectRows(host: InspectorHost, object: SceneObject, asset: Asset,
* differs: the template of the container the object belongs to, and the asset behind it.
* Evaluated on demand so a menu never acts on a stale snapshot.
*/
function deviations(state: InspectorHost['state'], sceneId: string, objectId: string, key: string): { template: boolean; asset: boolean } {
function deviations(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const asset = object && findAsset(state.project, object.assetId);
@@ -300,14 +435,23 @@ function deviations(state: InspectorHost['state'], sceneId: string, objectId: st
}
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(state: InspectorHost['state'], sceneId: string, objectId: string, axis: Axis): boolean {
function positionMoved(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
axis: Axis,
): boolean {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
}
function currentObject(state: InspectorHost['state'], sceneId: string, objectId: string): SceneObject | undefined {
function currentObject(
state: InspectorHost['state'],
sceneId: string,
objectId: string,
): SceneObject | undefined {
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
}
+4 -1
View File
@@ -27,7 +27,10 @@ export interface PreviewedValue<Target, Model = PropertyValue> {
* value from before the preview is written back first – including "the key did not exist",
* via `remove` – and only then is the command executed.
*/
export function previewingHandlers<Target, Model = PropertyValue>(host: InspectorHost, spec: PreviewedValue<Target, Model>): FieldHandlers {
export function previewingHandlers<Target, Model = PropertyValue>(
host: InspectorHost,
spec: PreviewedValue<Target, Model>,
): FieldHandlers {
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
let previewed = false;
let before: Model | undefined;
+21 -4
View File
@@ -13,12 +13,21 @@ export class AssetPanel extends Panel {
private readonly list = new ListView();
private readonly scroll = new ScrollContainer();
constructor(private readonly state: EditorState, private readonly placementPoint: () => Vec2) {
constructor(
private readonly state: EditorState,
private readonly placementPoint: () => Vec2,
) {
super({ title: 'Assets', padding: 0, gap: 0 });
this.minWidth = 160;
const add = new Button('+', { variant: 'ghost', width: 28 });
add.onClick = () => new Menu(ASSET_TYPES.map((type) => ({ label: ASSET_TYPE_LABELS[type], onSelect: () => this.createAsset(type) }))).openBelow(add);
add.onClick = () =>
new Menu(
ASSET_TYPES.map((type) => ({
label: ASSET_TYPE_LABELS[type],
onSelect: () => this.createAsset(type),
})),
).openBelow(add);
this.header.add(add);
this.list.emptyText = 'No assets – press +';
@@ -81,9 +90,17 @@ export class AssetPanel extends Panel {
if (!asset) return;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({ label: 'Set as active scene', onSelect: () => this.state.execute(new SetActiveSceneCommand(id)), disabled: this.state.project.activeSceneId === id });
items.push({
label: 'Set as active scene',
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
disabled: this.state.project.activeSceneId === id,
});
} else {
items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene });
items.push({
label: 'Place in scene',
onSelect: () => this.place(id),
disabled: !this.state.activeScene,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
+27 -6
View File
@@ -49,7 +49,10 @@ export class HierarchyPanel extends Panel {
state.on('project', () => this.refresh());
// A different project brings different objects – start folded again.
state.on('load', () => { this.expanded.clear(); this.refresh(); });
state.on('load', () => {
this.expanded.clear();
this.refresh();
});
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
this.refresh();
}
@@ -88,13 +91,31 @@ export class HierarchyPanel extends Panel {
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{ label: 'Add collider', disabled: !type || type === 'collider' || many, onSelect: () => addCollider(state, scene.id, id) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
},
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
onSelect: () => addCollider(state, scene.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
onSelect: () => createContainer(state, scene.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
{
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
danger: true,
onSelect: () => removeObjects(state, scene.id, selected),
},
]).open(x, y);
}
+13 -3
View File
@@ -34,7 +34,10 @@ export class Toolbar extends HStack {
private readonly notice: Label;
private readonly fps: Label;
constructor(private readonly state: EditorState, actions: ToolbarActions) {
constructor(
private readonly state: EditorState,
actions: ToolbarActions,
) {
super({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
this.minHeight = Theme.controlHeight + Theme.padding * 2;
this.addChildAt(this.bg, 0);
@@ -53,7 +56,10 @@ export class Toolbar extends HStack {
this.add(new Spacer(Theme.padding));
this.add(new Label('Scene', { style: 'muted' }));
this.sceneButton = new Button('—', { width: 160, onClick: () => actions.openSceneMenu(this.sceneButton) });
this.sceneButton = new Button('—', {
width: 160,
onClick: () => actions.openSceneMenu(this.sceneButton),
});
this.add(this.sceneButton);
this.add(new Spacer(Theme.padding));
@@ -63,7 +69,11 @@ export class Toolbar extends HStack {
this.add(this.redoButton);
this.add(new Spacer(), 1);
this.playButton = new Button('▶ Play', { variant: 'primary', width: 110, onClick: () => actions.togglePlay() });
this.playButton = new Button('▶ Play', {
variant: 'primary',
width: 110,
onClick: () => actions.togglePlay(),
});
this.add(this.playButton);
this.add(new Spacer(), 1);
+15 -4
View File
@@ -194,7 +194,10 @@ export class EditorState {
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]);
// The type says `objectIds` is always there – untyped callers are exactly why this guard exists.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
@@ -204,7 +207,9 @@ export class EditorState {
toggleObject(sceneId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
}
@@ -227,7 +232,8 @@ export class EditorState {
const asset = findAsset(this.project, s.assetId);
// Fall back to the behavior asset so the graph stays open when its node vanished (undo).
if (!asset || asset.type !== 'behavior') this.select(null);
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId)) this.select({ kind: 'asset', assetId: s.assetId });
else if (!asset.graph.nodes.some((n) => n.id === s.nodeId))
this.select({ kind: 'asset', assetId: s.assetId });
}
}
@@ -262,7 +268,12 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (!a || !b || 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 && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]);
return (
a.sceneId === b.sceneId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;