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:
1 parent
11981b40d0
commit
3b3f374e10
11 files changed
+258
-244
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,7 +76,6 @@ export class Input {
|
||||
return this.released;
|
||||
}
|
||||
|
||||
/** Keys currently held. */
|
||||
get heldKeys(): ReadonlySet<string> {
|
||||
return this.down;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user