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) {