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:
1 parent
91ee2feadd
commit
0b78382bcc
26 files changed
+897
-114
No files matched your search
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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: '',
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 : []);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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';
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user