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

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

No files matched your search

+10 -7
View File
@@ -10,8 +10,10 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| Term | Meaning |
|---|---|
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`) and typed properties. Assets have **no position**. |
| **Asset** | Named thing with a type (`scene`, `camera`, `sprite`, `behavior`, `collider`, `container`) and typed properties. Assets have **no position**. |
| **Scene object** | An asset placed into a scene: a link to the asset + a **position** (`x`, `y`, `z`) + sparse **overrides** of the asset's properties + an optional **parent** (another object of the scene). Scenes cannot be placed. |
| **Container** | A reusable group ("prefab"): the asset holds a **template** of objects (asset link, local position, overrides, parent links) centred on its origin. Placing a container creates a container object plus fresh copies of the template below it – ordinary objects, each remembering its template id. **Create container** turns the current selection into such an asset and replaces the selected objects by one placed container at their centre (world positions unchanged). Editing the template itself comes later. |
| **Selection** | One or more scene objects. Click selects, `Ctrl`-click adds/removes, dragging on empty scene space draws a rubber band (objects whose position lies inside are selected). Dragging a selected object moves the whole selection; `Delete` removes it; the inspector offers **Create container** and **Remove from scene** for several objects. |
| **Behavior** | Logic as an asset: a **node graph** plus the **variables** it declares. Placed like anything else (a diamond symbol in the scene); its parent is the **owner** the graph acts on. |
| **Collider** | A shape (box or circle) for collision detection, placed as a **child** of the object it belongs to (its *body*). Drawn as a green outline in edit mode only. |
| **Override** | A per-object value that replaces the asset's value for one property. Only changed keys are stored; the inspector marks them bold and offers a reset (↺). |
@@ -188,12 +190,13 @@ There is no authentication yet; `data/` is world-readable. Both are phase-2 topi
## Keyboard
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+Enter` play ·
`Esc` stop · `Tab` cycles fields · number fields: drag to scrub (`Shift` ×10, `Alt` ×0.1),
`↑`/`↓` step · viewport: wheel zooms, middle-drag or `Shift`+drag pans.
`Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` undo/redo · `Delete` remove selection · `Ctrl+G` create
container from the selection · `Ctrl+Enter` play · `Esc` stop · `Tab` cycles fields · number
fields: drag to scrub (`Shift` ×10, `Alt` ×0.1), `↑`/`↓` step · viewport: wheel zooms,
middle-drag or `Shift`+drag pans, drag on empty space rubber-bands, `Ctrl`-click toggles an
object in the selection (hierarchy rows too).
## Not yet
Textures on sprites, solid collisions (blocking movement, "move and slide"), spawning from
graphs, multi-select, reordering the hierarchy, auth, editing while playing, touch-optimised
layout, graph comments/groups.
Textures on sprites, editing a container's template (changes reaching all instances),
reordering the hierarchy, auth, touch-optimised layout, graph comments/groups.
+28
View File
@@ -0,0 +1,28 @@
import type { Project } from '../model/Project';
import type { Command } from './Command';
/**
* Several commands as one undo step (moving a multi-selection, for instance). Executes in
* order; if one of them throws, the ones already done are rolled back before re-throwing,
* so the project is never left half-changed.
*/
export class CompoundCommand implements Command {
constructor(readonly label: string, private readonly commands: readonly Command[]) {}
execute(project: Project): void {
const done: Command[] = [];
try {
for (const command of this.commands) {
command.execute(project);
done.push(command);
}
} catch (error) {
for (const command of done.reverse()) command.undo(project);
throw error;
}
}
undo(project: Project): void {
for (let i = this.commands.length - 1; i >= 0; i--) this.commands[i]!.undo(project);
}
}
+18 -9
View File
@@ -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 = '';
+103
View File
@@ -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<string>();
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);
}
}
+37 -12
View File
@@ -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<string>();
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';
+13 -1
View File
@@ -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';
+2 -2
View File
@@ -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';
}
+7
View File
@@ -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: '',
};
}
+28
View File
@@ -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;
}
+44 -21
View File
@@ -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<string>();
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<string>();
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<string>([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;
+2
View File
@@ -47,6 +47,8 @@ export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// 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. */
+2 -1
View File
@@ -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<string, PropertySchema>): 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.
+9 -2
View File
@@ -1,7 +1,6 @@
import { type Application, Container, Text } from 'pixi.js';
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
import { RemoveNodeCommand } from '../core/commands/graphCommands';
import { RemoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
import { listScenes } from '../core/model/Project';
import { HttpProjectStore } from '../core/store/HttpProjectStore';
import type { ProjectStore } from '../core/store/ProjectStore';
@@ -11,6 +10,7 @@ import { Input } from '../runtime/systems/Input';
import { World } from '../runtime/World';
import { Button, Dialog, HStack, Menu, type MenuItem, Splitter, Theme, ui, VStack, type Widget } from '../ui';
import { Viewport } from '../viewport/Viewport';
import { createContainer, removeObjects } from './actions';
import { attachAutosave } from './autosave';
import { GraphEditor } from './graph/GraphEditor';
import { AssetPanel } from './panels/AssetPanel';
@@ -67,6 +67,8 @@ function installDebugHook(app: Application, state: EditorState, editor: Editor):
gameInfo: () => gameView.debugInfo(),
layerOf: (id: string) => viewport.layerOf(id),
drawOrder: () => viewport.drawOrder(),
/** Screen position of an object in the Scene tab (its world position through the view). */
objectScreen: (id: string) => viewport.objectScreen(id),
graph: {
current: () => graphEditor.currentAssetId,
portAt: (nodeId: string, port: string, isOutput: boolean) => graphEditor.portGlobal(nodeId, port, isOutput),
@@ -367,9 +369,14 @@ class Editor {
this.state.redo();
return true;
}
if (mod && event.key.toLowerCase() === 'g') {
const s = this.state.selection;
if (s?.kind === 'object') createContainer(this.state, s.sceneId, s.objectIds);
return true;
}
if (event.key === 'Delete' || event.key === 'Backspace') {
const s = this.state.selection;
if (s?.kind === 'object') this.state.execute(new RemoveSceneObjectCommand(s.sceneId, s.objectId));
if (s?.kind === 'object') removeObjects(this.state, s.sceneId, s.objectIds);
else if (s?.kind === 'node') this.state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
else if (s?.kind === 'asset') {
const asset = this.state.project.assets.find((a) => a.id === s.assetId);
+21
View File
@@ -0,0 +1,21 @@
import { CreateContainerCommand } from '../core/commands/containerCommands';
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
import { type EditorState, objectSelection } from './state';
/**
* Editor actions that several places trigger the same way (inspector, hierarchy context
* menu, keyboard): they run the command and put the selection where the user expects it.
*/
/** Groups the objects into a new container asset placed in their stead; selects the container. */
export function createContainer(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
const command = new CreateContainerCommand(sceneId, objectIds);
if (!state.execute(command)) return false;
state.select(objectSelection(sceneId, [command.objectId]));
return true;
}
/** Removes the objects (with their subtrees) as one undo step. */
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
}
+6 -4
View File
@@ -5,9 +5,9 @@ import { ASSET_TYPES, type AssetType } from '../../core/model/AssetTypes';
import type { Vec2 } from '../../core/model/Properties';
import { findAsset, findUsages } from '../../core/model/Project';
import { Button, Dialog, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { type EditorState, objectSelection } from '../state';
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider' };
const TYPE_LABELS: Record<AssetType, string> = { scene: 'Scene', camera: 'Camera', sprite: 'Sprite', behavior: 'Behavior', collider: 'Collider', container: 'Container' };
/** Lists all assets of the project; creates, places and deletes them. */
export class AssetPanel extends Panel {
@@ -77,7 +77,7 @@ export class AssetPanel extends Panel {
return;
}
const command = new PlaceAssetCommand(scene.id, assetId, this.placementPoint());
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(scene.id, [command.objectId]));
}
private openContextMenu(id: string, x: number, y: number): void {
@@ -100,7 +100,9 @@ export class AssetPanel extends Panel {
const detail =
asset.type === 'scene'
? `The scene and its ${asset.objects.length} object(s) are removed.`
: usages > 0
: asset.type === 'container' && usages > 0
? `${usages} placed container(s) are removed from scenes together with their content.`
: usages > 0
? `${usages} placed instance(s) are removed from scenes as well.`
: 'This cannot be undone with Ctrl+Z after reload.';
new Dialog(`Delete "${asset.name}"?`, detail, [
+19 -10
View File
@@ -1,15 +1,17 @@
import { AddColliderCommand } from '../../core/commands/colliderCommands';
import { RemoveSceneObjectCommand, SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { SetParentCommand } from '../../core/commands/sceneObjectCommands';
import { treeOrder } from '../../core/model/hierarchy';
import { findAsset } from '../../core/model/Project';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import { ListView, Menu, Panel, ScrollContainer } from '../../ui';
import type { EditorState } from '../state';
import { createContainer, removeObjects } from '../actions';
import { type EditorState, objectSelection } from '../state';
/**
* Objects placed in the active scene as a tree (children indented below their parent).
* Drag a row onto another to parent it there; drop it on the empty space below the rows to
* move it to the top level. The object keeps its world position either way.
* Click selects, Ctrl/Shift-click adds to or removes from the selection. Drag a row onto
* another to parent it there; drop it on the empty space below the rows to move it to the
* top level. The object keeps its world position either way.
*/
export class HierarchyPanel extends Panel {
private readonly list = new ListView();
@@ -19,9 +21,11 @@ export class HierarchyPanel extends Panel {
super({ title: 'Scene objects', padding: 0, gap: 0 });
this.minWidth = 160;
this.list.emptyText = 'Place assets here';
this.list.onSelect = (id) => {
this.list.onSelect = (id, modifiers) => {
const scene = state.activeScene;
state.select(id && scene ? { kind: 'object', sceneId: scene.id, objectId: id } : null);
if (!scene) return;
if (id && modifiers && (modifiers.ctrl || modifiers.shift)) state.toggleObject(scene.id, id);
else state.select(id ? objectSelection(scene.id, [id]) : null);
};
this.list.onDrop = (draggedId, targetId) => {
const scene = state.activeScene;
@@ -35,18 +39,22 @@ export class HierarchyPanel extends Panel {
if (!scene) return;
const object = scene.objects.find((o) => o.id === id);
const type = object && findAsset(state.project, object.assetId)?.type;
// Inside a multi-selection the menu acts on all selected objects.
const selected = state.selectedObjectIds.includes(id) ? state.selectedObjectIds : [id];
const many = selected.length > 1;
new Menu([
{ label: 'Select asset', onSelect: () => object && state.select({ kind: 'asset', assetId: object.assetId }) },
{
label: 'Add collider',
disabled: !type || type === 'collider',
disabled: !type || type === 'collider' || many,
onSelect: () => {
const command = new AddColliderCommand(scene.id, id);
if (state.execute(command)) state.select({ kind: 'object', sceneId: scene.id, objectId: command.objectId });
if (state.execute(command)) state.select(objectSelection(scene.id, [command.objectId]));
},
},
{ label: 'Unparent (move to top level)', disabled: !object?.parentId, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: 'Remove from scene', danger: true, onSelect: () => state.execute(new RemoveSceneObjectCommand(scene.id, id)) },
{ label: many ? `Create container from ${selected.length} objects` : 'Create container', onSelect: () => createContainer(state, scene.id, selected) },
{ label: 'Unparent (move to top level)', disabled: !object?.parentId || many, onSelect: () => state.execute(new SetParentCommand(scene.id, id, '')) },
{ label: many ? `Remove ${selected.length} objects from scene` : 'Remove from scene', danger: true, onSelect: () => removeObjects(state, scene.id, selected) },
]).open(x, y);
};
this.scroll.setContent(this.list);
@@ -87,5 +95,6 @@ export class HierarchyPanel extends Panel {
private syncSelection(): void {
const s = this.state.selection;
this.list.select(s?.kind === 'object' ? s.objectId : null, true);
this.list.highlight(s?.kind === 'object' ? s.objectIds : []);
}
}
+35 -4
View File
@@ -20,8 +20,9 @@ import type { SceneObject } from '../../core/model/SceneObject';
import { type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
import { isModified, isOverridden, resolveAssetProps, resolveSceneObjectProps } from '../../core/properties/resolve';
import { Button, HStack, Label, Menu, type MenuItem, Panel, ScrollContainer, TextField, Theme, ui, VStack } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { createField, createFieldRow, FIELD_LABEL_WIDTH, type FieldRow } from '../inspector/fieldFactory';
import type { EditorState, Selection } from '../state';
import { type EditorState, objectSelection, type Selection } from '../state';
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
@@ -80,7 +81,8 @@ export class Inspector extends Panel {
} else if (s.kind === 'asset') {
this.buildAsset(s.assetId);
} else if (s.kind === 'object') {
this.buildObject(s.sceneId, s.objectId);
if (s.objectIds.length > 1) this.buildObjects(s.sceneId, s.objectIds);
else this.buildObject(s.sceneId, s.objectId);
} else {
this.buildNode(s.assetId, s.nodeId);
}
@@ -96,6 +98,7 @@ export class Inspector extends Panel {
return asset ? schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
}
if (s.kind === 'object') {
if (s.objectIds.length > 1) return `multi|${s.objectIds.join(',')}`;
const object = this.currentObject(s.sceneId, s.objectId);
const asset = object && findAsset(this.state.project, object.assetId);
return asset ? `${object.parentId}|` + schemaForAsset(asset).map((d) => `${d.key}:${d.kind}`).join(',') : 'missing';
@@ -204,6 +207,27 @@ export class Inspector extends Panel {
// --- scene objects -------------------------------------------------------------------
/** Several objects: what they are, and the actions that apply to all of them at once. */
private buildObjects(sceneId: string, objectIds: string[]): void {
const scene = findScene(this.state.project, sceneId);
if (!scene) return;
this.body.add(new Label(`${objectIds.length} OBJECTS SELECTED`, { style: 'title' }));
for (const id of objectIds) {
const object = findSceneObject(scene, id);
const asset = object && findAsset(this.state.project, object.assetId);
if (object && asset) this.body.add(new Label(`${object.name || asset.name} · ${asset.type}`, { style: 'muted' }));
}
this.body.add(new Label(''));
this.body.add(new Label('Ctrl-click adds or removes objects.', { style: 'small' }));
this.body.add(new Label('Dragging one in the scene moves all of them.', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Create container', { onClick: () => createContainer(this.state, sceneId, objectIds) }));
this.body.add(new Label('Groups the selection into a reusable container', { style: 'small' }));
this.body.add(new Label('asset placed at its centre (Ctrl+G).', { style: 'small' }));
this.body.add(new Label(''));
this.body.add(new Button('Remove from scene', { variant: 'danger', onClick: () => removeObjects(this.state, sceneId, objectIds) }));
}
private buildObject(sceneId: string, objectId: string): void {
const scene = findScene(this.state.project, sceneId);
const object = scene && findSceneObject(scene, objectId);
@@ -293,6 +317,13 @@ export class Inspector extends Panel {
this.body.add(row.row);
}
if (asset.type === 'container') {
const count = asset.objects.length;
this.body.add(new Label('CONTENT', { style: 'title' }));
this.body.add(new Label(count === 0 ? 'The container is empty.' : `Instance with ${count} template object(s).`, { style: 'muted' }));
if (count > 0) this.body.add(new Label('Its content is listed below it in the hierarchy.', { style: 'small' }));
}
// Sprites (and other bodies) get their collider with one click: a child sized like the object.
if (asset.type !== 'collider') {
this.body.add(new Label(''));
@@ -301,7 +332,7 @@ export class Inspector extends Panel {
variant: 'ghost',
onClick: () => {
const command = new AddColliderCommand(sceneId, objectId);
if (this.state.execute(command)) this.state.select({ kind: 'object', sceneId, objectId: command.objectId });
if (this.state.execute(command)) this.state.select(objectSelection(sceneId, [command.objectId]));
},
}),
);
@@ -483,7 +514,7 @@ function sameTarget(a: Selection, b: Selection): boolean {
if (!a || !b) return a === b;
if (a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.join(',') === b.objectIds.join(',');
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+34 -4
View File
@@ -6,11 +6,23 @@ import { deserializeProject, serializeProject } from '../core/store/serialize';
export type Selection =
| { kind: 'asset'; assetId: string }
| { kind: 'object'; sceneId: string; objectId: string }
/**
* One or more objects of a scene. `objectId` is the primary one (the last picked – the
* inspector shows it); `objectIds` holds the whole selection, primary included.
*/
| { kind: 'object'; sceneId: string; objectId: string; objectIds: string[] }
/** A node inside a behavior's graph. */
| { kind: 'node'; assetId: string; nodeId: string }
| null;
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(sceneId: string, objectIds: readonly string[], primary?: string): Selection {
const ids = [...new Set(objectIds)];
if (ids.length === 0) return null;
const objectId = primary && ids.includes(primary) ? primary : ids[ids.length - 1]!;
return { kind: 'object', sceneId, objectId, objectIds: ids };
}
export type EditorMode = 'edit' | 'play';
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
@@ -169,19 +181,35 @@ export class EditorState {
// --- selection -----------------------------------------------------------------------
select(selection: Selection): void {
// Tolerate the short form `{ kind: 'object', sceneId, objectId }` (tests, older callers).
if (selection?.kind === 'object' && !selection.objectIds) selection = objectSelection(selection.sceneId, [selection.objectId]);
if (sameSelection(this.selection, selection)) return;
this.selection = selection;
this.emit('selection');
}
/** Drops a selection whose target no longer exists (after delete/undo). */
/** Ctrl/Shift-click: adds the object to the selection or removes it again. */
toggleObject(sceneId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.sceneId === sceneId ? s.objectIds : [];
const next = current.includes(objectId) ? current.filter((id) => id !== objectId) : [...current, objectId];
this.select(objectSelection(sceneId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ids of the selected scene objects (empty unless objects are selected). */
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
if (!s) return;
if (s.kind === 'asset' && !this.project.assets.some((a) => a.id === s.assetId)) this.select(null);
if (s.kind === 'object') {
const scene = findScene(this.project, s.sceneId);
if (!scene || !scene.objects.some((o) => o.id === s.objectId)) this.select(null);
const alive = scene ? s.objectIds.filter((id) => scene.objects.some((o) => o.id === id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.sceneId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = this.project.assets.find((a) => a.id === s.assetId);
@@ -225,7 +253,9 @@ function sameSelection(a: Selection, b: Selection): boolean {
if (a === b) return true;
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') return a.sceneId === b.sceneId && a.objectId === b.objectId;
if (a.kind === 'object' && b.kind === 'object') {
return a.sceneId === b.sceneId && a.objectId === b.objectId && a.objectIds.length === b.objectIds.length && a.objectIds.every((id, i) => id === b.objectIds[i]);
}
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
return false;
}
+9 -1
View File
@@ -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;
}
+4
View File
@@ -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,
+1 -1
View File
@@ -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';
+32 -7
View File
@@ -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<string>();
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<string>): 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();
+132 -28
View File
@@ -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<typeof o> => 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 };
}
}
+195
View File
@@ -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()
});
});
+1
View File
@@ -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 });
+105
View File
@@ -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) {