From 3b3f374e107af609bf4fa2e01329bea2943f49c9 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 07:14:45 +0200 Subject: [PATCH] Split the object inspector, and sweep out the section banners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 22 +-- src/core/commands/sceneObjectCommands.ts | 112 ++++++++------- src/core/graph/nodes/shared.ts | 6 - src/editor/inspector/objectQueries.ts | 77 +++++++++++ src/editor/inspector/objectSection.ts | 165 ++--------------------- src/editor/inspector/parentRow.ts | 45 +++++++ src/editor/inspector/vectorRows.ts | 54 ++++++++ src/editor/state.ts | 8 -- src/editor/texture/TextureCanvas.ts | 6 - src/runtime/World.ts | 6 - src/runtime/systems/Input.ts | 1 - 11 files changed, 258 insertions(+), 244 deletions(-) create mode 100644 src/editor/inspector/objectQueries.ts create mode 100644 src/editor/inspector/parentRow.ts create mode 100644 src/editor/inspector/vectorRows.ts diff --git a/README.md b/README.md index 32f940fd..2130d58b 100644 --- a/README.md +++ b/README.md @@ -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//.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 diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index bbc096d8..c897e14d 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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 { diff --git a/src/core/graph/nodes/shared.ts b/src/core/graph/nodes/shared.ts index aacbdf07..b519e2f8 100644 --- a/src/core/graph/nodes/shared.ts +++ b/src/core/graph/nodes/shared.ts @@ -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, diff --git a/src/editor/inspector/objectQueries.ts b/src/editor/inspector/objectQueries.ts new file mode 100644 index 00000000..f9bf3058 --- /dev/null +++ b/src/editor/inspector/objectQueries.ts @@ -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]; +} diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index ccbb1f8d..24985a8f 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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 { - 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( - host: InspectorHost, - holderId: string, - objectId: string, - value: number, - spec: ComponentSpec, -): 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(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: ▾" – 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; -} diff --git a/src/editor/inspector/parentRow.ts b/src/editor/inspector/parentRow.ts new file mode 100644 index 00000000..0a27bd09 --- /dev/null +++ b/src/editor/inspector/parentRow.ts @@ -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: ▾" – 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; +} diff --git a/src/editor/inspector/vectorRows.ts b/src/editor/inspector/vectorRows.ts new file mode 100644 index 00000000..5397d083 --- /dev/null +++ b/src/editor/inspector/vectorRows.ts @@ -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 { + 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( + host: InspectorHost, + holderId: string, + objectId: string, + value: number, + spec: ComponentSpec, +): 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(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); +} diff --git a/src/editor/state.ts b/src/editor/state.ts index c903bab7..ea525ad0 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/src/editor/texture/TextureCanvas.ts b/src/editor/texture/TextureCanvas.ts index 25de8ae0..1c172969 100644 --- a/src/editor/texture/TextureCanvas.ts +++ b/src/editor/texture/TextureCanvas.ts @@ -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); diff --git a/src/runtime/World.ts b/src/runtime/World.ts index 34581388..18332200 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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 diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index 3469d701..18f8f289 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -76,7 +76,6 @@ export class Input { return this.released; } - /** Keys currently held. */ get heldKeys(): ReadonlySet { return this.down; }