Multiselection and container assets: create container from selection, template instances

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-07 17:40:51 +02:00
1 parent 91ee2feadd
commit 0b78382bcc
26 files changed
+897 -114

No files matched your search

+9 -2
View File
@@ -1,7 +1,6 @@
import { type Application, Container, Text } from 'pixi.js';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { listScenes } from '../core/model/Project';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import type { ProjectStore } from '../core/store/ProjectStore';
@@ -11,6 +10,7 @@ import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { Viewport } from '../viewport/Viewport';
import { createContainer, removeObjects } from './actions';
import { attachAutosave } from './autosave';
import { GraphEditor } from './graph/GraphEditor';
import { AssetPanel } from './panels/AssetPanel';
@@ -67,6 +67,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
gameInfo: () => gameView.debugInfo(),
layerOf: (id: string) => viewport.layerOf(id),
drawOrder: () => viewport.drawOrder(),
/** Screen position of an object in the Scene tab (its world position through the view). */
objectScreen: (id: string) => viewport.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
@@ -367,9 +369,14 @@ class Editor {
this.state.redo();
return true;
}
if (mod && event.key.toLowerCase() === 'g') {
const s = this.state.selection;
if (s?.kind === 'object') createContainer(this.state, s.sceneId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = this.state.selection;
if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId));
if (s?.kind === 'object') removeObjects(this.state, s.sceneId, s.objectIds);
else if (s?.kind === 'node') this.state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'asset') {
const asset = this.state.project.assets.find((a) => a.id === s.assetId);
+21
View File
@@ -0,0 +1,21 @@
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { type EditorState, objectSelection } from './state';
/**
* Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
/** 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);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [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));
}
+6 -4
View File
@@ -5,9 +5,9 @@ import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -77,7 +77,7 @@ export class AssetPanel extends Panel {
return;
}
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): void {
@@ -100,7 +100,9 @@ export class AssetPanel extends Panel {
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: usages > 0
: asset.type === 'container' && usages > 0
? `${usages} placed container(s) are removed from scenes together with their content.`
: usages > 0
? `${usages} placed instance(s) are removed from scenes as well.`
: 'This cannot be undone with Ctrl+Z after reload.';
new Dialog(`Delete "${asset.name}"?`, detail, [
+19 -10
View File
@@ -1,15 +1,17 @@
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { treeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* 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.
* 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.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
@@ -19,9 +21,11 @@ export class HierarchyPanel extends Panel {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id) => {
this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene;
state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null);
if (!scene) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
@@ -35,18 +39,22 @@ export class HierarchyPanel extends Panel {
if (!scene) return;
const object = scene.objects.find((o) => o.id === 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([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{
label: 'Add collider',
disabled: !type || type === 'collider',
disabled: !type || type === 'collider' || many,
onSelect: () => {
const command = new AddColliderCommand(scene.id, id);
if (state.execute(command)) state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (state.execute(command)) state.select(objectSelection(scene.id, [command.objectId]));
},
},
{ label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
]).open(x, y);
};
this.scroll.setContent(this.list);
@@ -87,5 +95,6 @@ export class HierarchyPanel extends Panel {
private syncSelection(): void {
const s = this.state.selection;
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
}
}
+35 -4
View File
@@ -20,8 +20,9 @@ import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
import { type EditorState, objectSelection, type Selection } from '../state';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
@@ -80,7 +81,8 @@ export class Inspector extends Panel {
} else if (s.kind === 'asset') {
this.buildAsset(s.assetId);
} else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
if (s.objectIds.length > 1) this.buildObjects(s.sceneId, s.objectIds);
else this.buildObject(s.sceneId, s.objectId);
} else {
this.buildNode(s.assetId, s.nodeId);
}
@@ -96,6 +98,7 @@ export class Inspector extends Panel {
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
if (s.kind === 'object') {
if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`;
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
@@ -204,6 +207,27 @@ export class Inspector extends Panel {
// --- scene objects -------------------------------------------------------------------
/** Several objects: what they are, and the actions that apply to all of them at once. */
private buildObjects(sceneId: string, objectIds: string[]): void {
const scene = findScene(this.state.project, sceneId);
if (!scene) return;
this.body.add(new Label(`${objectIds.length} OBJECTS SELECTED`, { style: 'title' }));
for (const id of objectIds) {
const object = findSceneObject(scene, id);
const asset = object && findAsset(this.state.project, object.assetId);
if (object && asset) this.body.add(new Label(`${object.name || asset.name} · ${asset.type}`, { style: 'muted' }));
}
this.body.add(new Label(''));
this.body.add(new Label('Ctrl-click adds or removes objects.', { style: 'small' }));
this.body.add(new Label('Dragging one in the scene moves all of them.', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Create container', { onClick: () => createContainer(this.state, sceneId, objectIds) }));
this.body.add(new Label('Groups the selection into a reusable container', { style: 'small' }));
this.body.add(new Label('asset placed at its centre (Ctrl+G).', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(this.state, sceneId, objectIds) }));
}
private buildObject(sceneId: string, objectId: string): void {
const scene = findScene(this.state.project, sceneId);
const object = scene && findSceneObject(scene, objectId);
@@ -293,6 +317,13 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
if (asset.type === 'container') {
const count = asset.objects.length;
this.body.add(new Label('CONTENT', { style: 'title' }));
this.body.add(new Label(count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`, { style: 'muted' }));
if (count > 0) this.body.add(new Label('Its content is listed below it in the hierarchy.', { style: 'small' }));
}
// Sprites (and other bodies) get their collider with one click: a child sized like the object.
if (asset.type !== 'collider') {
this.body.add(new Label(''));
@@ -301,7 +332,7 @@ export class Inspector extends Panel {
variant: 'ghost',
onClick: () => {
const command = new AddColliderCommand(sceneId, objectId);
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(sceneId, [command.objectId]));
},
}),
);
@@ -483,7 +514,7 @@ function sameTarget(a: Selection, b: Selection): boolean {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(',');
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+34 -4
View File
@@ -6,11 +6,23 @@ import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
| { kind: 'object'; sceneId: string; objectId: 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.
*/
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
| 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 {
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 };
}
export type EditorMode = 'edit' | 'play';
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
@@ -169,19 +181,35 @@ export class EditorState {
// --- selection -----------------------------------------------------------------------
select(selection: Selection): void {
// Tolerate the short form `{ kind: 'object', sceneId, objectId }` (tests, older callers).
if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
/** Drops a selection whose target no longer exists (after delete/undo). */
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(sceneId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ids of the selected scene objects (empty unless objects are selected). */
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
if (!s) return;
if (s.kind === 'asset' && !this.project.assets.some((a) => a.id === s.assetId)) this.select(null);
if (s.kind === 'object') {
const scene = findScene(this.project, s.sceneId);
if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null);
const alive = scene ? s.objectIds.filter((id) => scene.objects.some((o) => o.id === id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = this.project.assets.find((a) => a.id === s.assetId);
@@ -225,7 +253,9 @@ function sameSelection(a: Selection, b: Selection): boolean {
if (a === b) return true;
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'object' && b.kind === 'object') {
return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}