Split the object inspector, and sweep out the section banners

objectSection.ts had grown to 559 lines around three separable parts: the
queries, the vector rows and the parent row. Four object commands repeated the
same capture/restore preamble – applyCommands stays out, its five carry extra
undo state whose restore order matters.

58 dashed banner comments are gone. The nine labels in nodes/index.ts stay:
they are the only structure a flat 90-entry array has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-10 07:14:45 +02:00
1 parent 11981b40d0
commit 3b3f374e10
11 files changed
+258 -244

No files matched your search

+12 -10
View File
@@ -199,7 +199,7 @@ shown on the node and editable in the inspector.
- **Header bar** (the same one the Texture and Container tabs have): the behavior picker,
**Add node…** (the palette, dropping the node in the middle of the view – the right-click nobody
finds), **Fit** (which zooms out far enough to hold every node, measuring each by its own drawn
height – `editor/graph/framing.ts`), and the size of the graph as "N nodes · M links".
height – `editor/framing.ts`), and the size of the graph as "N nodes · M links".
- **Selection-following, not selection-bound**: selecting a behavior asset, a placed behavior
or one of its nodes shows that graph; a selection about anything else (a sprite, nothing at
all) leaves the tab on the behavior you were editing, because checking a sprite's position
@@ -496,25 +496,27 @@ selected and switched back on.
```
web/index.html Vite source page (dev) index.html built page (generated, served)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), container (the cycle guard)
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), instance (container content resolved from its template), usages (where an asset is used), container (the cycle guard)
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups, and what an instance may not change), naming.ts
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
src/core/store/ serialize (untrusted JSON → Project), http (one API call), HttpProjectStore
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
src/ui/ widget toolkit (generic, knows nothing about assets)
src/viewport/ WorldViewport base + SceneViewport/GameViewport, view (transforms),
items (what to draw), symbols (how to draw it)
src/viewport/ view (transforms), items (what to draw), symbols (how to draw it),
textureFill, TextureLibrary – no editor concepts, so tests reach it freely
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
state, actions, shortcuts, debugHook, autosave
state, actions, shortcuts, debugHook, autosave, framing (fit/marquee maths),
AssetTabEditor (the shared shell of the three asset tabs)
src/editor/viewport/ WorldViewport base + SceneViewport/GameViewport – editing tools, hence not in src/viewport/
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
one section per selection kind (asset/object/node)
one section per selection kind (asset/object/node), objectQueries/vectorRows/parentRow
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette
src/editor/graph/ GraphEditor (header + picker), GraphCanvas, NodeView, NodePalette, folding (hidden links)
src/editor/container/ ContainerEditor (header + picker), ContainerCanvas (a SceneViewport on a template)
src/editor/texture/ TextureEditor (header + picker), TextureCanvas (picture + frames), SliceDialog, sheetView (the grid maths)
api/ PHP backend data/projects/ persisted projects
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/), smoke-jumprun, smoke-tetris, lib/
tests/ Vitest unit tests tools/ seed-*, e2e (steps in tools/e2e/, rig in tools/e2e/lib/), smoke-*, lib/
```
The toolbar's right edge shows the frame rate (frames counted over a 0.5 s window, updated
@@ -542,7 +544,7 @@ npm run format # Prettier over src, tests and tools
npm run check # typecheck + lint + format:check + tests, what CI would run
npm run build # writes dist/ and regenerates index.html
export GM_REMOTE=user@host # optional: run the browser elsewhere; see below
node tools/seed-e2e.mjs && node tools/e2e.mjs # 415 editor checks, screenshots in /tmp/gm-e2e-*.png
node tools/seed-e2e.mjs && node tools/e2e.mjs # 416 editor checks, screenshots in /tmp/gm-e2e-*.png
GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 – a subset while working on one step
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs # 24 checks: the example game, played end to end
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs # 19 checks: the Tetris example, played end to end
+62 -50
View File
@@ -1,6 +1,12 @@
import { newId } from '../ids';
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
import { captureObject, isLiveInstance, type ObjectMemo, restoreObject } from '../model/instance';
import {
captureObject,
isLiveInstance,
type ObjectMemo,
type ObjectRef,
restoreObject,
} from '../model/instance';
import { placementRejection } from '../model/placement';
import { type Position, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
import type { Project } from '../model/Project';
@@ -17,6 +23,33 @@ import {
requireStructural,
} from './require';
/**
* An edit to one placed object. The base captures the object before the edit and puts it back on
* undo, so a subclass only says what changes. `applyCommands` deliberately does not use this: its
* commands carry extra undo state whose restore order matters.
*/
abstract class ObjectEditCommand implements Command {
abstract readonly label: string;
private memo: ObjectMemo | undefined;
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
) {}
protected abstract apply(ref: ObjectRef, project: Project): void;
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
this.apply(ref, project);
}
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
/**
* The position is a world position; with a parent it is converted to the parent's local space.
* Placing a container adds the container object alone – its content is resolved from the template
@@ -88,91 +121,70 @@ export class RemoveSceneObjectCommand extends RemoveSceneObjectsCommand {
* The previous position is read at execution time, so a caller that previewed the move must
* restore the model before executing.
*/
export class MoveSceneObjectCommand implements Command {
export class MoveSceneObjectCommand extends ObjectEditCommand {
readonly label = 'Move object';
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
private readonly objectId: string,
holderId: string,
objectId: string,
private readonly position: Position,
) {}
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setPosition(this.position);
) {
super(holderId, objectId);
}
undo(): void {
if (this.memo) restoreObject(this.memo);
protected override apply(ref: ObjectRef): void {
ref.setPosition(this.position);
}
}
/** Editor-only: undoable like any other edit, but it changes nothing the game can see. */
export class SetEditorOffsetCommand implements Command {
export class SetEditorOffsetCommand extends ObjectEditCommand {
readonly label = 'Editor offset';
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
private readonly objectId: string,
holderId: string,
objectId: string,
private readonly offset: Vec2,
) {}
) {
super(holderId, objectId);
}
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
protected override apply(ref: ObjectRef): void {
ref.setEditorOffset(this.offset);
}
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
export class RenameSceneObjectCommand implements Command {
export class RenameSceneObjectCommand extends ObjectEditCommand {
readonly label = 'Rename object';
private memo: ObjectMemo | undefined;
constructor(
private readonly holderId: string,
private readonly objectId: string,
holderId: string,
objectId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
ref.setName(this.name);
) {
super(holderId, objectId);
}
undo(): void {
if (this.memo) restoreObject(this.memo);
protected override apply(ref: ObjectRef): void {
ref.setName(this.name);
}
}
abstract class OverrideCommand implements Command {
private memo: ObjectMemo | undefined;
abstract class OverrideCommand extends ObjectEditCommand {
protected constructor(
private readonly holderId: string,
private readonly objectId: string,
holderId: string,
objectId: string,
protected readonly key: string,
readonly label: string,
) {}
) {
super(holderId, objectId);
}
protected abstract nextValue(object: SceneObject, project: Project): PropertyValue | undefined;
execute(project: Project): void {
const ref = requireObjectRef(project, this.holderId, this.objectId);
this.memo = captureObject(ref);
protected override apply(ref: ObjectRef, project: Project): void {
ref.setOverride(this.key, this.nextValue(ref.object, project));
}
undo(): void {
if (this.memo) restoreObject(this.memo);
}
}
export class SetOverrideCommand extends OverrideCommand {
-6
View File
@@ -3,8 +3,6 @@ import type { PropertyDef } from '../../properties/PropertySchema';
import type { NodeDef, PortDef } from '../NodeRegistry';
import { asBoolean, asNumber } from '../values';
// --- ports -----------------------------------------------------------------------------------
export const exec = (key = 'exec', label = ''): PortDef => ({ key, label, type: 'exec' });
export const num = (key: string, label = key): PortDef => ({ key, label, type: 'number' });
export const bool = (key: string, label = key): PortDef => ({ key, label, type: 'boolean' });
@@ -23,8 +21,6 @@ export const collisionOutputs = (): PortDef[] => [
obj('collider'),
];
// --- params ----------------------------------------------------------------------------------
export const keyParam: PropertyDef = { key: 'key', label: 'Key', kind: 'key', default: 'ArrowRight' };
/** Options are empty on purpose: the graph inspector fills in the behavior's variables. */
@@ -39,8 +35,6 @@ export const variableParam: PropertyDef = {
/** No `assetTypes`: any placeable asset is a valid answer here. */
export const assetParam: PropertyDef = { key: 'asset', label: 'Asset', kind: 'asset', default: '' };
// --- node factories --------------------------------------------------------------------------
export function binaryMath(type: string, description: string, fn: (a: number, b: number) => number): NodeDef {
return {
type,
+77
View File
@@ -0,0 +1,77 @@
/**
* What the object inspector asks about the object it is showing. Every one of these resolves from
* the project on demand: a resolved holder is a throwaway copy, so a row that captured one would
* answer for the project as it stood when the row was built.
*/
import { findObject } from '../../core/model/hierarchy';
import {
type InstanceTarget,
instanceTargetOf,
objectRef,
type ObjectRef,
resolvedHolder,
} from '../../core/model/instance';
import type { Axis, PropertyBag } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import {
deviatesFromAsset,
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import type { InspectorHost } from './host';
type State = InspectorHost['state'];
/** The holder with the content of placed containers resolved in – what the inspector addresses. */
export function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
const holder = findObjectHolder(project, holderId);
return holder && resolvedHolder(project, holder);
}
export function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
const holder = findObjectHolder(project, holderId);
return holder && instanceTargetOf(project, holder, objectId);
}
export function currentRef(state: State, holderId: string, objectId: string): ObjectRef | undefined {
const holder = findObjectHolder(state.project, holderId);
return holder && objectRef(state.project, holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
export function currentProps(state: State, holderId: string, objectId: string): PropertyBag | null {
const object = currentRef(state, holderId, objectId)?.object;
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
/**
* What the ↺/↑ menus may offer, each only when it actually differs. Reset goes to the nearest base
* only: dropping a deviation inside a container lands on the template, never past it. Evaluated on
* demand so a menu never acts on a stale snapshot.
*/
export function deviations(
state: State,
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean; applyToAsset: boolean } {
const view = viewOf(state.project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
const target = targetOf(state.project, holderId, objectId);
const fromAsset = deviatesFromAsset(object, asset, key);
return {
template: Boolean(target) && deviatesFromTemplate(object, target!.base, key),
asset: !target && fromAsset,
applyToAsset: fromAsset,
};
}
/** Whether the object sits somewhere else on `axis` than its container template says. */
export function positionMoved(state: State, holderId: string, objectId: string, axis: Axis): boolean {
const target = targetOf(state.project, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
return Boolean(target && object) && object!.position[axis] !== target!.base.position[axis];
}
+8 -157
View File
@@ -5,7 +5,6 @@ import {
RevertOverrideToTemplateCommand,
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import type { Command } from '../../core/commands/Command';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import {
ClearOverrideCommand,
@@ -13,21 +12,18 @@ import {
RenameSceneObjectCommand,
SetEditorOffsetCommand,
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset } from '../../core/model/Asset';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import { findObject } from '../../core/model/hierarchy';
import {
type InstanceTarget,
instanceTargetOf,
isLiveInstance,
isTemplateContent,
objectRef,
type ObjectRef,
resolvedHolder,
} from '../../core/model/instance';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { PropertyValue } from '../../core/model/Properties';
import { findAsset, findObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -41,28 +37,15 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button, Dropdown, Label, Menu, type MenuItem, TextField } from '../../ui';
import { Button, TextField } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
import { createFieldRow, type FieldRow, labelledRow } from './fieldFactory';
import { addGap, addNote, addTitle, type InspectorHost } from './host';
import { parentRow } from './parentRow';
import { previewingHandlers } from './previewHandlers';
const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z'];
const OFFSET_AXES = ['x', 'y'] as const;
const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** The holder with the content of placed containers resolved in – what the inspector addresses. */
function viewOf(project: Project, holderId: string): ObjectHolder | undefined {
const holder = findObjectHolder(project, holderId);
return holder && resolvedHolder(project, holder);
}
function targetOf(project: Project, holderId: string, objectId: string): InstanceTarget | undefined {
const holder = findObjectHolder(project, holderId);
return holder && instanceTargetOf(project, holder, objectId);
}
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
@@ -124,7 +107,6 @@ export function buildObjectsSection(host: InspectorHost, holderId: string, objec
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const { project } = host.state;
const view = viewOf(project, holderId);
@@ -311,48 +293,6 @@ export function refreshObjectSection(host: InspectorHost, holderId: string, obje
return true;
}
interface ComponentSpec<V extends object> {
key: string;
label: string;
axis: keyof V & string;
read: (ref: ObjectRef) => V;
write: (ref: ObjectRef, value: V) => void;
command: (value: V) => Command;
reset?: FieldActions;
apply?: FieldActions;
}
/**
* A number row editing one component of a vector on the object (position, editor offset).
* 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,
holderId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<ObjectRef, V>(host, {
target: () => currentRef(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (ref) => ({ ...spec.read(ref) }),
write: spec.write,
toModel: (next, ref) => withAxis(spec.read(ref), next),
commit: spec.command,
}),
{ reset: spec.reset, apply: spec.apply },
);
host.fields.set(spec.key, row);
host.body.add(row.row);
}
/**
* One editable property of a placed object: writes an override, offers ↺ (back to the asset
* or, inside a container, to the template) and ↑ (apply upwards).
@@ -444,43 +384,6 @@ function propertyRow(
return row;
}
/**
* "Parent: <name> ▾" – picks another object of the same scene or container, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner. Content of a
* placed container only shows its parent: where it hangs is the template's business.
*/
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(holder, object.parentId);
const name = parent ? nameOf(parent) : 'None';
if (fixed) {
row.add(new Label(name, { style: 'muted' }), 1);
return row;
}
const button = new Dropdown(name);
button.onClick = () => {
// Resolved anew on every open: `holder` is a derived copy of the scene, so one captured when
// the row was built lists the objects of that moment, not the ones there now.
const current = viewOf(host.state.project, holder.id) ?? holder;
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(current)) {
if (isSelfOrAncestor(current, object.id, candidate.id)) continue;
const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${label}` : label,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
// Searchable: this lists every object of the scene or container, which grows with the project.
new Menu(items, { searchable: true }).openBelow(button);
};
row.add(button, 1);
return row;
}
/**
* Gold marks exactly what the ↺/↑ buttons offer: for content of a placed container a deviation
* from the template (position per axis, overrides), for any other object one from the asset. The
@@ -505,55 +408,3 @@ function markObjectRows(
host.fields.get(def.key)?.setModified(deviates);
}
}
/**
* What the ↺/↑ menus may offer, each only when it actually differs. Reset goes to the nearest base
* only: dropping a deviation inside a container lands on the template, never past it. Evaluated on
* demand so a menu never acts on a stale snapshot.
*/
function deviations(
state: InspectorHost['state'],
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean; applyToAsset: boolean } {
const view = viewOf(state.project, holderId);
const object = view && findObject(view, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!view || !object || !asset) return { template: false, asset: false, applyToAsset: false };
const target = targetOf(state.project, holderId, objectId);
const fromAsset = deviatesFromAsset(object, asset, key);
return {
template: Boolean(target) && deviatesFromTemplate(object, target!.base, key),
asset: !target && fromAsset,
applyToAsset: fromAsset,
};
}
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(
state: InspectorHost['state'],
holderId: string,
objectId: string,
axis: Axis,
): boolean {
const target = targetOf(state.project, holderId, objectId);
const object = currentRef(state, holderId, objectId)?.object;
return Boolean(target && object) && object!.position[axis] !== target!.base.position[axis];
}
function currentRef(
state: InspectorHost['state'],
holderId: string,
objectId: string,
): ObjectRef | undefined {
const holder = findObjectHolder(state.project, holderId);
return holder && objectRef(state.project, holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentRef(state, holderId, objectId)?.object;
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+45
View File
@@ -0,0 +1,45 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import { Dropdown, Label, Menu, type MenuItem } from '../../ui';
import { labelledRow } from './fieldFactory';
import type { InspectorHost } from './host';
import { viewOf } from './objectQueries';
/**
* "Parent: <name> ▾" – picks another object of the same scene or container, or none. The object's own
* subtree is excluded (that would be a cycle). For behaviors the parent is the owner. Content of a
* placed container only shows its parent: where it hangs is the template's business.
*/
export function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject, fixed: boolean) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(holder, object.parentId);
const name = parent ? nameOf(parent) : 'None';
if (fixed) {
row.add(new Label(name, { style: 'muted' }), 1);
return row;
}
const button = new Dropdown(name);
button.onClick = () => {
// Resolved anew on every open: `holder` is a derived copy of the scene, so one captured when
// the row was built lists the objects of that moment, not the ones there now.
const current = viewOf(host.state.project, holder.id) ?? holder;
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(current)) {
if (isSelfOrAncestor(current, object.id, candidate.id)) continue;
const label = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${label}` : label,
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
// Searchable: this lists every object of the scene or container, which grows with the project.
new Menu(items, { searchable: true }).openBelow(button);
};
row.add(button, 1);
return row;
}
+54
View File
@@ -0,0 +1,54 @@
import type { Command } from '../../core/commands/Command';
import type { ObjectRef } from '../../core/model/instance';
import type { Axis, PropertyValue } from '../../core/model/Properties';
import { createFieldRow, type FieldActions } from './fieldFactory';
import type { InspectorHost } from './host';
import { currentRef } from './objectQueries';
import { previewingHandlers } from './previewHandlers';
export const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z'];
export const OFFSET_AXES = ['x', 'y'] as const;
export const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
export interface ComponentSpec<V extends object> {
key: string;
label: string;
axis: keyof V & string;
read: (ref: ObjectRef) => V;
write: (ref: ObjectRef, value: V) => void;
command: (value: V) => Command;
reset?: FieldActions;
apply?: FieldActions;
}
/**
* A number row editing one component of a vector on the object (position, editor offset).
* 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.
*/
export function componentRow<V extends object>(
host: InspectorHost,
holderId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
): void {
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
const row = createFieldRow(
host,
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<ObjectRef, V>(host, {
target: () => currentRef(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (ref) => ({ ...spec.read(ref) }),
write: spec.write,
toModel: (next, ref) => withAxis(spec.read(ref), next),
commit: spec.command,
}),
{ reset: spec.reset, apply: spec.apply },
);
host.fields.set(spec.key, row);
host.body.add(row.row);
}
-8
View File
@@ -102,8 +102,6 @@ export class EditorState {
for (const listener of this.listeners.get(event) ?? []) listener();
}
// --- project lifecycle ---------------------------------------------------------------------
loadProject(project: Project, revision: number): void {
this.edit = null;
this.mode = 'edit';
@@ -165,8 +163,6 @@ export class EditorState {
return findScene(this.project, this.project.activeSceneId);
}
// --- editing -------------------------------------------------------------------------------
/**
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
* In play mode the command changes the runtime copy only (see `enterPlay`).
@@ -234,8 +230,6 @@ export class EditorState {
this.emit('save');
}
// --- selection -----------------------------------------------------------------------------
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -350,8 +344,6 @@ export class EditorState {
return assetId ? findAsset(this.project, assetId) : undefined;
}
// --- status --------------------------------------------------------------------------------
setSaveStatus(status: SaveStatus): void {
if (this.saveStatus === status) return;
this.saveStatus = status;
-6
View File
@@ -143,8 +143,6 @@ export class TextureCanvas extends Widget {
return { x: origin.x + local.x, y: origin.y + local.y };
}
// --- model ------------------------------------------------------------------------------
private asset(): TextureAsset | null {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset && asset.type === 'texture' ? asset : null;
@@ -159,8 +157,6 @@ export class TextureCanvas extends Widget {
return this.asset()?.frames.find((f) => f.id === frameId);
}
// --- pointer ----------------------------------------------------------------------------
private onPointerDown(e: FederatedPointerEvent): void {
const asset = this.asset();
// Without a known picture size `normalizeFrameRect` has no bounds, and would keep a frame
@@ -312,8 +308,6 @@ export class TextureCanvas extends Widget {
this.cursor = over ? 'move' : 'crosshair';
}
// --- drawing ----------------------------------------------------------------------------
override redraw(): void {
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
-6
View File
@@ -82,8 +82,6 @@ export class World {
return this.elapsed;
}
// --- model sync ------------------------------------------------------------------------------
/**
* Makes the entities match the scene: one entity per placed object (created on demand,
* dropped when the object is gone), positions, names and resolved properties taken from
@@ -215,8 +213,6 @@ export class World {
return { structure: spawned || scene.objects.length !== before, values };
}
// --- simulation ------------------------------------------------------------------------------
/** Registers a system; it starts immediately and is ticked in registration order. */
addSystem(system: System): this {
this.systems.push(system);
@@ -260,8 +256,6 @@ export class World {
this.entities.length = 0;
}
// --- queries ---------------------------------------------------------------------------------
/**
* Whether a world point lies on an entity: on the colliders of its subtree when it has
* any, otherwise on the box of a sprite or text in it. Disabled parts never answer, so a
-1
View File
@@ -76,7 +76,6 @@ export class Input {
return this.released;
}
/** Keys currently held. */
get heldKeys(): ReadonlySet<string> {
return this.down;
}