diff --git a/README.md b/README.md
index a4936893..750d6927 100644
--- a/README.md
+++ b/README.md
@@ -10,8 +10,10 @@ Live: · Widget playground: = 0; i--) this.commands[i]!.undo(project);
+ }
+}
diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts
index bff1f67c..0f71266e 100644
--- a/src/core/commands/assetCommands.ts
+++ b/src/core/commands/assetCommands.ts
@@ -1,8 +1,8 @@
import { createEmptyGraph } from '../graph/Graph';
-import type { Asset } from '../model/Asset';
+import type { Asset, ContainerAsset, SceneAsset } from '../model/Asset';
import type { AssetType } from '../model/AssetTypes';
import type { PropertyValue } from '../model/Properties';
-import { findAsset, findScene, listScenes, type Project } from '../model/Project';
+import { findAsset, type Project } from '../model/Project';
import { coerceValue, defaultsFor, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
@@ -21,6 +21,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() };
case 'collider':
return { ...base, type: 'collider' };
+ case 'container':
+ return { ...base, type: 'container', objects: [] };
}
}
@@ -49,12 +51,14 @@ export class CreateAssetCommand implements Command {
/**
* Deletes an asset and, as a cascade, every scene object referencing it – together with the
- * objects parented below those (a subtree is removed as a whole).
+ * objects parented below those (a subtree is removed as a whole). Container templates
+ * referencing the asset lose those objects the same way.
*/
export class DeleteAssetCommand implements Command {
readonly label: string;
private removed?: { asset: Asset; index: number };
- private removedObjects: { sceneId: string; removed: RemovedObject[] }[] = [];
+ /** Objects removed per scene or container asset (both hold a flat object list). */
+ private removedObjects: { holderId: string; removed: RemovedObject[] }[] = [];
private previousActiveScene = '';
constructor(private readonly assetId: string) {
@@ -66,10 +70,10 @@ export class DeleteAssetCommand implements Command {
const asset = project.assets[index];
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
this.removedObjects = [];
- for (const scene of listScenes(project)) {
- const roots = scene.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
+ for (const holder of objectHolders(project)) {
+ const roots = holder.objects.filter((o) => o.assetId === this.assetId).map((o) => o.id);
if (roots.length === 0) continue;
- this.removedObjects.push({ sceneId: scene.id, removed: removeSubtrees(scene, roots) });
+ this.removedObjects.push({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
}
this.removed = { asset, index };
project.assets.splice(index, 1);
@@ -84,13 +88,18 @@ export class DeleteAssetCommand implements Command {
if (!this.removed) return;
project.assets.splice(this.removed.index, 0, this.removed.asset);
for (const entry of this.removedObjects) {
- const scene = findScene(project, entry.sceneId);
- if (scene) restoreObjects(scene, entry.removed);
+ const holder = objectHolders(project).find((h) => h.id === entry.holderId);
+ if (holder) restoreObjects(holder, entry.removed);
}
project.activeSceneId = this.previousActiveScene;
}
}
+/** Scenes and container assets: everything that holds placed objects. */
+function objectHolders(project: Project): (SceneAsset | ContainerAsset)[] {
+ return project.assets.filter((a): a is SceneAsset | ContainerAsset => a.type === 'scene' || a.type === 'container');
+}
+
export class RenameAssetCommand implements Command {
readonly label = 'Rename asset';
private previousName = '';
diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts
new file mode 100644
index 00000000..73c94f73
--- /dev/null
+++ b/src/core/commands/containerCommands.ts
@@ -0,0 +1,103 @@
+import type { ContainerAsset } from '../model/Asset';
+import { descendantsOf, localPositionFor, subtreeRoots, treeOrder, worldPosition } from '../model/hierarchy';
+import type { Position } from '../model/Properties';
+import { findScene, type Project } from '../model/Project';
+import { createSceneObject } from '../model/SceneObject';
+import { newId } from '../ids';
+import { buildAsset } from './assetCommands';
+import { type Command, CommandError } from './Command';
+import { nextAssetName } from './projectCommands';
+
+/**
+ * Turns the selected objects (with everything parented below them) into a new container
+ * asset and replaces them in the scene by one placed container – exactly as if that asset
+ * had been placed there. The container's origin is the centre of the selection's bounding
+ * box (x/y of the objects' world positions; z = 0), so the template is centred around
+ * (0, 0, 0). The objects keep their ids and world positions: the selected roots are simply
+ * re-parented under the container with recomputed local positions. When all roots share a
+ * parent the container is placed below that parent, otherwise at the top level.
+ */
+export class CreateContainerCommand implements Command {
+ readonly label = 'Create container';
+ readonly assetId = newId();
+ readonly objectId = newId();
+ private previous: { id: string; parentId: string; position: Position; templateId: string }[] = [];
+
+ constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
+
+ execute(project: Project): void {
+ const scene = findScene(project, this.sceneId);
+ if (!scene) throw new CommandError(`Scene ${this.sceneId} not found`);
+ const selected = [...new Set(this.objectIds)].map((id) => {
+ const object = scene.objects.find((o) => o.id === id);
+ if (!object) throw new CommandError(`Scene object ${id} not found`);
+ return object;
+ });
+ if (selected.length === 0) throw new CommandError('Select at least one object to create a container');
+
+ // Centre of the selection in world space – the container's origin.
+ const worlds = selected.map((o) => worldPosition(scene, o));
+ const centre: Position = {
+ x: Math.round((Math.min(...worlds.map((p) => p.x)) + Math.max(...worlds.map((p) => p.x))) / 2),
+ y: Math.round((Math.min(...worlds.map((p) => p.y)) + Math.max(...worlds.map((p) => p.y))) / 2),
+ z: 0,
+ };
+
+ // The subtrees that move into the container, in tree order (parents first).
+ const roots = new Set(subtreeRoots(scene, selected.map((o) => o.id)));
+ const memberIds = new Set();
+ for (const id of roots) {
+ memberIds.add(id);
+ for (const child of descendantsOf(scene, id)) memberIds.add(child.id);
+ }
+ const members = treeOrder(scene).map((e) => e.object).filter((o) => memberIds.has(o.id));
+ const rootParents = new Set([...roots].map((id) => scene.objects.find((o) => o.id === id)!.parentId));
+ const containerParentId = rootParents.size === 1 ? [...rootParents][0]! : '';
+
+ // Template: same structure with private ids, roots relative to the centre.
+ const asset = buildAsset('container', nextAssetName(project, 'Container'), this.assetId) as ContainerAsset;
+ const templateIds = new Map(members.map((o) => [o.id, newId()]));
+ for (const object of members) {
+ const isRoot = roots.has(object.id);
+ const world = worldPosition(scene, object);
+ asset.objects.push({
+ id: templateIds.get(object.id)!,
+ assetId: object.assetId,
+ name: object.name,
+ position: isRoot ? { x: world.x - centre.x, y: world.y - centre.y, z: world.z } : { ...object.position },
+ overrides: { ...object.overrides },
+ parentId: isRoot ? '' : templateIds.get(object.parentId) ?? '',
+ templateId: '',
+ });
+ }
+
+ // Scene: the container takes the roots' place, the members become its instance.
+ this.previous = members.map((o) => ({ id: o.id, parentId: o.parentId, position: { ...o.position }, templateId: o.templateId }));
+ const container = createSceneObject(this.objectId, this.assetId, localPositionFor(scene, containerParentId, centre), containerParentId);
+ for (const object of members) {
+ if (roots.has(object.id)) {
+ const world = worldPosition(scene, object);
+ object.parentId = this.objectId;
+ object.position = { x: world.x - centre.x, y: world.y - centre.y, z: world.z };
+ }
+ object.templateId = templateIds.get(object.id)!;
+ }
+ project.assets.push(asset);
+ scene.objects.push(container);
+ }
+
+ undo(project: Project): void {
+ const scene = findScene(project, this.sceneId);
+ if (scene) {
+ for (const entry of this.previous) {
+ const object = scene.objects.find((o) => o.id === entry.id);
+ if (!object) continue;
+ object.parentId = entry.parentId;
+ object.position = { ...entry.position };
+ object.templateId = entry.templateId;
+ }
+ scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
+ }
+ project.assets = project.assets.filter((a) => a.id !== this.assetId);
+ }
+}
diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts
index ef9c2da7..3ed420a9 100644
--- a/src/core/commands/sceneObjectCommands.ts
+++ b/src/core/commands/sceneObjectCommands.ts
@@ -1,13 +1,13 @@
import { isPlaceableType } from '../model/AssetTypes';
-import { descendantsOf, isSelfOrAncestor, localPositionFor, worldPosition } from '../model/hierarchy';
+import { descendantsOf, isSelfOrAncestor, localPositionFor, type ObjectTree, worldPosition } from '../model/hierarchy';
import type { Position, PropertyValue, Vec2 } from '../model/Properties';
import { findScene, findSceneObject, type Project } from '../model/Project';
-import type { SceneAsset } from '../model/Asset';
import { createSceneObject, type SceneObject } from '../model/SceneObject';
import { coerceValue, schemaForAsset } from '../properties/PropertySchema';
import { newId } from '../ids';
import { type Command, CommandError } from './Command';
import { requireAsset } from './assetCommands';
+import { instantiateContainer } from '../model/container';
function requireScene(project: Project, sceneId: string) {
const scene = findScene(project, sceneId);
@@ -28,35 +28,39 @@ export interface RemovedObject {
}
/**
- * Removes the given objects and all their descendants from the scene (a subtree goes as a
- * whole). Returns what was removed in ascending index order.
+ * Removes the given objects and all their descendants from the object list (a subtree goes
+ * as a whole; ids that are descendants of other ids are simply covered). Returns what was
+ * removed in ascending index order.
*/
-export function removeSubtrees(scene: SceneAsset, rootIds: string[]): RemovedObject[] {
+export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): RemovedObject[] {
const ids = new Set();
for (const rootId of rootIds) {
ids.add(rootId);
- for (const child of descendantsOf(scene, rootId)) ids.add(child.id);
+ for (const child of descendantsOf(tree, rootId)) ids.add(child.id);
}
const removed: RemovedObject[] = [];
- scene.objects.forEach((object, index) => {
+ tree.objects.forEach((object, index) => {
if (ids.has(object.id)) removed.push({ object, index });
});
- scene.objects = scene.objects.filter((o) => !ids.has(o.id));
+ tree.objects = tree.objects.filter((o) => !ids.has(o.id));
return removed;
}
/** Puts removed objects back at their former indices (ascending order restores them exactly). */
-export function restoreObjects(scene: SceneAsset, removed: RemovedObject[]): void {
- for (const entry of [...removed].sort((a, b) => a.index - b.index)) scene.objects.splice(entry.index, 0, entry.object);
+export function restoreObjects(tree: ObjectTree, removed: RemovedObject[]): void {
+ for (const entry of [...removed].sort((a, b) => a.index - b.index)) tree.objects.splice(entry.index, 0, entry.object);
}
/**
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. The
* position is a world position; with a parent it is converted to the parent's local space.
+ * Placing a container also creates its content (see `instantiateContainer`).
*/
export class PlaceAssetCommand implements Command {
readonly label = 'Place asset';
readonly objectId = newId();
+ /** Every object this command added: the placed one plus a container's content. */
+ private createdIds: string[] = [];
constructor(
private readonly sceneId: string,
@@ -71,12 +75,16 @@ export class PlaceAssetCommand implements Command {
if (!isPlaceableType(asset.type)) throw new CommandError('A scene cannot be placed into a scene');
if (this.parentId) requireObject(project, this.sceneId, this.parentId);
const world = { x: this.position.x, y: this.position.y, z: 'z' in this.position ? this.position.z : 0 };
- scene.objects.push(createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId));
+ const created = [createSceneObject(this.objectId, this.assetId, localPositionFor(scene, this.parentId, world), this.parentId)];
+ if (asset.type === 'container') created.push(...instantiateContainer(project, asset, this.objectId));
+ this.createdIds = created.map((o) => o.id);
+ scene.objects.push(...created);
}
undo(project: Project): void {
const scene = requireScene(project, this.sceneId);
- scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
+ const ids = new Set(this.createdIds);
+ scene.objects = scene.objects.filter((o) => !ids.has(o.id));
}
}
@@ -97,6 +105,23 @@ export class RemoveSceneObjectCommand implements Command {
}
}
+/** Removes several objects (a multi-selection) with their subtrees as one undo step. */
+export class RemoveSceneObjectsCommand implements Command {
+ readonly label = 'Remove objects';
+ private removed: RemovedObject[] = [];
+
+ constructor(private readonly sceneId: string, private readonly objectIds: readonly string[]) {}
+
+ execute(project: Project): void {
+ for (const id of this.objectIds) requireObject(project, this.sceneId, id);
+ this.removed = removeSubtrees(requireScene(project, this.sceneId), this.objectIds);
+ }
+
+ undo(project: Project): void {
+ restoreObjects(requireScene(project, this.sceneId), this.removed);
+ }
+}
+
/** Sets the full local position (x, y and draw order z) of a scene object. */
export class MoveSceneObjectCommand implements Command {
readonly label = 'Move object';
diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts
index b6759fa6..a0676071 100644
--- a/src/core/model/Asset.ts
+++ b/src/core/model/Asset.ts
@@ -45,7 +45,19 @@ export interface ColliderAsset extends AssetBase {
type: 'collider';
}
-export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset;
+/**
+ * A container is a reusable group of objects (a "prefab"): its `objects` are a template that
+ * is instantiated below the container whenever it is placed. Template positions are local
+ * to the container's origin – the centre of the group when it was created from a selection.
+ * Template ids are private to the asset; every placed copy gets fresh ids and remembers the
+ * template object it came from (`SceneObject.templateId`).
+ */
+export interface ContainerAsset extends AssetBase {
+ type: 'container';
+ objects: SceneObject[];
+}
+
+export type Asset = SpriteAsset | CameraAsset | SceneAsset | BehaviorAsset | ColliderAsset | ContainerAsset;
export function isSceneAsset(asset: Asset): asset is SceneAsset {
return asset.type === 'scene';
diff --git a/src/core/model/AssetTypes.ts b/src/core/model/AssetTypes.ts
index 2de8c666..d26936a0 100644
--- a/src/core/model/AssetTypes.ts
+++ b/src/core/model/AssetTypes.ts
@@ -1,5 +1,5 @@
/** All asset types. Scenes are assets too, but cannot be placed in scenes. */
-export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider'] as const;
+export const ASSET_TYPES = ['scene', 'camera', 'sprite', 'behavior', 'collider', 'container'] as const;
export type AssetType = (typeof ASSET_TYPES)[number];
@@ -19,5 +19,5 @@ export function isAssetType(value: unknown): value is AssetType {
* They exist in the scene for their position, not for their looks.
*/
export function isSymbolType(type: AssetType): boolean {
- return type === 'camera' || type === 'behavior' || type === 'collider';
+ return type === 'camera' || type === 'behavior' || type === 'collider' || type === 'container';
}
diff --git a/src/core/model/SceneObject.ts b/src/core/model/SceneObject.ts
index cab5496e..92f6b2f4 100644
--- a/src/core/model/SceneObject.ts
+++ b/src/core/model/SceneObject.ts
@@ -22,6 +22,12 @@ export interface SceneObject {
* acts on.
*/
parentId: string;
+ /**
+ * For objects that belong to a placed container: the id of the template object (inside
+ * the container asset) this one was instantiated from. Empty for everything else. This is
+ * the link that lets template changes reach the instances later on.
+ */
+ templateId: string;
}
/** Creates a scene object; `z` defaults to 0 when the caller only has a 2D point. */
@@ -33,5 +39,6 @@ export function createSceneObject(id: string, assetId: string, position: Vec2 |
position: { x: position.x, y: position.y, z: 'z' in position ? position.z : 0 },
overrides: {},
parentId,
+ templateId: '',
};
}
diff --git a/src/core/model/container.ts b/src/core/model/container.ts
new file mode 100644
index 00000000..87e392d3
--- /dev/null
+++ b/src/core/model/container.ts
@@ -0,0 +1,28 @@
+import { newId } from '../ids';
+import type { ContainerAsset } from './Asset';
+import { findAsset, type Project } from './Project';
+import type { SceneObject } from './SceneObject';
+
+/** Nesting depth at which a container inside a container inside … stops being expanded. */
+const MAX_NESTING = 8;
+
+/**
+ * The scene objects a placed container consists of: one fresh object per template object,
+ * parented below `containerObjectId` (or below the copy of their template parent), each
+ * remembering its template id. A template object that is itself a container is expanded in
+ * turn – unless the template already spells its content out (that is what a container built
+ * from a selection containing another container does).
+ */
+export function instantiateContainer(project: Project, asset: ContainerAsset, containerObjectId: string, depth = 0): SceneObject[] {
+ const ids = new Map(asset.objects.map((t) => [t.id, newId()]));
+ const result: SceneObject[] = [];
+ for (const template of asset.objects) {
+ const id = ids.get(template.id)!;
+ const parentId = template.parentId ? ids.get(template.parentId) ?? containerObjectId : containerObjectId;
+ result.push({ id, assetId: template.assetId, name: template.name, position: { ...template.position }, overrides: { ...template.overrides }, parentId, templateId: template.id });
+ const nested = findAsset(project, template.assetId);
+ const hasExplicitContent = asset.objects.some((o) => o.parentId === template.id);
+ if (nested?.type === 'container' && !hasExplicitContent && depth < MAX_NESTING) result.push(...instantiateContainer(project, nested, id, depth + 1));
+ }
+ return result;
+}
diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts
index 7c292fb4..fdfe1e23 100644
--- a/src/core/model/hierarchy.ts
+++ b/src/core/model/hierarchy.ts
@@ -1,4 +1,3 @@
-import type { SceneAsset } from './Asset';
import type { Position } from './Properties';
import type { SceneObject } from './SceneObject';
@@ -7,21 +6,27 @@ import type { SceneObject } from './SceneObject';
* creation order); the tree is implied by `SceneObject.parentId`. Positions are local to
* the parent, so the world position is the sum along the chain – there is no rotation or
* scale yet, which keeps this trivially invertible.
+ *
+ * The helpers work on anything that holds a flat object list – a scene, or the template
+ * inside a container asset.
*/
-
-export function findObject(scene: SceneAsset, id: string): SceneObject | undefined {
- return scene.objects.find((o) => o.id === id);
+export interface ObjectTree {
+ objects: SceneObject[];
}
-export function childrenOf(scene: SceneAsset, parentId: string): SceneObject[] {
- return scene.objects.filter((o) => o.parentId === parentId);
+export function findObject(tree: ObjectTree, id: string): SceneObject | undefined {
+ return tree.objects.find((o) => o.id === id);
+}
+
+export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
+ return tree.objects.filter((o) => o.parentId === parentId);
}
/** All objects below `id`, depth-first, parents before children. */
-export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] {
+export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
const result: SceneObject[] = [];
const visit = (parentId: string) => {
- for (const child of childrenOf(scene, parentId)) {
+ for (const child of childrenOf(tree, parentId)) {
result.push(child);
visit(child.id);
}
@@ -31,37 +36,55 @@ export function descendantsOf(scene: SceneAsset, id: string): SceneObject[] {
}
/** Whether `ancestorId` is `objectId` itself or one of its ancestors (guards against cycles). */
-export function isSelfOrAncestor(scene: SceneAsset, ancestorId: string, objectId: string): boolean {
- let current: SceneObject | undefined = findObject(scene, objectId);
+export function isSelfOrAncestor(tree: ObjectTree, ancestorId: string, objectId: string): boolean {
+ let current: SceneObject | undefined = findObject(tree, objectId);
const seen = new Set();
while (current && !seen.has(current.id)) {
if (current.id === ancestorId) return true;
seen.add(current.id);
- current = current.parentId ? findObject(scene, current.parentId) : undefined;
+ current = current.parentId ? findObject(tree, current.parentId) : undefined;
}
return false;
}
+/**
+ * Of the given ids, those that have no ancestor among the ids themselves – the tops of the
+ * selected subtrees. Whole subtrees are what group operations (move, remove, group) act on.
+ */
+export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[] {
+ const set = new Set(ids);
+ return ids.filter((id) => {
+ let current = findObject(tree, id);
+ const seen = new Set();
+ while (current?.parentId && !seen.has(current.id)) {
+ seen.add(current.id);
+ if (set.has(current.parentId)) return false;
+ current = findObject(tree, current.parentId);
+ }
+ return true;
+ });
+}
+
/** Position of an object in world units (all local positions along the chain summed up). */
-export function worldPosition(scene: SceneAsset, object: SceneObject): Position {
+export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
const result = { ...object.position };
- let parent = object.parentId ? findObject(scene, object.parentId) : undefined;
+ let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
const seen = new Set([object.id]);
while (parent && !seen.has(parent.id)) {
result.x += parent.position.x;
result.y += parent.position.y;
result.z += parent.position.z;
seen.add(parent.id);
- parent = parent.parentId ? findObject(scene, parent.parentId) : undefined;
+ parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
}
return result;
}
/** The local position that puts an object at `world` under the given parent ('' = top level). */
-export function localPositionFor(scene: SceneAsset, parentId: string, world: Position): Position {
- const parent = parentId ? findObject(scene, parentId) : undefined;
+export function localPositionFor(tree: ObjectTree, parentId: string, world: Position): Position {
+ const parent = parentId ? findObject(tree, parentId) : undefined;
if (!parent) return { ...world };
- const origin = worldPosition(scene, parent);
+ const origin = worldPosition(tree, parent);
return { x: world.x - origin.x, y: world.y - origin.y, z: world.z - origin.z };
}
@@ -74,11 +97,11 @@ export interface TreeEntry {
* Objects in tree order: top-level objects in storage order, each followed by its subtree.
* This is the order the hierarchy panel shows and the tie-break order for equal `z`.
*/
-export function treeOrder(scene: SceneAsset): TreeEntry[] {
+export function treeOrder(tree: ObjectTree): TreeEntry[] {
const result: TreeEntry[] = [];
- const ids = new Set(scene.objects.map((o) => o.id));
+ const ids = new Set(tree.objects.map((o) => o.id));
const visit = (parentId: string, depth: number) => {
- for (const object of scene.objects) {
+ for (const object of tree.objects) {
if (object.parentId !== parentId) continue;
result.push({ object, depth });
visit(object.id, depth + 1);
@@ -86,7 +109,7 @@ export function treeOrder(scene: SceneAsset): TreeEntry[] {
};
visit('', 0);
// Objects whose parent is missing (should not happen after loading) are listed at the top level.
- for (const object of scene.objects) {
+ for (const object of tree.objects) {
if (object.parentId && !ids.has(object.parentId) && !result.some((e) => e.object === object)) result.push({ object, depth: 0 });
}
return result;
diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts
index 9ccd3df2..c4b220be 100644
--- a/src/core/properties/PropertySchema.ts
+++ b/src/core/properties/PropertySchema.ts
@@ -47,6 +47,8 @@ export const ASSET_SCHEMAS: Record = {
// Triggers report collisions but never block "Move and slide"; solid colliders do both.
{ key: 'isTrigger', label: 'Trigger (no blocking)', kind: 'boolean', default: false },
],
+ // A container is pure structure: its content is the template, not a property.
+ container: [],
};
/** Properties every scene object has regardless of its asset type. */
diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts
index 161e264a..3312adbb 100644
--- a/src/core/store/serialize.ts
+++ b/src/core/store/serialize.ts
@@ -40,7 +40,7 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
const type = a.type;
const name = typeof a.name === 'string' ? a.name : type;
const props = normalizeBag(a.props, schema, false);
- if (type === 'scene') {
+ if (type === 'scene' || type === 'container') {
assets.push({ id, type, name, props, objects: normalizeObjects(a.objects, schemas) });
} else if (type === 'behavior') {
assets.push({ id, type, name, props, variables: variables.get(id) ?? [], graph: normalizeGraph(a.graph) });
@@ -85,6 +85,7 @@ function normalizeObjects(raw: unknown, schemas: Map): S
overrides: normalizeBag(o.overrides, schema, true),
// `ownerId` is the pre-hierarchy name of the same link (documents saved before parenting).
parentId: typeof o.parentId === 'string' ? o.parentId : typeof o.ownerId === 'string' ? o.ownerId : '',
+ templateId: typeof o.templateId === 'string' ? o.templateId : '',
});
}
// Parents must exist in the same scene, differ from the object itself and not form cycles.
diff --git a/src/editor/App.ts b/src/editor/App.ts
index 71466418..60e27fb1 100644
--- a/src/editor/App.ts
+++ b/src/editor/App.ts
@@ -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);
diff --git a/src/editor/actions.ts b/src/editor/actions.ts
new file mode 100644
index 00000000..b4a7a00f
--- /dev/null
+++ b/src/editor/actions.ts
@@ -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));
+}
diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts
index 2cb50106..5b1eb31c 100644
--- a/src/editor/panels/AssetPanel.ts
+++ b/src/editor/panels/AssetPanel.ts
@@ -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 = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider' };
+const TYPE_LABELS: Record = { 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, [
diff --git a/src/editor/panels/HierarchyPanel.ts b/src/editor/panels/HierarchyPanel.ts
index b3bf995d..9be33d3e 100644
--- a/src/editor/panels/HierarchyPanel.ts
+++ b/src/editor/panels/HierarchyPanel.ts
@@ -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 : []);
}
}
diff --git a/src/editor/panels/Inspector.ts b/src/editor/panels/Inspector.ts
index 3decae68..f32d00b0 100644
--- a/src/editor/panels/Inspector.ts
+++ b/src/editor/panels/Inspector.ts
@@ -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 = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record = { 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;
}
diff --git a/src/editor/state.ts b/src/editor/state.ts
index af3ade2a..757e8cc1 100644
--- a/src/editor/state.ts
+++ b/src/editor/state.ts
@@ -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;
}
diff --git a/src/runtime/World.ts b/src/runtime/World.ts
index 19c30573..d101e692 100644
--- a/src/runtime/World.ts
+++ b/src/runtime/World.ts
@@ -1,4 +1,5 @@
import { worldPositionOf } from '../core/graph';
+import { instantiateContainer } from '../core/model/container';
import { newId } from '../core/ids';
import type { Asset } from '../core/model/Asset';
import { isPlaceableType } from '../core/model/AssetTypes';
@@ -96,9 +97,16 @@ export class World {
if (!scene || !asset || !isPlaceableType(asset.type)) return null;
const parentEntity = parent && this.entities.includes(parent) ? parent : null;
const object = createSceneObject(newId(), asset.id, position, parentEntity?.objectId ?? '');
- scene.objects.push(object);
+ // A container brings its content along (parented below it, in template order).
+ const created = [object, ...(asset.type === 'container' ? instantiateContainer(this.project, asset, object.id) : [])];
+ scene.objects.push(...created);
const entity = this.materialize(object, asset);
entity.parent = parentEntity;
+ for (const child of created.slice(1)) {
+ const childAsset = findAsset(this.project, child.assetId);
+ if (!childAsset || childAsset.type === 'scene') continue;
+ this.materialize(child, childAsset).parent = this.findByObjectId(child.parentId) ?? null;
+ }
this.structureChanged = true;
return entity;
}
diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts
index 777caa48..eabb46a3 100644
--- a/src/ui/Theme.ts
+++ b/src/ui/Theme.ts
@@ -35,6 +35,10 @@ export const Theme = {
/** Collider outlines: solid (blocking) and trigger (events only). */
collider: 0x4fd18b,
trigger: 0xf2c84b,
+ /** Container (group) symbols. */
+ container: 0x3fb8c9,
+ /** Rubber-band selection rectangle. */
+ marquee: 0x4c8dff,
/** Node graph: canvas, node chrome, per-category title colors, port colors, live highlight. */
graphBackground: 0x15171b,
node: 0x262a32,
diff --git a/src/ui/index.ts b/src/ui/index.ts
index 7a613e8a..87e86167 100644
--- a/src/ui/index.ts
+++ b/src/ui/index.ts
@@ -8,7 +8,7 @@ export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel';
export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer';
-export { ListView, type ListItem } from './widgets/ListView';
+export { ListView, type ListItem, type PickModifiers } from './widgets/ListView';
export { Popup, Menu, Dialog, type MenuItem } from './widgets/Popup';
export { TextField } from './widgets/TextField';
export { NumberField } from './widgets/NumberField';
diff --git a/src/ui/widgets/ListView.ts b/src/ui/widgets/ListView.ts
index df4bdc31..3a9bb3be 100644
--- a/src/ui/widgets/ListView.ts
+++ b/src/ui/widgets/ListView.ts
@@ -17,16 +17,26 @@ export interface ListItem {
/** Pixels the pointer must travel before a press turns into a drag. */
const DRAG_THRESHOLD = 4;
+/** Modifier keys held while a row was clicked. */
+export interface PickModifiers {
+ /** Ctrl (or ⌘) – toggle the row in a multi-selection. */
+ ctrl: boolean;
+ shift: boolean;
+}
+
/**
- * Single-select list of rows. Rows are rebuilt from `setItems()`; selection is by id so
- * it survives rebuilds. Emits select, activate (double-click / Enter) and context events.
- * With `onDrop` set, rows can be dragged onto other rows (or onto the empty area below).
+ * List of rows with one selected row (by id, so it survives `setItems()` rebuilds) and,
+ * optionally, further *highlighted* rows the owner manages (a multi-selection). Emits select,
+ * activate (double-click / Enter) and context events. A click with Ctrl/Shift held does not
+ * change the selection itself but reports the modifiers, so the owner can toggle. With
+ * `onDrop` set, rows can be dragged onto other rows (or onto the empty area below).
*/
export class ListView extends Widget {
private items: ListItem[] = [];
private rows: Row[] = [];
private selectedId: string | null = null;
- onSelect?: (id: string | null) => void;
+ private highlighted = new Set();
+ onSelect?: (id: string | null, modifiers?: PickModifiers) => void;
onActivate?: (id: string) => void;
onContextMenu?: (id: string, globalX: number, globalY: number) => void;
/** Called when a dragged row is released over another row (`targetId`) or over empty space (null). */
@@ -66,11 +76,16 @@ export class ListView extends Widget {
this.rows = items.map((item, index) => {
const row = new Row(item, this.rowHeight);
row.on('pointerdown', (e: FederatedPointerEvent) => {
+ const modifiers: PickModifiers = { ctrl: e.ctrlKey || e.metaKey, shift: e.shiftKey };
if (e.button === 2) {
- this.select(item.id);
+ // A right-click inside a multi-selection keeps it (the menu acts on all of it).
+ if (!this.highlighted.has(item.id)) this.select(item.id);
this.onContextMenu?.(item.id, e.global.x, e.global.y);
+ } else if (modifiers.ctrl || modifiers.shift) {
+ this.onSelect?.(item.id, modifiers);
} else {
- this.select(item.id);
+ // Clicking a highlighted row keeps the multi-selection so it can be dragged as a whole.
+ if (!this.highlighted.has(item.id) || item.id === this.selectedId) this.select(item.id);
if (e.detail === 2) this.onActivate?.(item.id);
else if (this.onDrop) this.drag = { id: item.id, startX: e.global.x, startY: e.global.y, active: false, targetId: null };
}
@@ -92,6 +107,16 @@ export class ListView extends Widget {
if (!silent) this.onSelect?.(id);
}
+ /** Rows drawn as selected in addition to the selected one (the owner's multi-selection). */
+ highlight(ids: Iterable): void {
+ this.highlighted = new Set(ids);
+ this.redraw();
+ }
+
+ get highlightedIds(): string[] {
+ return [...this.highlighted];
+ }
+
override measure(): Size {
return { w: this.minWidth, h: Math.max(this.minHeight, this.items.length * this.rowHeight) };
}
@@ -120,7 +145,7 @@ export class ListView extends Widget {
}
override redraw(): void {
- for (const row of this.rows) row.setSelected(row.item.id === this.selectedId, this.focused);
+ for (const row of this.rows) row.setSelected(row.item.id === this.selectedId || this.highlighted.has(row.item.id), this.focused);
this.emptyLabel.text = this.items.length === 0 ? this.emptyText : '';
this.emptyLabel.position.set(Theme.padding, Math.round((this.rowHeight - this.emptyLabel.height) / 2));
this.drawDropMarker();
diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts
index 2714fce5..ed729a30 100644
--- a/src/viewport/Viewport.ts
+++ b/src/viewport/Viewport.ts
@@ -1,15 +1,16 @@
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
+import { CompoundCommand } from '../core/commands/CompoundCommand';
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
import { type Position, type PropertyBag, sortByZ, type Vec2 } from '../core/model/Properties';
-import { treeOrder, worldPosition } from '../core/model/hierarchy';
+import { subtreeRoots, treeOrder, worldPosition } from '../core/model/hierarchy';
import { findAsset } from '../core/model/Project';
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
import { type ColliderShape, colliderShape, colliderSize, isTrigger } from '../core/properties/collider';
import { resolveSceneObjectProps } from '../core/properties/resolve';
import type { World } from '../runtime/World';
import { Theme, textStyle, Widget, type Size } from '../ui';
-import type { EditorState } from '../editor/state';
+import { type EditorState, objectSelection } from '../editor/state';
/** What the viewport draws: the same shape whether it comes from the model or the world. */
interface DrawItem {
@@ -27,6 +28,10 @@ interface DrawItem {
/** World-unit size of the behavior symbol (a diamond). */
const BEHAVIOR_SYMBOL = 28;
+/** World-unit size of the container symbol (a bracketed square). */
+const CONTAINER_SYMBOL = 24;
+/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
+const MARQUEE_THRESHOLD = 3;
/** Screen-pixel half-width of the band along a collider's outline that grabs the collider. */
const COLLIDER_GRAB_BAND = 6;
@@ -69,12 +74,28 @@ export interface ViewportOptions {
game?: boolean;
}
+/** A drag of the selected objects: every moved root with the local position it started from. */
+interface ObjectDrag {
+ sceneId: string;
+ items: { objectId: string; start: Position }[];
+ pointer: Vec2;
+ moved: boolean;
+}
+
+/** A rubber-band selection in progress (viewport-local pixels). */
+interface Marquee {
+ start: Vec2;
+ current: Vec2;
+ active: boolean;
+}
+
/**
* Draws a scene as coloured boxes. The **scene view** shows the model with a grid, selection
- * outline, drag-to-move, middle-drag panning and wheel zoom – in edit and play mode alike.
- * The **game view** shows the running world: the scene's camera defines what is visible,
- * only its frame is drawn (letterboxed), everything outside it is masked and not rendered
- * at all. Without a camera the game view renders nothing.
+ * outlines, drag-to-move (all selected objects at once), rubber-band selection on empty
+ * space, Ctrl-click to add/remove, middle-drag or Shift-drag panning and wheel zoom – in
+ * edit and play mode alike. The **game view** shows the running world: the scene's camera
+ * defines what is visible, only its frame is drawn (letterboxed), everything outside it is
+ * masked and not rendered at all. Without a camera the game view renders nothing.
*/
export class Viewport extends Widget {
private readonly game: boolean;
@@ -102,8 +123,11 @@ export class Viewport extends Widget {
/** Edit-mode view: world point at the viewport centre and zoom. */
private view = { x: 0, y: 0, zoom: 1 };
private panning: { startX: number; startY: number; viewX: number; viewY: number } | null = null;
- private dragging: { objectId: string; sceneId: string; start: Position; pointer: Vec2; moved: boolean } | null = null;
+ private dragging: ObjectDrag | null = null;
+ private marquee: Marquee | null = null;
private visibleCount = 0;
+ /** Items of the last redraw (for hit tests such as the rubber band). */
+ private lastItems: DrawItem[] = [];
constructor(private readonly state: EditorState, options: ViewportOptions = {}) {
super();
@@ -125,7 +149,12 @@ export class Viewport extends Widget {
e.stopPropagation();
return;
}
- if (e.button === 0 && e.target === this) this.state.select(null);
+ if (e.button === 0 && e.target === this) {
+ // Empty space: a click deselects, a drag becomes a rubber band (decided on release).
+ const local = this.toLocal(e.global);
+ this.marquee = { start: { x: local.x, y: local.y }, current: { x: local.x, y: local.y }, active: false };
+ e.stopPropagation();
+ }
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
if (this.panning) {
@@ -138,23 +167,46 @@ export class Viewport extends Widget {
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
if (!this.dragging.moved && Math.hypot(dx, dy) < 2) return;
this.dragging.moved = true;
- const object = this.state.activeScene?.objects.find((o) => o.id === this.dragging?.objectId);
- if (!object) return;
+ const scene = this.state.activeScene;
+ if (!scene) return;
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
- object.position = { x: Math.round(this.dragging.start.x + dx), y: Math.round(this.dragging.start.y + dy), z: this.dragging.start.z };
+ for (const item of this.dragging.items) {
+ const object = scene.objects.find((o) => o.id === item.objectId);
+ if (object) object.position = { x: Math.round(item.start.x + dx), y: Math.round(item.start.y + dy), z: item.start.z };
+ }
this.state.preview();
+ } else if (this.marquee) {
+ const local = this.toLocal(e.global);
+ this.marquee.current = { x: local.x, y: local.y };
+ if (!this.marquee.active && Math.hypot(local.x - this.marquee.start.x, local.y - this.marquee.start.y) < MARQUEE_THRESHOLD) return;
+ this.marquee.active = true;
+ this.redraw();
}
});
const finish = () => {
this.panning = null;
+ const marquee = this.marquee;
+ this.marquee = null;
+ if (marquee) {
+ if (marquee.active) this.selectInMarquee(marquee);
+ else this.state.select(null);
+ this.redraw();
+ }
const drag = this.dragging;
this.dragging = null;
if (!drag || !drag.moved) return;
- const object = this.state.activeScene?.objects.find((o) => o.id === drag.objectId);
- if (!object) return;
- const next = { ...object.position };
- object.position = { ...drag.start };
- this.state.execute(new MoveSceneObjectCommand(drag.sceneId, drag.objectId, next, drag.start));
+ const scene = this.state.activeScene;
+ if (!scene) return;
+ const moves: MoveSceneObjectCommand[] = [];
+ for (const item of drag.items) {
+ const object = scene.objects.find((o) => o.id === item.objectId);
+ if (!object) continue;
+ const next = { ...object.position };
+ object.position = { ...item.start };
+ moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next, item.start));
+ }
+ if (moves.length === 1) this.state.execute(moves[0]!);
+ else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
};
this.on('pointerup', finish);
this.on('pointerupoutside', finish);
@@ -207,6 +259,15 @@ export class Viewport extends Widget {
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
}
+ /** Global screen position of an object's world position (test hook), null when not drawn. */
+ objectScreen(objectId: string): Vec2 | null {
+ const item = this.lastItems.find((i) => i.id === objectId);
+ if (!item) return null;
+ const local = this.worldToScreen(item.x, item.y);
+ const origin = this.getGlobalPosition();
+ return { x: origin.x + local.x, y: origin.y + local.y };
+ }
+
override measure(): Size {
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
}
@@ -274,6 +335,7 @@ export class Viewport extends Widget {
private itemSize(item: DrawItem): { w: number; h: number } {
if (item.type === 'sprite') return { w: Number(item.props.width ?? 1), h: Number(item.props.height ?? 1) };
if (item.type === 'behavior') return { w: BEHAVIOR_SYMBOL, h: BEHAVIOR_SYMBOL };
+ if (item.type === 'container') return { w: CONTAINER_SYMBOL, h: CONTAINER_SYMBOL };
if (item.type === 'collider') return colliderSize(item.props);
return cameraFrame(item.props);
}
@@ -294,6 +356,7 @@ export class Viewport extends Widget {
if (!view) return;
const { items, background } = this.collectItems();
+ this.lastItems = items;
const { rect } = view;
if (this.game || this.state.activeScene) this.bg.rect(rect.x, rect.y, rect.w, rect.h).fill(background);
this.contentMask.rect(rect.x, rect.y, rect.w, rect.h).fill(0xffffff);
@@ -337,6 +400,12 @@ export class Viewport extends Widget {
node.moveTo(-6, -3).lineTo(-1, -3).lineTo(-1, 3).lineTo(6, 3).stroke({ width: 2, color: Theme.colors.accentText });
node.circle(-6, -3, 2).fill(Theme.colors.accentText);
node.circle(6, 3, 2).fill(Theme.colors.accentText);
+ } else if (item.type === 'container') {
+ // A square with four small boxes inside: the group symbol. Its content is drawn as
+ // ordinary objects (they are ordinary objects, parented below the container).
+ const r = CONTAINER_SYMBOL / 2;
+ node.roundRect(-r, -r, CONTAINER_SYMBOL, CONTAINER_SYMBOL, 3).fill({ color: Theme.colors.container, alpha: 0.25 }).stroke({ width: 2, color: Theme.colors.container });
+ for (const [bx, by] of [[-7, -7], [1, -7], [-7, 1], [1, 1]] as const) node.rect(bx, by, 6, 6).fill(Theme.colors.container);
} else if (item.type === 'collider') {
// Outline plus a faint fill so the shape reads as "area". Colliders usually sit right on
// top of their body, so only the outline band is hit-testable: clicks inside still reach
@@ -437,19 +506,46 @@ export class Viewport extends Widget {
if (origin.y >= 0 && origin.y <= this.h) this.grid.moveTo(0, origin.y).lineTo(this.w, origin.y).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
}
+ /** Selection outlines (the primary object gets the corner handles) and the rubber band. */
private drawGizmos(items: DrawItem[], view: View): void {
const s = this.state.selection;
- if (!s || s.kind !== 'object') return;
- const item = items.find((i) => i.id === s.objectId);
- if (!item) return;
- const { w, h } = this.itemSize(item);
- const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
- const sw = w * view.scale;
- const sh = h * view.scale;
- this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
- for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
- this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
+ if (s?.kind === 'object') {
+ for (const id of s.objectIds) {
+ const item = items.find((i) => i.id === id);
+ if (!item) continue;
+ const { w, h } = this.itemSize(item);
+ const tl = this.worldToScreen(item.x - w / 2, item.y - h / 2, view);
+ const sw = w * view.scale;
+ const sh = h * view.scale;
+ this.gizmos.rect(tl.x - 2, tl.y - 2, sw + 4, sh + 4).stroke({ width: 2, color: Theme.colors.gizmo });
+ if (id !== s.objectId) continue;
+ for (const [cx, cy] of [[tl.x, tl.y], [tl.x + sw, tl.y], [tl.x, tl.y + sh], [tl.x + sw, tl.y + sh]] as const) {
+ this.gizmos.rect(cx - 3, cy - 3, 6, 6).fill(Theme.colors.gizmo);
+ }
+ }
}
+ const m = this.marquee;
+ if (m?.active) {
+ const x = Math.min(m.start.x, m.current.x);
+ const y = Math.min(m.start.y, m.current.y);
+ const w = Math.abs(m.current.x - m.start.x);
+ const h = Math.abs(m.current.y - m.start.y);
+ this.gizmos.rect(x, y, w, h).fill({ color: Theme.colors.marquee, alpha: 0.12 }).stroke({ width: 1, color: Theme.colors.marquee });
+ }
+ }
+
+ /** Rubber band released: selects every object whose world position lies inside it. */
+ private selectInMarquee(marquee: Marquee): void {
+ const scene = this.state.activeScene;
+ if (!scene) return;
+ const a = this.screenToWorld(marquee.start.x, marquee.start.y);
+ const b = this.screenToWorld(marquee.current.x, marquee.current.y);
+ const minX = Math.min(a.x, b.x);
+ const maxX = Math.max(a.x, b.x);
+ const minY = Math.min(a.y, b.y);
+ const maxY = Math.max(a.y, b.y);
+ const ids = this.lastItems.filter((i) => i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
+ this.state.select(objectSelection(scene.id, ids));
}
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
@@ -458,9 +554,17 @@ export class Viewport extends Widget {
const object = scene?.objects.find((o) => o.id === objectId);
if (!scene || !object) return;
e.stopPropagation();
- this.state.select({ kind: 'object', sceneId: scene.id, objectId });
+ if (e.ctrlKey || e.metaKey || e.shiftKey) {
+ this.state.toggleObject(scene.id, objectId);
+ return;
+ }
+ // Clicking a selected object keeps the selection so the whole group can be dragged.
+ if (!this.state.selectedObjectIds.includes(objectId)) this.state.select(objectSelection(scene.id, [objectId]));
+ // Only the tops of the selected subtrees move; their descendants follow through the hierarchy.
+ const roots = subtreeRoots(scene, this.state.selectedObjectIds);
+ const items = roots.map((id) => scene.objects.find((o) => o.id === id)).filter((o): o is NonNullable => Boolean(o)).map((o) => ({ objectId: o.id, start: { ...o.position } }));
const local = this.toLocal(e.global);
- this.dragging = { objectId, sceneId: scene.id, start: { ...object.position }, pointer: { x: local.x, y: local.y }, moved: false };
+ this.dragging = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
}
}
diff --git a/tests/core/container.test.ts b/tests/core/container.test.ts
new file mode 100644
index 00000000..deeb5352
--- /dev/null
+++ b/tests/core/container.test.ts
@@ -0,0 +1,195 @@
+import { describe, expect, it } from 'vitest';
+import { CreateAssetCommand, DeleteAssetCommand } from '../../src/core/commands/assetCommands';
+import { CommandStack } from '../../src/core/commands/CommandStack';
+import { CompoundCommand } from '../../src/core/commands/CompoundCommand';
+import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
+import { MoveSceneObjectCommand, PlaceAssetCommand, RemoveSceneObjectsCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
+import type { ContainerAsset } from '../../src/core/model/Asset';
+import { subtreeRoots, treeOrder, worldPosition } from '../../src/core/model/hierarchy';
+import { createEmptyProject, findScene } from '../../src/core/model/Project';
+import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
+import { World } from '../../src/runtime/World';
+
+/**
+ * Scene with a sprite asset and three boxes: a at (100,100,z1), b at (10,0) and c at (0,5)
+ * parented under b (local (-10,5)).
+ */
+function setup() {
+ const project = createEmptyProject('p', 'Containers');
+ const stack = new CommandStack(project);
+ const scene = new CreateAssetCommand('scene', 'Level');
+ const sprite = new CreateAssetCommand('sprite', 'Box');
+ stack.execute(scene);
+ stack.execute(sprite);
+ const a = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 100, y: 100, z: 1 });
+ const b = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 10, y: 0 });
+ const c = new PlaceAssetCommand(scene.asset.id, sprite.asset.id, { x: 0, y: 5 });
+ for (const cmd of [a, b, c]) stack.execute(cmd);
+ stack.execute(new SetParentCommand(scene.asset.id, c.objectId, b.objectId));
+ const sceneOf = () => findScene(project, scene.asset.id)!;
+ const object = (id: string) => sceneOf().objects.find((o) => o.id === id)!;
+ const world = (id: string) => worldPosition(sceneOf(), object(id));
+ return { project, stack, sceneId: scene.asset.id, spriteId: sprite.asset.id, a: a.objectId, b: b.objectId, c: c.objectId, sceneOf, object, world };
+}
+
+describe('subtree roots', () => {
+ it('drops ids that have a selected ancestor', () => {
+ const { sceneOf, a, b, c } = setup();
+ expect(subtreeRoots(sceneOf(), [a, b, c])).toEqual([a, b]);
+ expect(subtreeRoots(sceneOf(), [c, a])).toEqual([c, a]);
+ });
+});
+
+describe('CreateContainerCommand', () => {
+ it('centres the template on the selection and re-parents the roots, keeping world positions', () => {
+ const { project, stack, sceneId, spriteId, a, b, c, sceneOf, object, world } = setup();
+ const before = { a: world(a), b: world(b), c: world(c) };
+ const command = new CreateContainerCommand(sceneId, [a, b]);
+ stack.execute(command);
+
+ // Bounding box of (100,100) and (10,0) → centre (55,50); z is always 0.
+ const container = object(command.objectId);
+ expect(container.assetId).toBe(command.assetId);
+ expect(container.parentId).toBe('');
+ expect(container.position).toEqual({ x: 55, y: 50, z: 0 });
+ expect(object(a).parentId).toBe(command.objectId);
+ expect(object(a).position).toEqual({ x: 45, y: 50, z: 1 });
+ expect(object(b).position).toEqual({ x: -45, y: -50, z: 0 });
+ expect(object(c).parentId).toBe(b); // descendants keep their local frame
+ expect(object(c).position).toEqual({ x: -10, y: 5, z: 0 });
+ expect({ a: world(a), b: world(b), c: world(c) }).toEqual(before);
+
+ // The asset holds the same structure with private ids; the members point at it.
+ const asset = project.assets.find((x) => x.id === command.assetId) as ContainerAsset;
+ expect(asset.type).toBe('container');
+ expect(asset.objects).toHaveLength(3);
+ const template = (id: string) => asset.objects.find((t) => t.id === object(id).templateId)!;
+ expect(template(a).position).toEqual({ x: 45, y: 50, z: 1 });
+ expect(template(a).parentId).toBe('');
+ expect(template(c).parentId).toBe(template(b).id);
+ expect(template(c).assetId).toBe(spriteId);
+ expect(asset.objects.map((t) => t.id)).not.toContain(a);
+ expect(treeOrder(sceneOf()).map((e) => e.object.id)).toEqual([command.objectId, a, b, c]);
+
+ stack.undo();
+ expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
+ expect(object(a).parentId).toBe('');
+ expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
+ expect(object(a).templateId).toBe('');
+ expect(project.assets.some((x) => x.id === command.assetId)).toBe(false);
+ stack.redo();
+ expect(object(a).parentId).toBe(command.objectId);
+ expect(world(a)).toEqual(before.a);
+ });
+
+ it('places the container below the parent all roots share', () => {
+ const { stack, sceneId, spriteId, b, c, object, world } = setup();
+ const d = new PlaceAssetCommand(sceneId, spriteId, { x: 30, y: 25 }, b);
+ stack.execute(d);
+ const command = new CreateContainerCommand(sceneId, [c, d.objectId]);
+ stack.execute(command);
+ const container = object(command.objectId);
+ expect(container.parentId).toBe(b);
+ // World centre of c (0,5) and d (30,25) is (15,15); b is at (10,0) → local (5,15).
+ expect(container.position).toEqual({ x: 5, y: 15, z: 0 });
+ expect(world(c)).toEqual({ x: 0, y: 5, z: 0 });
+ expect(world(d.objectId)).toEqual({ x: 30, y: 25, z: 0 });
+ });
+
+ it('rejects an empty or unknown selection', () => {
+ const { stack, sceneId } = setup();
+ expect(() => stack.execute(new CreateContainerCommand(sceneId, []))).toThrow(/at least one/);
+ expect(() => stack.execute(new CreateContainerCommand(sceneId, ['nope']))).toThrow(/not found/);
+ });
+});
+
+describe('placing a container', () => {
+ it('instantiates the template with fresh ids below the placed object', () => {
+ const { project, stack, sceneId, a, b, c, sceneOf, object, world } = setup();
+ const create = new CreateContainerCommand(sceneId, [a, b]);
+ stack.execute(create);
+ const place = new PlaceAssetCommand(sceneId, create.assetId, { x: 1000, y: 0 });
+ stack.execute(place);
+
+ const copies = sceneOf().objects.filter((o) => ![a, b, c, create.objectId].includes(o.id));
+ expect(copies).toHaveLength(4); // container + 3 template objects
+ const root = object(place.objectId);
+ expect(root.assetId).toBe(create.assetId);
+ const asset = project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
+ const copyOf = (id: string) => copies.find((o) => o.templateId === object(id).templateId)!;
+ expect(copyOf(a).parentId).toBe(place.objectId);
+ expect(world(copyOf(a).id)).toEqual({ x: 1045, y: 50, z: 1 });
+ expect(copyOf(c).parentId).toBe(copyOf(b).id);
+ expect(world(copyOf(c).id)).toEqual({ x: 945, y: -45, z: 0 });
+ expect(new Set(copies.map((o) => o.id)).size).toBe(4);
+ expect(asset.objects.map((t) => t.id)).not.toContain(copyOf(a).id);
+
+ stack.undo();
+ expect(sceneOf().objects).toHaveLength(4);
+ expect(sceneOf().objects.some((o) => o.id === place.objectId)).toBe(false);
+ });
+
+ it('survives a save/load round trip', () => {
+ const { project, stack, sceneId, a, b } = setup();
+ const create = new CreateContainerCommand(sceneId, [a, b]);
+ stack.execute(create);
+ const loaded = deserializeProject(serializeProject(project));
+ const asset = loaded.assets.find((x) => x.id === create.assetId) as ContainerAsset;
+ expect(asset.type).toBe('container');
+ expect(asset.objects).toHaveLength(3);
+ const scene = findScene(loaded, sceneId)!;
+ expect(scene.objects.find((o) => o.id === a)?.templateId).toBe(asset.objects[0]!.id);
+ expect(scene.objects.find((o) => o.id === create.objectId)?.assetId).toBe(create.assetId);
+ });
+
+ it('deleting an asset removes its template objects from containers too', () => {
+ const { project, stack, sceneId, spriteId, a, b } = setup();
+ const create = new CreateContainerCommand(sceneId, [a, b]);
+ stack.execute(create);
+ const asset = () => project.assets.find((x) => x.id === create.assetId) as ContainerAsset;
+ stack.execute(new DeleteAssetCommand(spriteId));
+ expect(asset().objects).toHaveLength(0);
+ expect(findScene(project, sceneId)!.objects.map((o) => o.id)).toEqual([create.objectId]);
+ stack.undo();
+ expect(asset().objects).toHaveLength(3);
+ expect(findScene(project, sceneId)!.objects).toHaveLength(4);
+ });
+
+ it('spawns a container with its content at runtime', () => {
+ const { project, stack, sceneId, a, b } = setup();
+ const create = new CreateContainerCommand(sceneId, [a, b]);
+ stack.execute(create);
+ const world = World.instantiate(project, sceneId);
+ const spawned = world.spawn(create.assetId, { x: 500, y: 500, z: 0 }, null)!;
+ expect(spawned.type).toBe('container');
+ const children = world.entities.filter((e) => e.parent === spawned);
+ expect(children).toHaveLength(2);
+ const grandchild = world.entities.find((e) => e.parent && children.includes(e.parent))!;
+ expect(world.worldPosition(children.find((e) => e.z === 1)!)).toEqual({ x: 545, y: 550, z: 1 });
+ expect(world.worldPosition(grandchild)).toEqual({ x: 445, y: 455, z: 0 });
+ expect(world.syncToModel()).toBe(true);
+ expect(findScene(project, sceneId)!.objects).toHaveLength(8);
+ });
+});
+
+describe('multi-selection commands', () => {
+ it('removes several subtrees as one step, covering nested ids', () => {
+ const { stack, sceneId, a, b, c, sceneOf } = setup();
+ stack.execute(new RemoveSceneObjectsCommand(sceneId, [b, c, a]));
+ expect(sceneOf().objects).toHaveLength(0);
+ stack.undo();
+ expect(sceneOf().objects.map((o) => o.id)).toEqual([a, b, c]);
+ expect(sceneOf().objects.find((o) => o.id === c)?.parentId).toBe(b);
+ });
+
+ it('a compound move rolls back entirely when one part fails', () => {
+ const { stack, sceneId, a, object } = setup();
+ const compound = new CompoundCommand('Move objects', [
+ new MoveSceneObjectCommand(sceneId, a, { x: 1, y: 2, z: 3 }),
+ new MoveSceneObjectCommand(sceneId, 'missing', { x: 0, y: 0, z: 0 }),
+ ]);
+ expect(() => stack.execute(compound)).toThrow(/not found/);
+ expect(object(a).position).toEqual({ x: 100, y: 100, z: 1 });
+ expect(stack.canUndo).toBe(true); // the earlier commands of setup()
+ });
+});
diff --git a/tests/core/serialize.test.ts b/tests/core/serialize.test.ts
index 775591e1..3d5703a4 100644
--- a/tests/core/serialize.test.ts
+++ b/tests/core/serialize.test.ts
@@ -53,6 +53,7 @@ describe('serialize', () => {
position: { x: 5, y: 0, z: 0 },
overrides: { color: 0x00ff00, width: 1 },
parentId: '',
+ templateId: '',
});
expect(project.assets[1]!.name).toBe('sprite');
expect(project.assets[1]!.props).toEqual({ width: 10, height: 100, color: 0xe0553f });
diff --git a/tools/e2e.mjs b/tools/e2e.mjs
index c0a60559..c1e9b7af 100644
--- a/tools/e2e.mjs
+++ b/tools/e2e.mjs
@@ -664,6 +664,111 @@ try {
await sleep(200);
await shot('22-trigger-collider');
+ // 16. Multi-selection. Reset both crates to known spots, then Ctrl-click the second row in
+ // the hierarchy: both are selected, the inspector shows the multi view.
+ await evaluate(`(() => {
+ const objs = __gm.state.activeScene.objects;
+ const o1 = objs.find(o => o.id === 'o1'); const o2 = objs.find(o => o.id === 'o2');
+ o1.position = { x: -150, y: 0, z: 0 }; o1.parentId = '';
+ o2.position = { x: 120, y: -80, z: 0 }; o2.parentId = '';
+ objs.find(o => o.id === 'oc2').overrides = { width: 140 };
+ __gm.state.select(null);
+ __gm.state.preview();
+ })()`);
+ await sleep(200);
+ const clickMod = async (x, y, modifiers) => {
+ await mouse('mouseMoved', x, y);
+ await mouse('mousePressed', x, y, 'left', { modifiers });
+ await mouse('mouseReleased', x, y, 'left', { modifiers });
+ await sleep(150);
+ };
+ await clickText('Red crate');
+ const blueRow3 = await locate('Blue crate');
+ await clickMod(blueRow3.x, blueRow3.y, 2); // Ctrl
+ s = JSON.parse(await stateJson());
+ check('Ctrl-click in the hierarchy adds to the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1","o2"]' && s.selection.objectId === 'o2', JSON.stringify(s.selection));
+ check('inspector shows the multi-selection', Boolean(await evaluate(`__gm.locate('2 OBJECTS SELECTED')`)));
+ await shot('23-multi-selected');
+ await clickMod(blueRow3.x, blueRow3.y, 2);
+ s = JSON.parse(await stateJson());
+ check('Ctrl-click again removes the object from the selection', s.selection?.kind === 'object' && JSON.stringify(s.selection.objectIds) === '["o1"]', JSON.stringify(s.selection));
+ await clickMod(blueRow3.x, blueRow3.y, 2);
+
+ // 16a. Create container: both crates (with their colliders and the Mover) become the content
+ // of a new container asset; the scene gets one container object at the selection's
+ // centre (-15,-40), the crates hang below it with recomputed local positions, world
+ // positions are unchanged, and the container is selected.
+ const assetsBeforeContainer = await evaluate('__gm.state.project.assets.length');
+ const objectsBeforeContainer = await evaluate('__gm.state.activeScene.objects.length');
+ await clickText('Create container');
+ await sleep(250);
+ s = JSON.parse(await stateJson());
+ const containerObj = s.selection?.kind === 'object' ? JSON.parse(await objectJson(s.selection.objectId)) : null;
+ const containerAsset = containerObj ? JSON.parse(await evaluate(`JSON.stringify(__gm.state.project.assets.find(a => a.id === '${containerObj.assetId}'))`)) : null;
+ check('Create container adds one container asset and one placed container, selected', containerAsset?.type === 'container' && s.selection.objectIds.length === 1 && (await evaluate('__gm.state.project.assets.length')) === assetsBeforeContainer + 1 && (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeContainer + 1, JSON.stringify({ containerObj, type: containerAsset?.type }));
+ check('the container sits at the centre of the selection', containerObj && containerObj.parentId === '' && containerObj.position.x === -15 && containerObj.position.y === -40 && containerObj.position.z === 0, JSON.stringify(containerObj?.position));
+ let o1c = JSON.parse(await objectJson('o1'));
+ const o2c = JSON.parse(await objectJson('o2'));
+ check('the crates are re-parented below the container with local positions relative to its centre', o1c.parentId === containerObj?.id && o1c.position.x === -135 && o1c.position.y === 40 && o2c.parentId === containerObj?.id && o2c.position.x === 135 && o2c.position.y === -40, JSON.stringify({ o1: o1c.position, o2: o2c.position }));
+ check('the template holds the whole subtree (2 crates, 2 colliders, the Mover)', containerAsset?.objects.length === 5 && containerAsset.objects.filter((t) => t.parentId === '').length === 2 && o1c.templateId && containerAsset.objects.some((t) => t.id === o1c.templateId), JSON.stringify(containerAsset?.objects.map((t) => [t.assetId, t.parentId === '' ? 'root' : 'child'])));
+ const orderC = JSON.parse(await evaluate('JSON.stringify(__gm.hierarchyOrder())'));
+ const cAt = orderC.indexOf(containerObj?.id);
+ check('hierarchy lists the container with its content indented below', cAt >= 0 && JSON.stringify(orderC.slice(cAt)) === JSON.stringify([containerObj.id, 'o1', 'o4', 'oc1', 'o2', 'oc2']), JSON.stringify(orderC));
+ check('container symbol is drawn in the overlay layer', containerObj && (await evaluate(`__gm.layerOf('${containerObj.id}')`)) === 'overlay');
+ await shot('24-container-created');
+
+ // 16b. Dragging the container symbol in the viewport moves the whole group; undo restores it.
+ const cScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('${containerObj.id}'))`));
+ await drag(cScreen.x, cScreen.y, cScreen.x + 100, cScreen.y);
+ const containerMoved = JSON.parse(await objectJson(containerObj.id));
+ o1c = JSON.parse(await objectJson('o1'));
+ check('dragging the container moves the group (children keep their local position)', containerMoved.position.x === 85 && o1c.position.x === -135, JSON.stringify({ container: containerMoved.position, o1: o1c.position }));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ check('undo moves the container back', JSON.parse(await objectJson(containerObj.id)).position.x === -15);
+
+ // 16c. Rubber band on empty space around the red crate (world x −230…−90, y −50…50): picks
+ // the objects whose position lies inside – the crate and its collider, not the Mover
+ // (at y = 110), not the camera (at the origin).
+ await drag(center.x - 230, center.y - 50, center.x - 90, center.y + 50);
+ s = JSON.parse(await stateJson());
+ check('rubber-band selection picks the objects inside it', s.selection?.kind === 'object' && JSON.stringify([...s.selection.objectIds].sort()) === '["o1","oc1"]', JSON.stringify(s.selection));
+ await shot('25-marquee');
+
+ // 16d. Dragging one of several selected objects moves all of them as one undo step.
+ const redScreen = JSON.parse(await evaluate(`JSON.stringify(__gm.objectScreen('o1'))`));
+ await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
+ await drag(redScreen.x, redScreen.y + 20, redScreen.x + 50, redScreen.y + 20);
+ o1c = JSON.parse(await objectJson('o1'));
+ const o2d = JSON.parse(await objectJson('o2'));
+ check('dragging a multi-selection moves every selected object', o1c.position.x === -85 && o2d.position.x === 185, JSON.stringify({ o1: o1c.position, o2: o2d.position }));
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ o1c = JSON.parse(await objectJson('o1'));
+ check('one undo restores both', o1c.position.x === -135 && JSON.parse(await objectJson('o2')).position.x === 135);
+
+ // 16e. Delete with a multi-selection removes all of them (with subtrees) as one step.
+ const objectsBeforeDelete = await evaluate('__gm.state.activeScene.objects.length');
+ await evaluate(`__gm.state.select({ kind: 'object', sceneId: 'scene1', objectId: 'o2', objectIds: ['o1', 'o2'] })`);
+ await keyPress('Delete', 'Delete', { windowsVirtualKeyCode: 46 });
+ await sleep(150);
+ check('Delete removes the whole multi-selection with its subtrees', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete - 5);
+ await keyPress('z', 'KeyZ', { modifiers: 2, windowsVirtualKeyCode: 90 });
+ await sleep(150);
+ check('undo restores all of them', (await evaluate('__gm.state.activeScene.objects.length')) === objectsBeforeDelete);
+
+ // 16f. Placing the container asset again from the asset panel creates a second instance:
+ // the root plus 5 content objects with fresh ids, the crate copy at the same offset.
+ await clickText(containerAsset.name, 'right');
+ await sleep(150);
+ await clickText('Place in scene');
+ await sleep(250);
+ const afterPlace = JSON.parse(await evaluate('JSON.stringify(__gm.state.activeScene.objects)'));
+ const secondRoot = afterPlace.find((o) => o.assetId === containerAsset.id && o.id !== containerObj.id);
+ const secondCrate = secondRoot && afterPlace.find((o) => o.parentId === secondRoot.id && o.templateId === o1c.templateId);
+ check('placing the container asset instantiates its content below a new container object', afterPlace.length === objectsBeforeDelete + 6 && secondCrate && secondCrate.position.x === -135 && secondCrate.position.y === 40, JSON.stringify({ count: afterPlace.length, secondCrate }));
+ await shot('26-container-placed');
+
check('no exceptions during run', !consoleLog.some((l) => l.startsWith('EXCEPTION')), consoleLog.filter((l) => l.startsWith('EXCEPTION')).slice(0, 3).join(' | '));
ws.close();
} catch (error) {