Add a Container tab and propagate template edits

The Container tab reuses SceneViewport against any object holder, so a
container template is edited exactly like a scene. Right-click no longer
changes the selection, which previously made nesting impossible: opening
the menu on a container loaded it, and "Place in container" then targeted
itself.

Template edits (move, rename, override) now reach already-placed instances
per slot, through a shared templateSync used by both the apply path and the
plain object commands. Copies the user has touched keep their own value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-08 20:19:54 +02:00
1 parent 6bedc948be
commit 9d93020bd8
35 files changed
+1452 -363

No files matched your search

+44 -10
View File
@@ -1,13 +1,15 @@
import type { Application } from 'pixi.js';
import { findAsset, findObjectHolder } from '../core/model/Project';
import type { ProjectStore } from '../core/store/ProjectStore';
import type { TextureStore } from '../core/store/TextureStore';
import { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
import { GameViewport } from '../viewport/GameViewport';
import { SceneViewport } from '../viewport/SceneViewport';
import { attachAutosave } from './autosave';
import { ContainerEditor } from './container/ContainerEditor';
import { GraphEditor } from './graph/GraphEditor';
import { Inspector } from './inspector/Inspector';
import { AssetPanel } from './panels/AssetPanel';
import { AssetPanel, type PlacementTarget } from './panels/AssetPanel';
import { HierarchyPanel } from './panels/HierarchyPanel';
import { Toolbar } from './panels/Toolbar';
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
@@ -17,9 +19,9 @@ import type { EditorState } from './state';
import { TextureEditor } from './texture/TextureEditor';
import { installTextureImport } from './textureImport';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture';
export type CentreTab = 'scene' | 'graph' | 'game' | 'texture' | 'container';
const TAB_ORDER = ['scene', 'game', 'graph', 'texture'] as const;
const TAB_ORDER = ['scene', 'game', 'graph', 'texture', 'container'] as const;
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
@@ -38,6 +40,7 @@ export class Editor implements PlayHost {
readonly gameView: GameViewport;
readonly graphEditor: GraphEditor;
readonly textureEditor: TextureEditor;
readonly containerEditor: ContainerEditor;
readonly hierarchy: HierarchyPanel;
tab: CentreTab = 'scene';
/** Frame rate, refreshed twice a second; null until the first measurement. */
@@ -60,7 +63,7 @@ export class Editor implements PlayHost {
constructor(
app: Application,
root: VStack,
state: EditorState,
private readonly state: EditorState,
store: ProjectStore,
textures: TextureStore,
) {
@@ -68,6 +71,7 @@ export class Editor implements PlayHost {
this.gameView = new GameViewport(state);
this.graphEditor = new GraphEditor(state);
this.textureEditor = new TextureEditor(state);
this.containerEditor = new ContainerEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.play = new PlaySession(state, this);
@@ -87,7 +91,7 @@ export class Editor implements PlayHost {
this.workspace = new HStack();
this.leftColumn = new VStack({ gap: 1 });
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter(), textures), 1);
this.leftColumn.add(new AssetPanel(state, () => this.placementTarget(), textures), 1);
installTextureImport(app.canvas, { state, textures });
this.leftColumn.add(this.hierarchy, 1);
@@ -103,6 +107,11 @@ export class Editor implements PlayHost {
width: 90,
onClick: () => this.showTab('texture'),
}),
container: new Button('Container', {
variant: 'ghost',
width: 90,
onClick: () => this.showTab('container'),
}),
};
for (const tab of TAB_ORDER) tabs.add(this.tabButtons[tab]);
this.centre.add(tabs);
@@ -110,9 +119,11 @@ export class Editor implements PlayHost {
this.centre.add(this.graphEditor, 1);
this.centre.add(this.gameView, 1);
this.centre.add(this.textureEditor, 1);
this.centre.add(this.containerEditor, 1);
this.graphEditor.visible = false;
this.gameView.visible = false;
this.textureEditor.visible = false;
this.containerEditor.visible = false;
this.tabButtons.scene.setToggled(true);
this.leftColumn.minWidth = this.leftWidth;
@@ -138,12 +149,18 @@ export class Editor implements PlayHost {
attachAutosave(state, store);
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
// A node and a frame exist only in their own tab, so selecting one switches to it;
// selecting the behavior or texture asset itself does not.
// A node, a frame and a template object exist only in their own tab, so selecting one
// switches to it; selecting the behavior, texture or container asset itself does not.
state.on('selection', () => {
const kind = state.selection?.kind;
if (kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
const s = state.selection;
if (s?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
if (s?.kind === 'frame' && this.tab !== 'texture') this.showTab('texture');
if (
s?.kind === 'object' &&
this.tab !== 'container' &&
findAsset(state.project, s.holderId)?.type === 'container'
)
this.showTab('container');
});
state.on('project', () => this.play.syncFromModel());
}
@@ -154,11 +171,28 @@ export class Editor implements PlayHost {
this.graphEditor.visible = tab === 'graph';
this.gameView.visible = tab === 'game';
this.textureEditor.visible = tab === 'texture';
this.containerEditor.visible = tab === 'container';
// The hierarchy lists what the visible tab edits; every other tab is about the scene.
this.hierarchy.showObjectsOf(
tab === 'container'
? () => findObjectHolder(this.state.project, this.containerEditor.currentAssetId)
: () => this.state.activeScene,
);
for (const key of TAB_ORDER) this.tabButtons[key].setToggled(key === tab);
this.centre.relayout();
ui().focus.validate();
}
/** Where the Assets panel places: into the container being edited, otherwise into the scene. */
private placementTarget(): PlacementTarget | undefined {
if (this.tab === 'container') {
const holder = findObjectHolder(this.state.project, this.containerEditor.currentAssetId);
return holder ? { holder, at: this.containerEditor.worldCenter() } : undefined;
}
const scene = this.state.activeScene;
return scene ? { holder: scene, at: this.viewport.worldCenter() } : undefined;
}
boot(): Promise<void> {
return this.projects.boot();
}
+8 -8
View File
@@ -12,24 +12,24 @@ import { Dialog } from '../ui';
import { type EditorState, objectSelection } from './state';
/** Groups the objects into a new container asset placed in their stead; selects the container. */
export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(sceneId, objectIds);
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(holderId, objectIds);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId]));
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Gives the object a collider child sized like it; selects the collider. */
export function addCollider(state: EditorState, sceneId: string, objectId: string): boolean {
const command = new AddColliderCommand(sceneId, objectId);
export function addCollider(state: EditorState, holderId: string, objectId: string): boolean {
const command = new AddColliderCommand(holderId, objectId);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId]));
state.select(objectSelection(holderId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
}
/** Asks before deleting an asset, spelling out what goes with it. */
+65
View File
@@ -0,0 +1,65 @@
import { Graphics } from 'pixi.js';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { Theme } from '../../ui';
import type { DrawItem } from '../../viewport/items';
import { SceneViewport } from '../../viewport/SceneViewport';
import { type View, worldToScreen } from '../../viewport/view';
import type { EditorState } from '../state';
/** Arm length of the origin cross, in screen pixels. */
const ORIGIN_ARM = 14;
/**
* The editable view of one container's template. Everything about picking, dragging, the marquee
* and the labels is the scene view's – the only difference is which asset holds the objects.
*/
export class ContainerCanvas extends SceneViewport {
private assetId = '';
private readonly originMark = new Graphics();
constructor(state: EditorState) {
super(state);
this.content.addChild(this.originMark);
}
get currentAssetId(): string {
return this.assetId;
}
show(assetId: string): void {
if (this.assetId === assetId) return;
this.assetId = assetId;
this.redraw();
}
protected override holder(): ObjectHolder | undefined {
const asset = this.assetId ? findAsset(this.state.project, this.assetId) : undefined;
return asset?.type === 'container' ? asset : undefined;
}
/** A container has no background of its own – painting one would invent a scene it is not in. */
protected override paintsBackground(): boolean {
return false;
}
protected override beforeItems(): void {
super.beforeItems();
this.originMark.clear();
}
/**
* The origin, marked because it carries meaning here: it is the point the container is held by
* when placed, so where the content sits relative to it is the thing being authored.
*/
protected override afterItems(items: DrawItem[], view: View): void {
super.afterItems(items, view);
const at = worldToScreen(view, 0, 0);
this.originMark
.moveTo(at.x - ORIGIN_ARM, at.y)
.lineTo(at.x + ORIGIN_ARM, at.y)
.moveTo(at.x, at.y - ORIGIN_ARM)
.lineTo(at.x, at.y + ORIGIN_ARM)
.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
this.originMark.circle(at.x, at.y, 3).stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.6 });
}
}
+124
View File
@@ -0,0 +1,124 @@
import { Graphics } from 'pixi.js';
import type { ContainerAsset } from '../../core/model/Asset';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, listContainers } from '../../core/model/Project';
import { Button, Dropdown, HStack, Label, Menu, type MenuItem, Spacer, Theme, VStack } from '../../ui';
import type { EditorState } from '../state';
import { ContainerCanvas } from './ContainerCanvas';
/** Room kept for the "N objects" readout, wide enough for a three-digit template. */
const INFO_WIDTH = 110;
/**
* The Container tab. It follows the selection, but keeps showing the last container when the
* selection moves elsewhere – clicking a sprite must not empty the tab being worked in.
*/
export class ContainerEditor extends VStack {
private readonly canvas: ContainerCanvas;
private readonly picker: Dropdown;
private readonly info: Label;
private readonly headerBg = new Graphics();
constructor(private readonly state: EditorState) {
super();
const header = new HStack({ gap: Theme.gap, padding: Theme.padding, align: 'center' });
header.minHeight = Theme.controlHeight + Theme.padding * 2;
header.addChildAt(this.headerBg, 0);
header.add(new Label('Container', { style: 'muted' }));
this.picker = new Dropdown('—', { width: 200, onClick: () => this.openPicker() });
header.add(this.picker);
header.add(new Button('Fit', { onClick: () => this.canvas.fit() }));
header.add(new Spacer(), 1);
this.info = new Label('', { style: 'small', align: 'right' });
// Reserved, not measured: a stack sizes the label once, from the empty text it was built with.
this.info.minWidth = INFO_WIDTH;
header.add(this.info);
this.canvas = new ContainerCanvas(state);
this.add(header);
this.add(this.canvas, 1);
state.on('selection', () => this.followSelection());
state.on('project', () => this.refresh());
state.on('load', () => {
this.canvas.show('');
this.followSelection();
});
this.followSelection();
}
/** The container currently shown – the check scripts read this. */
get currentAssetId(): string {
return this.canvas.currentAssetId;
}
/** For the check scripts: the picker's width must not follow the name it happens to show. */
get pickerWidth(): number {
return this.picker.w;
}
show(assetId: string): void {
this.canvas.show(assetId);
this.refresh();
}
/** Middle of the view – where the Assets panel drops what is placed into this template. */
worldCenter(): Vec2 {
return this.canvas.worldCenter();
}
/** Where a template object is drawn, in global screen coordinates – for the check scripts. */
objectScreen(objectId: string): { x: number; y: number } | null {
return this.canvas.objectScreen(objectId);
}
/** The drawing area without the header, so a check can ask whether a point is on screen. */
canvasRect(): { x: number; y: number; w: number; h: number } {
const p = this.canvas.getGlobalPosition();
return { x: p.x, y: p.y, w: this.canvas.w, h: this.canvas.h };
}
private followSelection(): void {
const id = this.state.selectedContainerId;
// Null merely means "the selection is about something else"; the tab keeps its subject.
if (id) this.canvas.show(id);
this.refresh();
}
private asset(): ContainerAsset | null {
const asset = findAsset(this.state.project, this.canvas.currentAssetId);
return asset && asset.type === 'container' ? asset : null;
}
private containers(): ContainerAsset[] {
return listContainers(this.state.project).sort((a, b) => a.name.localeCompare(b.name));
}
private openPicker(): void {
const current = this.canvas.currentAssetId;
const items: MenuItem[] = this.containers().map((asset) => ({
label: asset.id === current ? `● ${asset.name}` : asset.name,
onSelect: () => this.state.select({ kind: 'asset', assetId: asset.id }),
}));
if (items.length === 0) items.push({ label: 'No containers yet', onSelect: () => undefined });
new Menu(items, { searchable: true }).openBelow(this.picker);
}
private refresh(): void {
// A deleted container leaves the canvas empty; fall back to whatever container is left.
if (!this.asset()) this.canvas.show(this.containers()[0]?.id ?? '');
const asset = this.asset();
this.picker.setText(asset ? asset.name : 'No container');
const count = asset?.objects.length ?? 0;
this.info.setText(asset ? `${count} ${count === 1 ? 'object' : 'objects'}` : '');
}
override redraw(): void {
super.redraw();
this.headerBg.clear();
const h = Theme.controlHeight + Theme.padding * 2;
this.headerBg.rect(0, 0, this.w, h).fill(Theme.colors.panelHeader);
this.headerBg.rect(0, h - 1, this.w, 1).fill(Theme.colors.border);
}
}
+9 -1
View File
@@ -8,7 +8,7 @@ import type { EditorState } from './state';
* visible text and to read editor state.
*/
export function installDebugHook(app: Application, state: EditorState, editor: Editor): void {
const { viewport, gameView, graphEditor, textureEditor, hierarchy } = editor;
const { viewport, gameView, graphEditor, textureEditor, containerEditor, hierarchy } = editor;
const centreOf = (widget: { getGlobalPosition(): { x: number; y: number }; w: number; h: number }) => {
const p = widget.getGlobalPosition();
return { x: p.x + widget.w / 2, y: p.y + widget.h / 2 };
@@ -135,6 +135,14 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
/** Where an image pixel of the shown sheet sits on screen (frames are dragged in image coordinates). */
pointAt: (x: number, y: number) => textureEditor.imageToScreen(x, y),
},
container: {
current: () => containerEditor.currentAssetId,
pickerWidth: () => containerEditor.pickerWidth,
center: () => centreOf(containerEditor),
rect: () => containerEditor.canvasRect(),
/** Where a template object is drawn, so a check can click or sample it. */
objectScreen: (id: string) => containerEditor.objectScreen(id),
},
world: () => editor.worldSnapshot(),
/** Rows the hierarchy shows – collapsed subtrees are not in here. */
hierarchyOrder: () => hierarchy.rowOrder(),
+4 -4
View File
@@ -72,8 +72,8 @@ export class Inspector extends Panel {
else if (s.kind === 'asset') buildAssetSection(this.host, s.assetId);
else if (s.kind === 'node') buildNodeSection(this.host, s.assetId, s.nodeId);
else if (s.kind === 'frame') buildFrameSection(this.host, s.assetId, s.frameId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.sceneId, s.objectIds);
else buildObjectSection(this.host, s.sceneId, s.objectId);
else if (s.objectIds.length > 1) buildObjectsSection(this.host, s.holderId, s.objectIds);
else buildObjectSection(this.host, s.holderId, s.objectId);
this.scroll.setContent(this.host.body);
}
@@ -83,7 +83,7 @@ export class Inspector extends Panel {
if (s.kind === 'asset') return assetSignature(this.state.project, s.assetId);
if (s.kind === 'node') return nodeSignature(this.state.project, s.assetId, s.nodeId);
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
return objectSignature(this.state.project, s.sceneId, s.objectIds, s.objectId);
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
}
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
@@ -101,7 +101,7 @@ export class Inspector extends Panel {
? refreshNodeSection(this.host, s.assetId, s.nodeId)
: s.kind === 'frame'
? refreshFrameSection(this.host, s.assetId, s.frameId)
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.sceneId, s.objectId);
: s.objectIds.length > 1 || refreshObjectSection(this.host, s.holderId, s.objectId);
if (!ok) this.rebuild();
}
}
+73 -73
View File
@@ -14,11 +14,11 @@ import {
SetOverrideCommand,
SetParentCommand,
} from '../../core/commands/sceneObjectCommands';
import type { Asset, SceneAsset } from '../../core/model/Asset';
import type { Asset } from '../../core/model/Asset';
import { type TemplateLink, templateLinkOf } from '../../core/model/container';
import { findObject, isSelfOrAncestor, treeOrder } from '../../core/model/hierarchy';
import type { Axis, PropertyBag, PropertyValue } from '../../core/model/Properties';
import { findAsset, findScene, type Project } from '../../core/model/Project';
import { findAsset, findObjectHolder, type ObjectHolder, type Project } from '../../core/model/Project';
import type { SceneObject } from '../../core/model/SceneObject';
import {
COMMON_PROPS,
@@ -46,13 +46,13 @@ const offsetKey = (axis: 'x' | 'y') => `editorOffset${axis.toUpperCase()}`;
/** Structure-defining facts of an object selection: parent, template link and schema. */
export function objectSignature(
project: Project,
sceneId: string,
holderId: string,
objectIds: readonly string[],
objectId: string,
): string {
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
const scene = findScene(project, sceneId);
const object = scene && findObject(scene, objectId);
const holder = findObjectHolder(project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(project, object.assetId);
if (!object || !asset) return 'missing';
return (
@@ -64,44 +64,44 @@ export function objectSignature(
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
export function buildObjectsSection(host: InspectorHost, sceneId: string, objectIds: string[]): void {
const scene = findScene(host.state.project, sceneId);
if (!scene) return;
export function buildObjectsSection(host: InspectorHost, holderId: string, objectIds: string[]): void {
const holder = findObjectHolder(host.state.project, holderId);
if (!holder) return;
addTitle(host, `${objectIds.length} OBJECTS SELECTED`);
for (const id of objectIds) {
const object = findObject(scene, id);
const object = findObject(holder, id);
const asset = object && findAsset(host.state.project, object.assetId);
if (object && asset) addNote(host, `${object.name || asset.name} · ${asset.type}`);
}
addGap(host);
addNote(host, 'Ctrl-click adds or removes objects.', 'small');
addNote(host, 'Dragging one in the scene moves all of them.', 'small');
addNote(host, 'Dragging one in the viewport moves all of them.', 'small');
addGap(host);
host.body.add(
new Button('Create container', { onClick: () => createContainer(host.state, sceneId, objectIds) }),
new Button('Create container', { onClick: () => createContainer(host.state, holderId, objectIds) }),
);
addNote(host, 'Groups the selection into a reusable container', 'small');
addNote(host, 'asset placed at its centre (Ctrl+G).', 'small');
addGap(host);
host.body.add(
new Button('Remove from scene', {
new Button('Remove', {
variant: 'danger',
onClick: () => removeObjects(host.state, sceneId, objectIds),
onClick: () => removeObjects(host.state, holderId, objectIds),
}),
);
}
/** One object: name, asset, parent, container link, the switch, position, editor offset, properties, collider button. */
export function buildObjectSection(host: InspectorHost, sceneId: string, objectId: string): void {
const scene = findScene(host.state.project, sceneId);
const object = scene && findObject(scene, objectId);
export function buildObjectSection(host: InspectorHost, holderId: string, objectId: string): void {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!scene || !object || !asset) return;
if (!holder || !object || !asset) return;
addTitle(host, `${asset.type.toUpperCase()} OBJECT`);
host.nameField = new TextField(object.name, {
placeholder: asset.name,
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(sceneId, objectId, name.trim())),
onChange: (name) => host.state.execute(new RenameSceneObjectCommand(holderId, objectId, name.trim())),
});
const nameRow = labelledRow('Name');
nameRow.row.add(host.nameField, 1);
@@ -117,10 +117,10 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
);
host.body.add(assetRow.row);
host.body.add(parentRow(host, scene, object));
host.body.add(parentRow(host, holder, object));
// Part of a placed container: values compare against the template, not just the asset.
const link = templateLinkOf(host.state.project, scene, object);
const link = templateLinkOf(host.state.project, holder, object);
if (link) {
const linkRow = labelledRow('Part of');
linkRow.row.add(
@@ -135,25 +135,25 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
// The master switch, right where it is looked for: above everything type-specific.
for (const def of COMMON_PROPS)
propertyRow(host, sceneId, objectId, asset, def, resolveSceneObjectProps(asset, object));
propertyRow(host, holderId, objectId, asset, def, resolveSceneObjectProps(asset, object));
addTitle(host, object.parentId ? 'POSITION (LOCAL TO PARENT)' : 'POSITION');
for (const axis of POSITION_AXES) {
const moved = () => positionMoved(host.state, sceneId, objectId, axis);
componentRow(host, sceneId, objectId, object.position[axis], {
const moved = () => positionMoved(host.state, holderId, objectId, axis);
componentRow(host, holderId, objectId, object.position[axis], {
key: axis,
label: axis.toUpperCase(),
axis,
read: (o) => o.position,
write: (o, v) => (o.position = v),
command: (v) => new MoveSceneObjectCommand(sceneId, objectId, v),
command: (v) => new MoveSceneObjectCommand(holderId, objectId, v),
reset: () =>
moved()
? [
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertPositionToTemplateCommand(sceneId, objectId, axis)),
host.state.execute(new RevertPositionToTemplateCommand(holderId, objectId, axis)),
},
]
: [],
@@ -163,33 +163,33 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyPositionToContainerCommand(sceneId, objectId, axis)),
host.state.execute(new ApplyPositionToContainerCommand(holderId, objectId, axis)),
},
]
: [],
});
}
// Where the object is *drawn* in the scene view, nothing else – for pulling stacked objects apart.
// Where the object is *drawn* in the viewport, nothing else – for pulling stacked objects apart.
addTitle(host, 'EDITOR OFFSET (SCENE VIEW ONLY)');
for (const axis of OFFSET_AXES) {
componentRow(host, sceneId, objectId, object.editorOffset[axis], {
componentRow(host, holderId, objectId, object.editorOffset[axis], {
key: offsetKey(axis),
label: `Offset ${axis.toUpperCase()}`,
axis,
read: (o) => o.editorOffset,
write: (o, v) => (o.editorOffset = v),
command: (v) => new SetEditorOffsetCommand(sceneId, objectId, v),
command: (v) => new SetEditorOffsetCommand(holderId, objectId, v),
// No apply: the offset never leaves the placed object.
reset: () => {
const current = currentObject(host.state, sceneId, objectId);
const current = currentObject(host.state, holderId, objectId);
if (!current || current.editorOffset[axis] === 0) return [];
return [
{
label: 'Reset to 0',
onSelect: () =>
host.state.execute(
new SetEditorOffsetCommand(sceneId, objectId, { ...current.editorOffset, [axis]: 0 }),
new SetEditorOffsetCommand(holderId, objectId, { ...current.editorOffset, [axis]: 0 }),
),
},
];
@@ -204,7 +204,7 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
if (own.length > 0 || asset.type === 'behavior')
addTitle(host, asset.type === 'behavior' ? 'VARIABLES' : 'PROPERTIES');
if (own.length === 0 && asset.type === 'behavior') addNote(host, 'The behavior declares no variables.');
for (const def of own) propertyRow(host, sceneId, objectId, asset, def, values);
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values);
markObjectRows(host, object, asset, link);
if (asset.type === 'container') {
@@ -219,24 +219,24 @@ export function buildObjectSection(host: InspectorHost, sceneId: string, objectI
host.body.add(
new Button('+ Add collider', {
variant: 'ghost',
onClick: () => addCollider(host.state, sceneId, objectId),
onClick: () => addCollider(host.state, holderId, objectId),
}),
);
}
}
/** Pushes the current model values into the rows. False when the object is gone (rebuild). */
export function refreshObjectSection(host: InspectorHost, sceneId: string, objectId: string): boolean {
const scene = findScene(host.state.project, sceneId);
const object = scene && findObject(scene, objectId);
export function refreshObjectSection(host: InspectorHost, holderId: string, objectId: string): boolean {
const holder = findObjectHolder(host.state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(host.state.project, object.assetId);
if (!scene || !object || !asset) return false;
if (!holder || !object || !asset) return false;
host.nameField?.setValue(object.name);
for (const axis of POSITION_AXES) host.fields.get(axis)?.setValue(object.position[axis]);
for (const axis of OFFSET_AXES) host.fields.get(offsetKey(axis))?.setValue(object.editorOffset[axis]);
const values = resolveSceneObjectProps(asset, object);
for (const def of schemaForAsset(asset)) host.fields.get(def.key)?.setValue(values[def.key] ?? def.default);
markObjectRows(host, object, asset, templateLinkOf(host.state.project, scene, object));
markObjectRows(host, object, asset, templateLinkOf(host.state.project, holder, object));
return true;
}
@@ -258,7 +258,7 @@ interface ComponentSpec<V extends object> {
*/
function componentRow<V extends object>(
host: InspectorHost,
sceneId: string,
holderId: string,
objectId: string,
value: number,
spec: ComponentSpec<V>,
@@ -269,7 +269,7 @@ function componentRow<V extends object>(
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
value,
previewingHandlers<SceneObject, V>(host, {
target: () => currentObject(host.state, sceneId, objectId),
target: () => currentObject(host.state, holderId, objectId),
// Copied: the snapshot outlives the scrub, the vector in the model does not.
read: (object) => ({ ...spec.read(object) }),
write: spec.write,
@@ -283,7 +283,7 @@ function componentRow<V extends object>(
}
/**
* One editable property of a scene object: writes an override, offers ↺ (back to the asset
* 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).
*
* Inside a container a value has two bases – the template it was instantiated from and, one
@@ -293,31 +293,31 @@ function componentRow<V extends object>(
*/
function propertyRow(
host: InspectorHost,
sceneId: string,
holderId: string,
objectId: string,
owner: Asset,
def: PropertyDef,
values: Record<string, PropertyValue>,
): FieldRow {
const bases = () => deviations(host.state, sceneId, objectId, def.key);
const bases = () => deviations(host.state, holderId, objectId, def.key);
const row = createFieldRow(
host,
def,
values[def.key] ?? def.default,
previewingHandlers<SceneObject>(host, {
target: () => currentObject(host.state, sceneId, objectId),
target: () => currentObject(host.state, holderId, objectId),
read: (object) => object.overrides[def.key],
write: (object, value) => (object.overrides[def.key] = value),
remove: (object) => delete object.overrides[def.key],
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
commit: (value) => new SetOverrideCommand(holderId, objectId, def.key, value),
}),
{
extra: aspectActions(
host,
owner,
def,
() => currentProps(host.state, sceneId, objectId),
(value) => host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value)),
() => currentProps(host.state, holderId, objectId),
(value) => host.state.execute(new SetOverrideCommand(holderId, objectId, def.key, value)),
),
reset: () => {
const { template, asset } = bases();
@@ -327,7 +327,7 @@ function propertyRow(
{
label: 'Reset to container',
onSelect: () =>
host.state.execute(new RevertOverrideToTemplateCommand(sceneId, objectId, def.key)),
host.state.execute(new RevertOverrideToTemplateCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -335,7 +335,7 @@ function propertyRow(
? [
{
label: 'Reset to asset',
onSelect: () => host.state.execute(new ClearOverrideCommand(sceneId, objectId, def.key)),
onSelect: () => host.state.execute(new ClearOverrideCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -349,7 +349,7 @@ function propertyRow(
{
label: 'Apply to container',
onSelect: () =>
host.state.execute(new ApplyOverrideToContainerCommand(sceneId, objectId, def.key)),
host.state.execute(new ApplyOverrideToContainerCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -358,7 +358,7 @@ function propertyRow(
{
label: 'Apply to asset',
onSelect: () =>
host.state.execute(new ApplyOverrideToAssetCommand(sceneId, objectId, def.key)),
host.state.execute(new ApplyOverrideToAssetCommand(holderId, objectId, def.key)),
},
]
: []),
@@ -372,27 +372,27 @@ function propertyRow(
}
/**
* "Parent: <name> ▾" – picks another object of the same scene, or none. The object's own
* "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.
*/
function parentRow(host: InspectorHost, scene: SceneAsset, object: SceneObject) {
function parentRow(host: InspectorHost, holder: ObjectHolder, object: SceneObject) {
const { row } = labelledRow('Parent');
const nameOf = (o: SceneObject) => o.name || findAsset(host.state.project, o.assetId)?.name || '?';
const parent = findObject(scene, object.parentId);
const parent = findObject(holder, object.parentId);
const button = new Dropdown(parent ? nameOf(parent) : 'None');
button.onClick = () => {
const items: MenuItem[] = [
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, '')) },
{ label: 'None', onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, '')) },
];
for (const { object: candidate, depth } of treeOrder(scene)) {
if (isSelfOrAncestor(scene, object.id, candidate.id)) continue;
for (const { object: candidate, depth } of treeOrder(holder)) {
if (isSelfOrAncestor(holder, object.id, candidate.id)) continue;
const name = `${' '.repeat(depth)}${nameOf(candidate)}`;
items.push({
label: candidate.id === object.parentId ? `● ${name}` : name,
onSelect: () => host.state.execute(new SetParentCommand(scene.id, object.id, candidate.id)),
onSelect: () => host.state.execute(new SetParentCommand(holder.id, object.id, candidate.id)),
});
}
// Searchable: this lists every object of the scene, which grows with the project.
// 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);
@@ -431,15 +431,15 @@ function markObjectRows(
*/
function deviations(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
key: string,
): { template: boolean; asset: boolean } {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
const asset = object && findAsset(state.project, object.assetId);
if (!scene || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, scene, object);
if (!holder || !object || !asset) return { template: false, asset: false };
const link = templateLinkOf(state.project, holder, object);
return {
template: Boolean(link) && deviatesFromTemplate(object, link!.template, key),
asset: deviatesFromAsset(object, asset, key),
@@ -449,28 +449,28 @@ function deviations(
/** Whether the object sits somewhere else on `axis` than its container template says. */
function positionMoved(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
axis: Axis,
): boolean {
const scene = findScene(state.project, sceneId);
const object = scene && findObject(scene, objectId);
const link = scene && object && templateLinkOf(state.project, scene, object);
const holder = findObjectHolder(state.project, holderId);
const object = holder && findObject(holder, objectId);
const link = holder && object && templateLinkOf(state.project, holder, object);
return Boolean(link) && object!.position[axis] !== link!.template.position[axis];
}
function currentObject(
state: InspectorHost['state'],
sceneId: string,
holderId: string,
objectId: string,
): SceneObject | undefined {
const scene = findScene(state.project, sceneId);
return scene && findObject(scene, objectId);
const holder = findObjectHolder(state.project, holderId);
return holder && findObject(holder, objectId);
}
/** The values the object shows right now, asset defaults and overrides merged. */
function currentProps(state: InspectorHost['state'], sceneId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, sceneId, objectId);
function currentProps(state: InspectorHost['state'], holderId: string, objectId: string): PropertyBag | null {
const object = currentObject(state, holderId, objectId);
const asset = object && findAsset(state.project, object.assetId);
return object && asset ? resolveSceneObjectProps(asset, object) : null;
}
+25 -12
View File
@@ -3,13 +3,22 @@ import { nextAssetName } from '../../core/commands/naming';
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset } from '../../core/model/Project';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import type { TextureStore } from '../../core/store/TextureStore';
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import { confirmDeleteAsset } from '../actions';
import { type EditorState, objectSelection } from '../state';
import { pickAndImport } from '../textureImport';
/**
* Where a placed asset lands. The tab in front decides, so it appears where the user is
* looking: the Scene tab places into the active scene, the Container tab into the template.
*/
export interface PlacementTarget {
holder: ObjectHolder;
at: Vec2;
}
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
private readonly list = new ListView();
@@ -17,7 +26,7 @@ export class AssetPanel extends Panel {
constructor(
private readonly state: EditorState,
private readonly placementPoint: () => Vec2,
private readonly target: () => PlacementTarget | undefined,
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
private readonly textures: TextureStore | null = null,
) {
@@ -92,18 +101,19 @@ export class AssetPanel extends Panel {
}
private place(assetId: string): void {
const scene = this.state.activeScene;
if (!scene) {
this.state.setNotice('Create a scene first, then place assets into it.');
const target = this.target();
if (!target) {
this.state.setNotice('Nothing to place into – create a scene, or open a container first.');
return;
}
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
const { holder, at } = target;
const command = new PlaceAssetCommand(holder.id, assetId, at);
if (this.state.execute(command)) this.state.select(objectSelection(holder.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): void {
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const asset = findAsset(this.state.project, id);
if (!asset) return;
if (!asset) return undefined;
const items: MenuItem[] = [];
if (asset.type === 'scene') {
items.push({
@@ -112,13 +122,16 @@ export class AssetPanel extends Panel {
disabled: this.state.project.activeSceneId === id,
});
} else {
const target = this.target();
items.push({
label: 'Place in scene',
label: target?.holder.type === 'container' ? 'Place in container' : 'Place in scene',
onSelect: () => this.place(id),
disabled: !this.state.activeScene,
disabled: !target,
});
}
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
new Menu(items).open(x, y);
const menu = new Menu(items);
menu.open(x, y);
return menu;
}
}
+64 -32
View File
@@ -1,14 +1,14 @@
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { hasColorSwatch } from '../../core/model/AssetTypes';
import { ancestorIdsOf, findObject, visibleTreeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { findAsset, type ObjectHolder } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import { addCollider, createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* Objects of the scene or container being edited, as a tree (children indented below their parent).
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
@@ -22,24 +22,31 @@ export class HierarchyPanel extends Panel {
private readonly scroll = new ScrollContainer();
/** Ids of the objects whose children are shown. Anything missing is collapsed. */
private readonly expanded = new Set<string>();
/**
* Which objects are listed. A function, not an id, so a container renamed or swapped under the
* panel needs no notification; the Container tab points this at the template it edits.
*/
private source: () => ObjectHolder | undefined = () => this.state.activeScene;
/** Id of the holder the rows were built from, to notice when the source starts naming another. */
private shownHolderId = '';
constructor(private readonly state: EditorState) {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene;
if (!scene) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null);
const holder = this.holder();
if (!holder) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(holder.id, id);
else state.select(id ? objectSelection(holder.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
const object = scene && findObject(scene, draggedId);
if (!scene || !object || object.parentId === (targetId ?? '')) return;
const holder = this.holder();
const object = holder && findObject(holder, draggedId);
if (!holder || !object || object.parentId === (targetId ?? '')) return;
// Unfold the new parent, otherwise the dragged row would seem to vanish on drop.
if (targetId) this.expanded.add(targetId);
state.execute(new SetParentCommand(scene.id, draggedId, targetId ?? ''));
state.execute(new SetParentCommand(holder.id, draggedId, targetId ?? ''));
};
this.list.onToggle = (id, expand) => this.setExpanded(id, expand);
this.list.onActivate = (id) => this.setExpanded(id, !this.expanded.has(id));
@@ -53,14 +60,33 @@ export class HierarchyPanel extends Panel {
this.expanded.clear();
this.refresh();
});
state.on('selection', () => (this.revealSelection() ? this.refresh() : this.syncSelection()));
state.on('selection', () => {
// The Container tab's picker only changes the selection, which swaps the holder under this
// panel; without the id check the title and rows would keep naming the previous one.
const swapped = (this.holder()?.id ?? '') !== this.shownHolderId;
if (this.revealSelection() || swapped) this.refresh();
else this.syncSelection();
});
this.refresh();
}
/**
* Lists another holder's objects – the Container tab hands in its template, the Scene tab the
* active scene again.
*/
showObjectsOf(source: () => ObjectHolder | undefined): void {
this.source = source;
this.refresh();
}
private holder(): ObjectHolder | undefined {
return this.source();
}
/** Ids of the rows currently shown – subtrees of collapsed objects are not among them (test hook). */
rowOrder(): string[] {
const scene = this.state.activeScene;
return scene ? visibleTreeOrder(scene, this.expanded).map((e) => e.object.id) : [];
const holder = this.holder();
return holder ? visibleTreeOrder(holder, this.expanded).map((e) => e.object.id) : [];
}
/** Global centre of a row's chevron, so tests can click the real hit area (test hook). */
@@ -69,8 +95,7 @@ export class HierarchyPanel extends Panel {
}
expandAll(): void {
const scene = this.state.activeScene;
for (const object of scene?.objects ?? []) this.expanded.add(object.id);
for (const object of this.holder()?.objects ?? []) this.expanded.add(object.id);
this.refresh();
}
@@ -81,16 +106,17 @@ export class HierarchyPanel extends Panel {
this.refresh();
}
private openContextMenu(id: string, x: number, y: number): void {
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
const { state } = this;
const scene = state.activeScene;
if (!scene) return;
const object = findObject(scene, id);
const holder = this.holder();
if (!holder) return undefined;
const inScene = holder.type === 'scene';
const object = findObject(holder, id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
const menu = new Menu([
{
label: 'Select asset',
onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }),
@@ -98,25 +124,29 @@ export class HierarchyPanel extends Panel {
{
label: 'Add collider',
disabled: !type || type === 'collider' || many,
onSelect: () => addCollider(state, scene.id, id),
onSelect: () => addCollider(state, holder.id, id),
},
{
label: many ? `Create container from ${selected.length} objects` : 'Create container',
onSelect: () => createContainer(state, scene.id, selected),
onSelect: () => createContainer(state, holder.id, selected),
},
{
label: 'Unparent (move to top level)',
disabled: !object?.parentId || many,
onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')),
onSelect: () => state.execute(new SetParentCommand(holder.id, id, '')),
},
{ label: 'Expand all', onSelect: () => this.expandAll() },
{ label: 'Collapse all', onSelect: () => this.collapseAll() },
{
label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene',
label: many
? `Remove ${selected.length} objects from ${inScene ? 'scene' : 'container'}`
: `Remove from ${inScene ? 'scene' : 'container'}`,
danger: true,
onSelect: () => removeObjects(state, scene.id, selected),
onSelect: () => removeObjects(state, holder.id, selected),
},
]).open(x, y);
]);
menu.open(x, y);
return menu;
}
private setExpanded(id: string, expand: boolean): void {
@@ -127,11 +157,11 @@ export class HierarchyPanel extends Panel {
/** Unfolds the ancestors of every selected object. Returns whether that changed anything. */
private revealSelection(): boolean {
const scene = this.state.activeScene;
if (!scene) return false;
const holder = this.holder();
if (!holder) return false;
let changed = false;
for (const id of this.state.selectedObjectIds) {
for (const ancestorId of ancestorIdsOf(scene, id)) {
for (const ancestorId of ancestorIdsOf(holder, id)) {
if (this.expanded.has(ancestorId)) continue;
this.expanded.add(ancestorId);
changed = true;
@@ -141,10 +171,12 @@ export class HierarchyPanel extends Panel {
}
private refresh(): void {
const scene = this.state.activeScene;
this.setTitle(scene ? `Scene: ${scene.name}` : 'Scene objects');
const holder = this.holder();
this.shownHolderId = holder?.id ?? '';
const kind = holder?.type === 'container' ? 'Container' : 'Scene';
this.setTitle(holder ? `${kind}: ${holder.name}` : 'Scene objects');
this.list.setItems(
(scene ? visibleTreeOrder(scene, this.expanded) : []).map(({ object, depth, hasChildren }) => {
(holder ? visibleTreeOrder(holder, this.expanded) : []).map(({ object, depth, hasChildren }) => {
const asset = findAsset(this.state.project, object.assetId);
const props = asset ? resolveSceneObjectProps(asset, object) : {};
return {
+2 -2
View File
@@ -32,12 +32,12 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
}
if (mod && key === 'g') {
const s = state.selection;
if (s?.kind === 'object') createContainer(state, s.sceneId, s.objectIds);
if (s?.kind === 'object') createContainer(state, s.holderId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = state.selection;
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
if (s?.kind === 'object') removeObjects(state, s.holderId, s.objectIds);
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'frame') state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
+45 -19
View File
@@ -1,17 +1,24 @@
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { SceneAsset } from '../core/model/Asset';
import type { Asset, SceneAsset } from '../core/model/Asset';
import { findObject } from '../core/model/hierarchy';
import { createEmptyProject, findAsset, findScene, type Project } from '../core/model/Project';
import {
createEmptyProject,
findAsset,
findObjectHolder,
findScene,
type Project,
} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
/**
* One or more objects of a scene. `objectId` is the primary one (the last picked – the
* inspector shows it); `objectIds` holds the whole selection, primary included.
* One or more objects of a scene or of a container's template (`holderId` names which).
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
* holds the whole selection, primary included.
*/
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
/** A frame rectangle inside a texture asset (the Texture tab). */
@@ -19,11 +26,11 @@ export type Selection =
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(sceneId: string, objectIds: readonly string[], primary?: string): Selection {
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const ids = [...new Set(objectIds)];
if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
return { kind: 'object', sceneId, objectId, objectIds: ids };
return { kind: 'object', holderId, objectId, objectIds: ids };
}
export type EditorMode = 'edit' | 'play';
@@ -194,23 +201,23 @@ export class EditorState {
// --- selection -----------------------------------------------------------------------------
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', sceneId, objectId }` without `objectIds`.
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.sceneId, [selection.objectId]);
selection = objectSelection(selection.holderId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(sceneId: string, objectId: string): void {
toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ids of the selected scene objects (empty unless objects are selected). */
@@ -224,9 +231,9 @@ export class EditorState {
if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const scene = findScene(this.project, s.sceneId);
const alive = scene ? s.objectIds.filter((id) => findObject(scene, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
const holder = findObjectHolder(this.project, s.holderId);
const alive = holder ? s.objectIds.filter((id) => findObject(holder, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findAsset(this.project, s.assetId);
@@ -259,12 +266,31 @@ export class EditorState {
if (!s) return null;
if (s.kind === 'node') return s.assetId;
if (s.kind === 'frame') return null;
const scene = s.kind === 'object' ? findScene(this.project, s.sceneId) : undefined;
const assetId = s.kind === 'asset' ? s.assetId : scene && findObject(scene, s.objectId)?.assetId;
const asset = assetId ? findAsset(this.project, assetId) : undefined;
const asset = this.assetBehind(s);
return asset && asset.type === 'behavior' ? asset.id : null;
}
/**
* The container the current selection refers to: the asset itself, a placed container, or the
* template being edited – selecting one of its objects keeps the Container tab on it.
*/
get selectedContainerId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
const asset = this.assetBehind(s);
return asset && asset.type === 'container' ? asset.id : null;
}
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
private assetBehind(s: NonNullable<Selection>): Asset | undefined {
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
if (s.kind !== 'object') return undefined;
const holder = findObjectHolder(this.project, s.holderId);
const assetId = holder && findObject(holder, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}
// --- status --------------------------------------------------------------------------------
setSaveStatus(status: SaveStatus): void {
@@ -286,7 +312,7 @@ export function sameSelection(a: Selection, b: Selection): boolean {
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return (
a.sceneId === b.sceneId &&
a.holderId === b.holderId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])