Fix findUsages/undo/node-lookup bugs, split Viewport and Editor, share command and inspector helpers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1eb28314cf
commit
f140c2fd77
58 files changed
+1969
-1171
No files matched your search
@@ -188,16 +188,19 @@ selected and switched back on.
|
||||
|
||||
```
|
||||
web/index.html Vite source page (dev) index.html built page (generated, served)
|
||||
src/core/model/ Project, Asset, SceneObject, hierarchy (parent chain, world positions), container templates
|
||||
src/core/model/ Project (object holders), Asset, SceneObject, hierarchy, walk (generic parent/child walks), container templates
|
||||
src/core/properties/ PropertySchema, resolve (the only override merge), collider/camera/text geometry, enabled
|
||||
src/core/commands/ undoable commands, CommandStack, require.ts (validated lookups), naming.ts
|
||||
src/core/commands/ undoable commands, CommandStack, SetBagValueCommand base, require.ts (validated lookups), naming.ts
|
||||
src/core/graph/ graph model, NodeRegistry/NodeContext/values, interpreter; nodes/<category>/<Type>.ts
|
||||
src/core/store/ serialize (untrusted JSON → Project), HttpProjectStore
|
||||
src/runtime/ World, Entity, System interface, Input tracker, CollisionSystem, BehaviorSystem
|
||||
src/ui/ widget toolkit (generic, knows nothing about assets)
|
||||
src/viewport/ Viewport (Scene and Game tab), items (what to draw), symbols (how to draw it)
|
||||
src/editor/ App → Editor composition, state, actions, shortcuts, debugHook, autosave
|
||||
src/editor/inspector/ Inspector panel, field factory, one section per selection kind (asset/object/node)
|
||||
src/viewport/ WorldViewport base + SceneViewport/GameViewport, view (transforms),
|
||||
items (what to draw), symbols (how to draw it)
|
||||
src/editor/ App → Editor (layout + ticker), PlaySession (the run), ProjectSession (load/create),
|
||||
state, actions, shortcuts, debugHook, autosave
|
||||
src/editor/inspector/ Inspector panel, field factory, previewHandlers (scrub → preview → command),
|
||||
one section per selection kind (asset/object/node)
|
||||
src/editor/panels/ AssetPanel, HierarchyPanel, Toolbar
|
||||
src/editor/graph/ GraphEditor (canvas), NodeView, NodePalette
|
||||
api/ PHP backend data/projects/ persisted projects
|
||||
|
||||
@@ -4,6 +4,11 @@ import type { Project } from '../model/Project';
|
||||
* An undoable edit of the project. Commands mutate the project in place and remember
|
||||
* whatever they need to revert. They validate first: `execute` throws a `CommandError` on
|
||||
* an invalid operation (e.g. placing a scene into a scene) before it changes anything.
|
||||
*
|
||||
* The same holds for `undo`: when its target is gone it throws a `CommandError` rather than
|
||||
* reverting half of the edit. The command then stays on the undo stack (see `CommandStack`)
|
||||
* and the editor shows the message. Validation belongs in `execute`/`undo`, never in the
|
||||
* constructor – commands are built outside the editor's error handling.
|
||||
*/
|
||||
export interface Command {
|
||||
readonly label: string;
|
||||
|
||||
@@ -15,20 +15,26 @@ export class CommandStack {
|
||||
this.redoStack.length = 0;
|
||||
}
|
||||
|
||||
/** Reverts the last command; false when there is none. */
|
||||
/**
|
||||
* Reverts the last command; false when there is none. The command moves between the stacks
|
||||
* only after it succeeded – a throwing `undo` leaves the history exactly as it was instead
|
||||
* of dropping the command from both stacks.
|
||||
*/
|
||||
undo(): boolean {
|
||||
const command = this.undoStack.pop();
|
||||
const command = this.undoStack[this.undoStack.length - 1];
|
||||
if (!command) return false;
|
||||
command.undo(this.project);
|
||||
this.undoStack.pop();
|
||||
this.redoStack.push(command);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Re-applies the last undone command; false when there is none. */
|
||||
/** Re-applies the last undone command; false when there is none. Moves it only on success. */
|
||||
redo(): boolean {
|
||||
const command = this.redoStack.pop();
|
||||
const command = this.redoStack[this.redoStack.length - 1];
|
||||
if (!command) return false;
|
||||
command.execute(this.project);
|
||||
this.redoStack.pop();
|
||||
this.undoStack.push(command);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { Command } from './Command';
|
||||
|
||||
/**
|
||||
* The skeleton every "write one key into a property bag" edit shares: remember what the bag
|
||||
* held, write the new value, put the old one back on undo. A key that is not in the bag is
|
||||
* `undefined` on both ends – as a `computeNext` result it removes the key (this is how
|
||||
* clearing an override works), and as remembered value it makes undo remove it again.
|
||||
*
|
||||
* Subclasses look their target up on every call instead of holding on to it, so an undo whose
|
||||
* target is gone fails with a `CommandError` instead of writing into an orphaned object.
|
||||
*/
|
||||
export abstract class SetBagValueCommand<T> implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
protected constructor(protected readonly key: string, label: string) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/** Throws a `CommandError` when the target does not exist. */
|
||||
protected abstract resolveTarget(project: Project): T;
|
||||
|
||||
protected abstract bagOf(target: T): PropertyBag;
|
||||
|
||||
/**
|
||||
* The value to write, `undefined` to remove the key. Called before anything is mutated, so
|
||||
* this is where a subclass rejects an invalid edit (an unknown property key, say).
|
||||
*/
|
||||
protected abstract computeNext(target: T, project: Project): PropertyValue | undefined;
|
||||
|
||||
execute(project: Project): void {
|
||||
const target = this.resolveTarget(project);
|
||||
const bag = this.bagOf(target);
|
||||
const next = this.computeNext(target, project);
|
||||
this.previous = bag[this.key];
|
||||
setBagValue(bag, this.key, next);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(this.bagOf(this.resolveTarget(project)), this.key, this.previous);
|
||||
}
|
||||
}
|
||||
@@ -22,30 +22,55 @@ function requireLink(project: Project, sceneId: string, object: SceneObject): Te
|
||||
return link;
|
||||
}
|
||||
|
||||
/** Remembered override values of several objects, for undo. */
|
||||
interface OverrideMemo {
|
||||
/**
|
||||
* One value that is applied on its own: an override key, or a single axis of the position.
|
||||
* Applying works the same for both – the slot is the only thing that differs.
|
||||
*/
|
||||
interface Slot<T> {
|
||||
read(object: SceneObject): T;
|
||||
write(object: SceneObject, value: T): void;
|
||||
}
|
||||
|
||||
const overrideSlot = (key: string): Slot<PropertyValue | undefined> => ({
|
||||
read: (object) => object.overrides[key],
|
||||
write: (object, value) => setBagValue(object.overrides, key, value),
|
||||
});
|
||||
|
||||
const positionSlot = (axis: Axis): Slot<number> => ({
|
||||
read: (object) => object.position[axis],
|
||||
write: (object, value) => {
|
||||
object.position[axis] = value;
|
||||
},
|
||||
});
|
||||
|
||||
/** A slot's value on one object before it was overwritten. */
|
||||
interface SlotMemo<T> {
|
||||
object: SceneObject;
|
||||
previous: PropertyValue | undefined;
|
||||
slot: Slot<T>;
|
||||
previous: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the template's override for `key` and moves every instance that still had the old
|
||||
* template value along with it. Returns what to restore on undo.
|
||||
* Writes `values` into the template's slots and carries every instance that still agreed
|
||||
* with the template along – copies the user changed on their own keep their value. Returns
|
||||
* the memos in application order; `restoreSlots` undoes them.
|
||||
*/
|
||||
function updateTemplateOverride(project: Project, link: TemplateLink, key: string, next: PropertyValue | undefined): OverrideMemo[] {
|
||||
const previous = link.template.overrides[key];
|
||||
const memos: OverrideMemo[] = [{ object: link.template, previous }];
|
||||
function applyToTemplate<T>(project: Project, link: TemplateLink, slots: readonly Slot<T>[], values: readonly T[]): SlotMemo<T>[] {
|
||||
const before = slots.map((slot) => slot.read(link.template));
|
||||
const memos: SlotMemo<T>[] = slots.map((slot, i) => ({ object: link.template, slot, previous: before[i]! }));
|
||||
for (const { object } of instancesOf(project, link.template.id)) {
|
||||
if (object.overrides[key] !== previous) continue;
|
||||
memos.push({ object, previous: object.overrides[key] });
|
||||
setBagValue(object.overrides, key, next);
|
||||
slots.forEach((slot, i) => {
|
||||
if (slot.read(object) !== before[i]) return;
|
||||
memos.push({ object, slot, previous: slot.read(object) });
|
||||
slot.write(object, values[i]!);
|
||||
});
|
||||
}
|
||||
setBagValue(link.template.overrides, key, next);
|
||||
slots.forEach((slot, i) => slot.write(link.template, values[i]!));
|
||||
return memos;
|
||||
}
|
||||
|
||||
function restoreOverrides(memos: OverrideMemo[], key: string): void {
|
||||
for (const memo of [...memos].reverse()) setBagValue(memo.object.overrides, key, memo.previous);
|
||||
function restoreSlots<T>(memos: readonly SlotMemo<T>[]): void {
|
||||
for (const memo of [...memos].reverse()) memo.slot.write(memo.object, memo.previous);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,7 +82,7 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
readonly label: string;
|
||||
private previousAssetValue: PropertyValue | undefined;
|
||||
private previousOverride: PropertyValue | undefined;
|
||||
private templateMemos: OverrideMemo[] = [];
|
||||
private templateMemos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Apply ${key} to asset`;
|
||||
@@ -74,13 +99,13 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
asset.props[this.key] = value!;
|
||||
delete object.overrides[this.key];
|
||||
const link = scene && templateLinkOf(project, scene, object);
|
||||
this.templateMemos = link ? updateTemplateOverride(project, link, this.key, undefined) : [];
|
||||
this.templateMemos = link ? applyToTemplate(project, link, [overrideSlot(this.key)], [undefined]) : [];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const asset = requireAsset(project, object.assetId);
|
||||
restoreOverrides(this.templateMemos, this.key);
|
||||
restoreSlots(this.templateMemos);
|
||||
setBagValue(object.overrides, this.key, this.previousOverride);
|
||||
setBagValue(asset.props, this.key, this.previousAssetValue);
|
||||
}
|
||||
@@ -89,7 +114,7 @@ export class ApplyOverrideToAssetCommand implements Command {
|
||||
/** Writes the object's override for `key` into its container template (instances at the old value follow). */
|
||||
export class ApplyOverrideToContainerCommand implements Command {
|
||||
readonly label: string;
|
||||
private memos: OverrideMemo[] = [];
|
||||
private memos: SlotMemo<PropertyValue | undefined>[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Apply ${key} to container`;
|
||||
@@ -98,11 +123,11 @@ export class ApplyOverrideToContainerCommand implements Command {
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
this.memos = updateTemplateOverride(project, link, this.key, object.overrides[this.key]);
|
||||
this.memos = applyToTemplate(project, link, [overrideSlot(this.key)], [object.overrides[this.key]]);
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
restoreOverrides(this.memos, this.key);
|
||||
restoreSlots(this.memos);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,19 +152,13 @@ export class RevertOverrideToTemplateCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remembered positions of several objects, for undo. */
|
||||
interface PositionMemo {
|
||||
object: SceneObject;
|
||||
previous: Position;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the object's local position (one axis, or all) into its container template.
|
||||
* Instances whose axis still matched the template follow; the others keep their own value.
|
||||
*/
|
||||
export class ApplyPositionToContainerCommand implements Command {
|
||||
readonly label: string;
|
||||
private memos: PositionMemo[] = [];
|
||||
private memos: SlotMemo<number>[] = [];
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly axis?: Axis) {
|
||||
this.label = axis ? `Apply ${axis} to container` : 'Apply position to container';
|
||||
@@ -149,19 +168,11 @@ export class ApplyPositionToContainerCommand implements Command {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const link = requireLink(project, this.sceneId, object);
|
||||
const axes = this.axis ? [this.axis] : ALL_AXES;
|
||||
const before = { ...link.template.position };
|
||||
this.memos = [{ object: link.template, previous: before }];
|
||||
for (const instance of instancesOf(project, link.template.id)) {
|
||||
const changed = axes.filter((a) => instance.object.position[a] === before[a]);
|
||||
if (changed.length === 0) continue;
|
||||
this.memos.push({ object: instance.object, previous: { ...instance.object.position } });
|
||||
for (const a of changed) instance.object.position[a] = object.position[a];
|
||||
}
|
||||
for (const a of axes) link.template.position[a] = object.position[a];
|
||||
this.memos = applyToTemplate(project, link, axes.map(positionSlot), axes.map((a) => object.position[a]));
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
for (const memo of [...this.memos].reverse()) memo.object.position = { ...memo.previous };
|
||||
restoreSlots(this.memos);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { createEmptyGraph } from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, ContainerAsset, SceneAsset } from '../model/Asset';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { removeSubtrees } from '../model/hierarchy';
|
||||
import { type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { objectHolders, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue, defaultsFor } from '../properties/PropertySchema';
|
||||
import { type Removed, restoreAt } from '../util/list';
|
||||
import { type Removed, removeById, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requirePropDef } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
/** A new asset of the given type with default properties (and empty content for scenes, behaviors, containers). */
|
||||
export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
@@ -50,7 +51,7 @@ export class CreateAssetCommand implements Command {
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = project.assets.filter((a) => a.id !== this.asset.id);
|
||||
project.assets = removeById(project.assets, this.asset.id);
|
||||
project.activeSceneId = this.previousActiveScene;
|
||||
}
|
||||
}
|
||||
@@ -70,17 +71,16 @@ export class DeleteAssetCommand implements Command {
|
||||
constructor(private readonly assetId: string) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const index = project.assets.findIndex((a) => a.id === this.assetId);
|
||||
const asset = project.assets[index];
|
||||
if (!asset) throw new CommandError(`Asset ${this.assetId} not found`);
|
||||
requireAsset(project, this.assetId);
|
||||
this.removedObjects = [];
|
||||
// The cascade runs while the asset is still in the list: a container asset is an object
|
||||
// holder itself, so removing it first would change what `objectHolders` reports.
|
||||
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({ holderId: holder.id, removed: removeSubtrees(holder, roots) });
|
||||
}
|
||||
this.removed = { item: asset, index };
|
||||
project.assets.splice(index, 1);
|
||||
this.removed = removeFirst(project.assets, (a) => a.id === this.assetId);
|
||||
this.previousActiveScene = project.activeSceneId;
|
||||
if (project.activeSceneId === this.assetId) {
|
||||
const next = project.assets.find((a) => a.type === 'scene');
|
||||
@@ -90,20 +90,16 @@ export class DeleteAssetCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
project.assets.splice(this.removed.index, 0, this.removed.item);
|
||||
restoreAt(project.assets, [this.removed]);
|
||||
const holders = new Map(objectHolders(project).map((h) => [h.id, h]));
|
||||
for (const entry of this.removedObjects) {
|
||||
const holder = objectHolders(project).find((h) => h.id === entry.holderId);
|
||||
const holder = holders.get(entry.holderId);
|
||||
if (holder) restoreAt(holder.objects, 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 = '';
|
||||
@@ -121,45 +117,40 @@ export class RenameAssetCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** The half of `SetBagValueCommand` both asset property edits share: the bag is the asset's own. */
|
||||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||||
protected constructor(private readonly assetId: string, key: string, label: string) {
|
||||
super(key, label);
|
||||
}
|
||||
|
||||
protected override resolveTarget(project: Project): Asset {
|
||||
return requireAsset(project, this.assetId);
|
||||
}
|
||||
|
||||
protected override bagOf(asset: Asset): PropertyBag {
|
||||
return asset.props;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets an asset property (coerced against its schema entry). */
|
||||
export class SetAssetPropertyCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly assetId: string, private readonly key: string, private readonly value: PropertyValue) {
|
||||
this.label = `Set ${key}`;
|
||||
export class SetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
constructor(assetId: string, key: string, private readonly value: PropertyValue) {
|
||||
super(assetId, key, `Set ${key}`);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
const def = requirePropDef(asset, this.key);
|
||||
this.previous = asset.props[this.key];
|
||||
asset.props[this.key] = coerceValue(def, this.value);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous);
|
||||
protected override computeNext(asset: Asset): PropertyValue {
|
||||
return coerceValue(requirePropDef(asset, this.key), this.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts an asset property back to its schema default (the asset-level "clear override"). */
|
||||
export class ResetAssetPropertyCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly assetId: string, private readonly key: string) {
|
||||
this.label = `Reset ${key}`;
|
||||
export class ResetAssetPropertyCommand extends AssetPropertyCommand {
|
||||
constructor(assetId: string, key: string) {
|
||||
super(assetId, key, `Reset ${key}`);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireAsset(project, this.assetId);
|
||||
const def = requirePropDef(asset, this.key);
|
||||
this.previous = asset.props[this.key];
|
||||
asset.props[this.key] = def.default;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireAsset(project, this.assetId).props, this.key, this.previous);
|
||||
protected override computeNext(asset: Asset): PropertyValue {
|
||||
return requirePropDef(asset, this.key).default;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { newId } from '../ids';
|
||||
import type { Project } from '../model/Project';
|
||||
import { createSceneObject } from '../model/SceneObject';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
@@ -37,7 +38,7 @@ export class AddColliderCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
const scene = requireScene(project, this.sceneId);
|
||||
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
|
||||
project.assets = project.assets.filter((a) => a.id !== this.assetId);
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { findObject, localPositionFor, subtreeIds, subtreeRoots, treeOrder, worl
|
||||
import type { Position } from '../model/Properties';
|
||||
import { findScene, type Project } from '../model/Project';
|
||||
import { createSceneObject } from '../model/SceneObject';
|
||||
import { removeById } from '../util/list';
|
||||
import { buildAsset } from './assetCommands';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { nextAssetName } from './naming';
|
||||
@@ -90,8 +91,8 @@ export class CreateContainerCommand implements Command {
|
||||
object.position = { ...entry.position };
|
||||
object.templateId = entry.templateId;
|
||||
}
|
||||
scene.objects = scene.objects.filter((o) => o.id !== this.objectId);
|
||||
scene.objects = removeById(scene.objects, this.objectId);
|
||||
}
|
||||
project.assets = project.assets.filter((a) => a.id !== this.assetId);
|
||||
project.assets = removeById(project.assets, this.assetId);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { edgesInto, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
|
||||
import { requireNodeDef } from '../graph/NodeRegistry';
|
||||
import { edgesInto, edgesOfNode, findNode, type GraphEdge, type GraphNode, portsCompatible } from '../graph/Graph';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { newId } from '../ids';
|
||||
import { type PropertyBag, type PropertyValue, setBagValue } from '../model/Properties';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { coerceValue, defaultsOf } from '../properties/PropertySchema';
|
||||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Removed, removeById, removeFirst, removeWhere, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior, requireNode } from './require';
|
||||
import { requireBehavior, requireNode, requireNodeDef } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
/** Adds a node of the given type at graph coordinates. Params start at their defaults. */
|
||||
export class AddNodeCommand implements Command {
|
||||
@@ -14,18 +15,21 @@ export class AddNodeCommand implements Command {
|
||||
readonly node: GraphNode;
|
||||
|
||||
constructor(private readonly assetId: string, type: string, x: number, y: number, params: PropertyBag = {}) {
|
||||
const def = requireNodeDef(type);
|
||||
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def.params), ...params } };
|
||||
this.label = `Add ${def.label}`;
|
||||
// An unknown type is rejected in `execute`, not here: constructors run outside the
|
||||
// editor's error handling, so throwing here would crash instead of showing a notice.
|
||||
const def = nodeDef(type);
|
||||
this.node = { id: newId(), type, x: Math.round(x), y: Math.round(y), params: { ...defaultsOf(def?.params ?? []), ...params } };
|
||||
this.label = def ? `Add ${def.label}` : `Add ${type}`;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
requireNodeDef(this.node.type);
|
||||
requireBehavior(project, this.assetId).graph.nodes.push(this.node);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== this.node.id);
|
||||
graph.nodes = removeById(graph.nodes, this.node.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +42,11 @@ export class RemoveNodeCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
const index = graph.nodes.findIndex((n) => n.id === this.nodeId);
|
||||
const node = graph.nodes[index];
|
||||
const node = removeFirst(graph.nodes, (n) => n.id === this.nodeId);
|
||||
if (!node) throw new CommandError(`Node ${this.nodeId} not found`);
|
||||
const edges = removeWhere(graph.edges, (e) => e.from.node === this.nodeId || e.to.node === this.nodeId);
|
||||
graph.nodes.splice(index, 1);
|
||||
this.removed = { node: { item: node, index }, edges };
|
||||
// `edgesOfNode` reads the edge list only, so it still finds them after the node is gone.
|
||||
const touching = edgesOfNode(graph, this.nodeId);
|
||||
this.removed = { node, edges: removeWhere(graph.edges, (e) => touching.includes(e)) };
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
@@ -79,24 +82,23 @@ export class MoveNodeCommand implements Command {
|
||||
}
|
||||
|
||||
/** Sets a node param or the inline value of an unconnected data input. */
|
||||
export class SetNodeParamCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly assetId: string, private readonly nodeId: string, private readonly key: string, private readonly value: PropertyValue) {
|
||||
this.label = `Set ${key}`;
|
||||
export class SetNodeParamCommand extends SetBagValueCommand<GraphNode> {
|
||||
constructor(private readonly assetId: string, private readonly nodeId: string, key: string, private readonly value: PropertyValue) {
|
||||
super(key, `Set ${key}`);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const node = requireNode(project, this.assetId, this.nodeId);
|
||||
protected override resolveTarget(project: Project): GraphNode {
|
||||
return requireNode(project, this.assetId, this.nodeId);
|
||||
}
|
||||
|
||||
protected override bagOf(node: GraphNode): PropertyBag {
|
||||
return node.params;
|
||||
}
|
||||
|
||||
protected override computeNext(node: GraphNode): PropertyValue {
|
||||
const param = requireNodeDef(node.type).params.find((p) => p.key === this.key);
|
||||
this.previous = node.params[this.key];
|
||||
// Inline input values are stored as-is; declared params are coerced against their definition.
|
||||
node.params[this.key] = param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireNode(project, this.assetId, this.nodeId).params, this.key, this.previous);
|
||||
return param && param.kind !== 'enum' ? coerceValue(param, this.value) : this.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +133,7 @@ export class ConnectCommand implements Command {
|
||||
|
||||
undo(project: Project): void {
|
||||
const graph = requireBehavior(project, this.assetId).graph;
|
||||
graph.edges = graph.edges.filter((e) => e.id !== this.edge.id);
|
||||
graph.edges = removeById(graph.edges, this.edge.id);
|
||||
restoreAt(graph.edges, this.replaced);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* message the editor can show.
|
||||
*/
|
||||
import { findNode, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, BehaviorAsset, SceneAsset } from '../model/Asset';
|
||||
import { findObject } from '../model/hierarchy';
|
||||
import { findAsset, findScene, type Project } from '../model/Project';
|
||||
@@ -41,6 +42,17 @@ export function requireNode(project: Project, assetId: string, nodeId: string):
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The definition of a node type. `NodeRegistry.requireNodeDef` throws a plain `Error`, which
|
||||
* the editor does not catch – a command must reject an unknown type as a `CommandError` so it
|
||||
* becomes a notice instead of a crash.
|
||||
*/
|
||||
export function requireNodeDef(type: string): NodeDef {
|
||||
const def = nodeDef(type);
|
||||
if (!def) throw new CommandError(`Unknown node type "${type}"`);
|
||||
return def;
|
||||
}
|
||||
|
||||
/** The schema entry for `key` on the asset – a property the asset's type (or the behavior) declares. */
|
||||
export function requirePropDef(asset: Asset, key: string): PropertyDef {
|
||||
const def = propDef(asset, key);
|
||||
|
||||
@@ -2,13 +2,14 @@ import { newId } from '../ids';
|
||||
import { isPlaceableType } from '../model/AssetTypes';
|
||||
import { instantiateContainer } from '../model/container';
|
||||
import { isSelfOrAncestor, localPositionFor, removeSubtrees, worldPosition } from '../model/hierarchy';
|
||||
import { type Position, type PropertyValue, setBagValue, toPosition, type Vec2 } from '../model/Properties';
|
||||
import { type Position, type PropertyBag, type PropertyValue, toPosition, type Vec2 } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { createSceneObject, type SceneObject } from '../model/SceneObject';
|
||||
import { coerceValue } from '../properties/PropertySchema';
|
||||
import { type Removed, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset, requireObject, requirePropDef, requireScene } from './require';
|
||||
import { SetBagValueCommand } from './SetBagValueCommand';
|
||||
|
||||
/**
|
||||
* Places an asset into a scene, creating a scene object. Scenes cannot be placed. The
|
||||
@@ -134,49 +135,42 @@ export class RenameSceneObjectCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** The half of `SetBagValueCommand` both override edits share: the bag is the object's overrides. */
|
||||
abstract class OverrideCommand extends SetBagValueCommand<SceneObject> {
|
||||
protected constructor(private readonly sceneId: string, private readonly objectId: string, key: string, label: string) {
|
||||
super(key, label);
|
||||
}
|
||||
|
||||
protected override resolveTarget(project: Project): SceneObject {
|
||||
return requireObject(project, this.sceneId, this.objectId);
|
||||
}
|
||||
|
||||
protected override bagOf(object: SceneObject): PropertyBag {
|
||||
return object.overrides;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets a per-object override of an asset property. */
|
||||
export class SetOverrideCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly sceneId: string,
|
||||
private readonly objectId: string,
|
||||
private readonly key: string,
|
||||
private readonly value: PropertyValue,
|
||||
) {
|
||||
this.label = `Override ${key}`;
|
||||
export class SetOverrideCommand extends OverrideCommand {
|
||||
constructor(sceneId: string, objectId: string, key: string, private readonly value: PropertyValue) {
|
||||
super(sceneId, objectId, key, `Override ${key}`);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
const def = requirePropDef(requireAsset(project, object.assetId), this.key);
|
||||
this.previous = object.overrides[this.key];
|
||||
object.overrides[this.key] = coerceValue(def, this.value);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
|
||||
protected override computeNext(object: SceneObject, project: Project): PropertyValue {
|
||||
return coerceValue(requirePropDef(requireAsset(project, object.assetId), this.key), this.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes an override so the object follows its asset again. */
|
||||
export class ClearOverrideCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous: PropertyValue | undefined;
|
||||
|
||||
constructor(private readonly sceneId: string, private readonly objectId: string, private readonly key: string) {
|
||||
this.label = `Reset ${key}`;
|
||||
export class ClearOverrideCommand extends OverrideCommand {
|
||||
constructor(sceneId: string, objectId: string, key: string) {
|
||||
super(sceneId, objectId, key, `Reset ${key}`);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const object = requireObject(project, this.sceneId, this.objectId);
|
||||
this.previous = object.overrides[this.key];
|
||||
delete object.overrides[this.key];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setBagValue(requireObject(project, this.sceneId, this.objectId).overrides, this.key, this.previous);
|
||||
// Unlike setting one, clearing an override does not check the key against the schema: a
|
||||
// key the asset no longer declares is exactly what one wants to be able to clear.
|
||||
protected override computeNext(): undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PropertyValue } from '../model/Properties';
|
||||
import { findUsages, type Project } from '../model/Project';
|
||||
import { isCommonProp, type PropertyDef, type VariableKind, variableDef } from '../properties/PropertySchema';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireBehavior } from './require';
|
||||
|
||||
@@ -32,8 +33,8 @@ export class AddVariableCommand implements Command {
|
||||
/** Removes a variable together with its default and every instance override. */
|
||||
export class RemoveVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
private removed?: { def: PropertyDef; index: number; value: PropertyValue | undefined };
|
||||
private overrides: { sceneId: string; objectId: string; value: PropertyValue }[] = [];
|
||||
private removed?: { def: Removed<PropertyDef>; value: PropertyValue | undefined };
|
||||
private overrides: { holderId: string; objectId: string; value: PropertyValue }[] = [];
|
||||
|
||||
constructor(private readonly assetId: string, private readonly key: string) {
|
||||
this.label = `Remove variable ${key}`;
|
||||
@@ -41,17 +42,15 @@ export class RemoveVariableCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
const index = asset.variables.findIndex((v) => v.key === this.key);
|
||||
const def = asset.variables[index];
|
||||
const def = removeFirst(asset.variables, (v) => v.key === this.key);
|
||||
if (!def) throw new CommandError(`Variable "${this.key}" not found`);
|
||||
this.removed = { def, index, value: asset.props[this.key] };
|
||||
asset.variables.splice(index, 1);
|
||||
this.removed = { def, value: asset.props[this.key] };
|
||||
delete asset.props[this.key];
|
||||
this.overrides = [];
|
||||
for (const { scene, object } of findUsages(project, this.assetId)) {
|
||||
for (const { holder, object } of findUsages(project, this.assetId)) {
|
||||
const value = object.overrides[this.key];
|
||||
if (value === undefined) continue;
|
||||
this.overrides.push({ sceneId: scene.id, objectId: object.id, value });
|
||||
this.overrides.push({ holderId: holder.id, objectId: object.id, value });
|
||||
delete object.overrides[this.key];
|
||||
}
|
||||
}
|
||||
@@ -59,17 +58,20 @@ export class RemoveVariableCommand implements Command {
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireBehavior(project, this.assetId);
|
||||
asset.variables.splice(this.removed.index, 0, this.removed.def);
|
||||
restoreAt(asset.variables, [this.removed.def]);
|
||||
if (this.removed.value !== undefined) asset.props[this.key] = this.removed.value;
|
||||
const usages = findUsages(project, this.assetId);
|
||||
for (const o of this.overrides) {
|
||||
const object = usages.find((u) => u.scene.id === o.sceneId && u.object.id === o.objectId)?.object;
|
||||
const object = usages.find((u) => u.holder.id === o.holderId && u.object.id === o.objectId)?.object;
|
||||
if (object) object.overrides[this.key] = o.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Renames a variable everywhere: definition, default, instance overrides and graph nodes referring to it. */
|
||||
/**
|
||||
* Renames a variable everywhere: definition, default, instance overrides (in scenes and in
|
||||
* container templates) and graph nodes referring to it.
|
||||
*/
|
||||
export class RenameVariableCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
|
||||
@@ -44,10 +44,6 @@ export function findNode(graph: Graph, nodeId: string): GraphNode | undefined {
|
||||
return graph.nodes.find((n) => n.id === nodeId);
|
||||
}
|
||||
|
||||
export function findEdge(graph: Graph, edgeId: string): GraphEdge | undefined {
|
||||
return graph.edges.find((e) => e.id === edgeId);
|
||||
}
|
||||
|
||||
/** Edges arriving at one input port (data inputs allow at most one, exec inputs any number). */
|
||||
export function edgesInto(graph: Graph, nodeId: string, port: string): GraphEdge[] {
|
||||
return graph.edges.filter((e) => e.to.node === nodeId && e.to.port === port);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { sumAlongChain } from '../model/walk';
|
||||
import { enabledAlongChain } from '../properties/enabled';
|
||||
|
||||
/**
|
||||
@@ -21,15 +22,7 @@ export interface EntityHandle {
|
||||
|
||||
/** World position of a handle: its local position plus those of all ancestors. */
|
||||
export function worldPositionOf(handle: EntityHandle): { x: number; y: number; z: number } {
|
||||
const result = { x: handle.x, y: handle.y, z: handle.z };
|
||||
const seen = new Set<EntityHandle>([handle]);
|
||||
for (let p = handle.parent; p && !seen.has(p); p = p.parent) {
|
||||
result.x += p.x;
|
||||
result.y += p.y;
|
||||
result.z += p.z;
|
||||
seen.add(p);
|
||||
}
|
||||
return result;
|
||||
return sumAlongChain(handle, (h) => h.parent, (h) => h);
|
||||
}
|
||||
|
||||
/** Whether a handle and every ancestor of it are switched on (see `properties/enabled.ts`). */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Asset, SceneAsset } from './Asset';
|
||||
import type { Asset, ContainerAsset, SceneAsset } from './Asset';
|
||||
import { isSceneAsset } from './Asset';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -32,21 +32,32 @@ export function listScenes(project: Project): SceneAsset[] {
|
||||
return project.assets.filter(isSceneAsset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that holds placed objects. Container assets do so just like scenes: their
|
||||
* `objects` are the template a placed container is instantiated from. Anything that walks
|
||||
* "all placed objects" has to cover both, or it silently skips the templates.
|
||||
*/
|
||||
export type ObjectHolder = SceneAsset | ContainerAsset;
|
||||
|
||||
export function objectHolders(project: Project): ObjectHolder[] {
|
||||
return project.assets.filter((a): a is ObjectHolder => a.type === 'scene' || a.type === 'container');
|
||||
}
|
||||
|
||||
export interface SceneObjectRef {
|
||||
scene: SceneAsset;
|
||||
holder: ObjectHolder;
|
||||
object: SceneObject;
|
||||
}
|
||||
|
||||
/** Objects of all scenes that satisfy `predicate`, each with the scene it belongs to. */
|
||||
export function sceneObjectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
|
||||
/** Objects of all scenes and container templates that satisfy `predicate`, each with its holder. */
|
||||
export function objectsWhere(project: Project, predicate: (object: SceneObject) => boolean): SceneObjectRef[] {
|
||||
const result: SceneObjectRef[] = [];
|
||||
for (const scene of listScenes(project)) {
|
||||
for (const object of scene.objects) if (predicate(object)) result.push({ scene, object });
|
||||
for (const holder of objectHolders(project)) {
|
||||
for (const object of holder.objects) if (predicate(object)) result.push({ holder, object });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Every placed instance of an asset, across all scenes. */
|
||||
/** Every placed instance of an asset, in scenes and in container templates alike. */
|
||||
export function findUsages(project: Project, assetId: string): SceneObjectRef[] {
|
||||
return sceneObjectsWhere(project, (o) => o.assetId === assetId);
|
||||
return objectsWhere(project, (o) => o.assetId === assetId);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { newId } from '../ids';
|
||||
import type { ContainerAsset } from './Asset';
|
||||
import { chainOf, type ObjectTree } from './hierarchy';
|
||||
import { findAsset, type Project, sceneObjectsWhere, type SceneObjectRef } from './Project';
|
||||
import { findAsset, objectsWhere, type Project, type SceneObjectRef } from './Project';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
/** What a placed container's content object is linked to: the container and its template object. */
|
||||
@@ -27,9 +27,13 @@ export function templateLinkOf(project: Project, tree: ObjectTree, object: Scene
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Every placed copy of a template object, across all scenes (template ids are unique per project). */
|
||||
/**
|
||||
* Every placed copy of a template object (template ids are unique per project). Template
|
||||
* objects themselves never carry a template id, so a template is never mistaken for a copy
|
||||
* of itself even though the search covers container assets too.
|
||||
*/
|
||||
export function instancesOf(project: Project, templateId: string): SceneObjectRef[] {
|
||||
return sceneObjectsWhere(project, (o) => o.templateId === templateId);
|
||||
return objectsWhere(project, (o) => o.templateId === templateId);
|
||||
}
|
||||
|
||||
/** Nesting depth at which a container inside a container inside … stops being expanded. */
|
||||
|
||||
+17
-29
@@ -1,6 +1,7 @@
|
||||
import { type Removed, removeWhere } from '../util/list';
|
||||
import type { Position } from './Properties';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
import { descendantsBelow, parentChain, sumAlongChain } from './walk';
|
||||
|
||||
/**
|
||||
* Parent/child structure of a scene. `scene.objects` stays a flat list (storage order =
|
||||
@@ -19,21 +20,18 @@ export function findObject(tree: ObjectTree, id: string): SceneObject | undefine
|
||||
return tree.objects.find((o) => o.id === id);
|
||||
}
|
||||
|
||||
export function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
|
||||
function childrenOf(tree: ObjectTree, parentId: string): SceneObject[] {
|
||||
return tree.objects.filter((o) => o.parentId === parentId);
|
||||
}
|
||||
|
||||
/** The parent of an object, or undefined for a top-level one (and for a dangling link). */
|
||||
function parentOf(tree: ObjectTree, object: SceneObject): SceneObject | undefined {
|
||||
return object.parentId ? findObject(tree, object.parentId) : undefined;
|
||||
}
|
||||
|
||||
/** All objects below `id`, depth-first, parents before children. */
|
||||
export function descendantsOf(tree: ObjectTree, id: string): SceneObject[] {
|
||||
const result: SceneObject[] = [];
|
||||
const visit = (parentId: string) => {
|
||||
for (const child of childrenOf(tree, parentId)) {
|
||||
result.push(child);
|
||||
visit(child.id);
|
||||
}
|
||||
};
|
||||
visit(id);
|
||||
return result;
|
||||
return descendantsBelow(id, (parentId) => childrenOf(tree, parentId), (o) => o.id);
|
||||
}
|
||||
|
||||
/** The ids of `rootIds` and of everything below them – what group operations act on. */
|
||||
@@ -54,15 +52,7 @@ export function removeSubtrees(tree: ObjectTree, rootIds: readonly string[]): Re
|
||||
|
||||
/** An object and every parent above it, nearest first. Stops at a cycle (defensive). */
|
||||
export function chainOf(tree: ObjectTree, object: SceneObject): SceneObject[] {
|
||||
const chain = [object];
|
||||
const seen = new Set<string>([object.id]);
|
||||
let parent = object.parentId ? findObject(tree, object.parentId) : undefined;
|
||||
while (parent && !seen.has(parent.id)) {
|
||||
chain.push(parent);
|
||||
seen.add(parent.id);
|
||||
parent = parent.parentId ? findObject(tree, parent.parentId) : undefined;
|
||||
}
|
||||
return chain;
|
||||
return [...parentChain(object, (o) => parentOf(tree, o))];
|
||||
}
|
||||
|
||||
/** Ids of the ancestors of `id`, nearest parent first. Empty for a top-level object. */
|
||||
@@ -86,19 +76,17 @@ export function subtreeRoots(tree: ObjectTree, ids: readonly string[]): string[]
|
||||
return ids.filter((id) => !ancestorIdsOf(tree, id).some((ancestorId) => set.has(ancestorId)));
|
||||
}
|
||||
|
||||
function sumAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
|
||||
const result: Position = { x: 0, y: 0, z: 0 };
|
||||
for (const o of chainOf(tree, object)) {
|
||||
result.x += o.position.x + (withEditorOffset ? o.editorOffset.x : 0);
|
||||
result.y += o.position.y + (withEditorOffset ? o.editorOffset.y : 0);
|
||||
result.z += o.position.z;
|
||||
}
|
||||
return result;
|
||||
function positionAlongChain(tree: ObjectTree, object: SceneObject, withEditorOffset: boolean): Position {
|
||||
return sumAlongChain(
|
||||
object,
|
||||
(o) => parentOf(tree, o),
|
||||
(o) => (withEditorOffset ? { x: o.position.x + o.editorOffset.x, y: o.position.y + o.editorOffset.y, z: o.position.z } : o.position),
|
||||
);
|
||||
}
|
||||
|
||||
/** Position of an object in world units (all local positions along the chain summed up). */
|
||||
export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
|
||||
return sumAlongChain(tree, object, false);
|
||||
return positionAlongChain(tree, object, false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +95,7 @@ export function worldPosition(tree: ObjectTree, object: SceneObject): Position {
|
||||
* in the game, in collisions or in the graph ever sees it (they all use `worldPosition`).
|
||||
*/
|
||||
export function editorPosition(tree: ObjectTree, object: SceneObject): Position {
|
||||
return sumAlongChain(tree, object, true);
|
||||
return positionAlongChain(tree, object, true);
|
||||
}
|
||||
|
||||
/** The local position that puts an object at `world` under the given parent ('' = top level). */
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Walking a parent/child structure, generic over the shape. Two hierarchies exist side by
|
||||
* side – scene objects (`parentId` + lookup in a flat list) and runtime entities (direct
|
||||
* `parent` pointer) – and both walk the same way. Callers pass in how to get from an item to
|
||||
* its parent (or children); everything else lives here, so the cycle guard exists once.
|
||||
*/
|
||||
import type { Position } from './Properties';
|
||||
|
||||
/**
|
||||
* `start` and every ancestor above it, nearest first. An item that was already visited ends
|
||||
* the walk instead of looping forever: a cyclic parent chain is always a bug (loading repairs
|
||||
* one, see `store/serialize`), but no walk at runtime may hang because of it.
|
||||
*/
|
||||
export function* parentChain<T>(start: T, parentOf: (item: T) => T | null | undefined): Generator<T> {
|
||||
const seen = new Set<T>();
|
||||
for (let current: T | null | undefined = start; current && !seen.has(current); current = parentOf(current)) {
|
||||
seen.add(current);
|
||||
yield current;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the parents of `start` lead into a cycle instead of up to a root. */
|
||||
export function hasParentCycle<T>(start: T, parentOf: (item: T) => T | null | undefined): boolean {
|
||||
let last = start;
|
||||
for (const item of parentChain(start, parentOf)) last = item;
|
||||
// The walk stops either above a root or at an already visited item – only the latter still has a parent.
|
||||
return Boolean(parentOf(last));
|
||||
}
|
||||
|
||||
/**
|
||||
* The local positions of `start` and all its ancestors summed up – the world position, as
|
||||
* long as the hierarchy has no rotation or scale (it has none).
|
||||
*/
|
||||
export function sumAlongChain<T>(start: T, parentOf: (item: T) => T | null | undefined, positionOf: (item: T) => Position): Position {
|
||||
const total: Position = { x: 0, y: 0, z: 0 };
|
||||
for (const item of parentChain(start, parentOf)) {
|
||||
const position = positionOf(item);
|
||||
total.x += position.x;
|
||||
total.y += position.y;
|
||||
total.z += position.z;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything below `rootKey`, depth-first, parents before children. `keyOf` turns a found item
|
||||
* back into the key its own children are looked up by, which is what lets ids (scene objects)
|
||||
* and object identity (entities) use the same walk.
|
||||
*/
|
||||
export function descendantsBelow<T, K>(rootKey: K, childrenOf: (key: K) => readonly T[], keyOf: (item: T) => K): T[] {
|
||||
const result: T[] = [];
|
||||
const visit = (key: K) => {
|
||||
for (const child of childrenOf(key)) {
|
||||
result.push(child);
|
||||
visit(keyOf(child));
|
||||
}
|
||||
};
|
||||
visit(rootKey);
|
||||
return result;
|
||||
}
|
||||
@@ -16,8 +16,6 @@ export type PropertyDef =
|
||||
/** A keyboard key name (`KeyboardEvent.key`), captured by pressing it. */
|
||||
| { key: string; label: string; kind: 'key'; default: string };
|
||||
|
||||
export type PropertyKind = PropertyDef['kind'];
|
||||
|
||||
export type PropertySchema = readonly PropertyDef[];
|
||||
|
||||
/** Kinds a behavior variable may have (what the node graph can compute with). */
|
||||
@@ -40,7 +38,7 @@ export function isCommonProp(key: string): boolean {
|
||||
}
|
||||
|
||||
/** Property schema per asset type. Position is deliberately absent: it belongs to scene objects. */
|
||||
export const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }],
|
||||
camera: [
|
||||
{ key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1 },
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* what makes a container usable as a screen: switch it on to show it, off to hide it.
|
||||
*/
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { parentChain } from '../model/walk';
|
||||
|
||||
/** A missing value counts as on. */
|
||||
function propsEnabled(props: PropertyBag): boolean {
|
||||
@@ -16,10 +17,6 @@ function propsEnabled(props: PropertyBag): boolean {
|
||||
* model (scene objects), the viewport (draw items) and the runtime (entities) share one rule.
|
||||
*/
|
||||
export function enabledAlongChain<T>(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean {
|
||||
const seen = new Set<T>();
|
||||
for (let current: T | null = start; current && !seen.has(current); current = parentOf(current)) {
|
||||
if (!propsEnabled(propsOf(current))) return false;
|
||||
seen.add(current);
|
||||
}
|
||||
for (const item of parentChain(start, parentOf)) if (!propsEnabled(propsOf(item))) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { isAssetType } from '../model/AssetTypes';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import { PROJECT_FORMAT_VERSION, type Project } from '../model/Project';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { hasParentCycle } from '../model/walk';
|
||||
import { coerceValue, COMMON_PROPS, isCommonProp, type PropertyDef, type PropertySchema, schemaFor, VARIABLE_KINDS, variableDef, type VariableKind } from '../properties/PropertySchema';
|
||||
|
||||
/**
|
||||
@@ -94,18 +95,9 @@ function normalizeObjects(raw: unknown, schemas: Map<string, PropertySchema>): S
|
||||
// Parents must exist in the same scene, differ from the object itself and not form cycles.
|
||||
const byId = new Map(objects.map((o) => [o.id, o]));
|
||||
for (const o of objects) if (o.parentId && (o.parentId === o.id || !byId.has(o.parentId))) o.parentId = '';
|
||||
for (const o of objects) {
|
||||
const seen = new Set<string>([o.id]);
|
||||
let current = o.parentId ? byId.get(o.parentId) : undefined;
|
||||
while (current) {
|
||||
if (seen.has(current.id)) {
|
||||
o.parentId = '';
|
||||
break;
|
||||
}
|
||||
seen.add(current.id);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
}
|
||||
// Cut one link per cycle: an object already unlinked here is a root for the objects checked after it.
|
||||
const parentOf = (o: SceneObject) => (o.parentId ? byId.get(o.parentId) : undefined);
|
||||
for (const o of objects) if (hasParentCycle(o, parentOf)) o.parentId = '';
|
||||
return objects;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ export interface Removed<T> {
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the first item matching `predicate` in place and returns it with its former index.
|
||||
* `undefined` means nothing matched – the list is then untouched, so a caller may reject the
|
||||
* edit afterwards without having to revert anything.
|
||||
*/
|
||||
export function removeFirst<T>(list: T[], predicate: (item: T) => boolean): Removed<T> | undefined {
|
||||
const index = list.findIndex(predicate);
|
||||
// A valid index makes `splice` return exactly one element; the `!` is that index check.
|
||||
return index < 0 ? undefined : { item: list.splice(index, 1)[0]!, index };
|
||||
}
|
||||
|
||||
/** Removes every item matching `predicate` in place; returns them with their former indices, ascending. */
|
||||
export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Removed<T>[] {
|
||||
const removed: Removed<T>[] = [];
|
||||
@@ -18,3 +29,11 @@ export function removeWhere<T>(list: T[], predicate: (item: T) => boolean): Remo
|
||||
export function restoreAt<T>(list: T[], removed: readonly Removed<T>[]): void {
|
||||
for (const entry of [...removed].sort((a, b) => a.index - b.index)) list.splice(entry.index, 0, entry.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* A **new** list without the item carrying `id` – unlike the `remove*` helpers above this one
|
||||
* does not mutate, which is what undoing a `push` needs: `list = removeById(list, id)`.
|
||||
*/
|
||||
export function removeById<T extends { id: string }>(list: readonly T[], id: string): T[] {
|
||||
return list.filter((item) => item.id !== id);
|
||||
}
|
||||
+48
-172
@@ -1,45 +1,43 @@
|
||||
import type { Application } from 'pixi.js';
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { BehaviorSystem } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
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 { Button, HStack, Splitter, Theme, ui, VStack } from '../ui';
|
||||
import { GameViewport } from '../viewport/GameViewport';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
import { attachAutosave } from './autosave';
|
||||
import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
import { type PlayHost, PlaySession, type WorldSnapshot } from './PlaySession';
|
||||
import { ProjectSession } from './ProjectSession';
|
||||
import { installShortcuts } from './shortcuts';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export type CentreTab = 'scene' | 'graph' | 'game';
|
||||
|
||||
/** Facts about the running world for the check scripts. */
|
||||
export interface WorldSnapshot {
|
||||
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
|
||||
log: string[];
|
||||
}
|
||||
const MIN_LEFT_WIDTH = 160;
|
||||
const MAX_LEFT_WIDTH = 500;
|
||||
const MIN_RIGHT_WIDTH = 200;
|
||||
const MAX_RIGHT_WIDTH = 600;
|
||||
|
||||
/**
|
||||
* Composes toolbar, panels, viewports, graph editor and runtime into the one application that
|
||||
* edits and plays. Owns the play session: the world, its systems and the tick.
|
||||
* Composes toolbar, panels, viewports and graph editor into the one application that edits and
|
||||
* plays. What it owns itself is the layout, the tab strip and the frame ticker; running the
|
||||
* project is `PlaySession`, loading it is `ProjectSession`.
|
||||
*/
|
||||
export class Editor {
|
||||
export class Editor implements PlayHost {
|
||||
/** Scene tab: the editable model view (live positions while playing). */
|
||||
readonly viewport: Viewport;
|
||||
readonly viewport: SceneViewport;
|
||||
/** Game tab: the running world through its camera. */
|
||||
readonly gameView: Viewport;
|
||||
readonly gameView: GameViewport;
|
||||
readonly graphEditor: GraphEditor;
|
||||
readonly hierarchy: HierarchyPanel;
|
||||
tab: CentreTab = 'scene';
|
||||
/** Frame rate, refreshed twice a second; null until the first measurement. */
|
||||
fps: number | null = null;
|
||||
private readonly play: PlaySession;
|
||||
private readonly projects: ProjectSession;
|
||||
private readonly leftColumn: VStack;
|
||||
private readonly inspector: Inspector;
|
||||
private readonly leftSplitter = new Splitter('x');
|
||||
@@ -48,33 +46,37 @@ export class Editor {
|
||||
private readonly centre: VStack;
|
||||
private readonly tabButtons: Record<CentreTab, Button>;
|
||||
private readonly toolbar: Toolbar;
|
||||
private world: World | null = null;
|
||||
private behaviors: BehaviorSystem | null = null;
|
||||
private readonly input = new Input();
|
||||
private fpsFrames = 0;
|
||||
private fpsElapsed = 0;
|
||||
private leftWidth = 240;
|
||||
private rightWidth = 300;
|
||||
|
||||
constructor(app: Application, root: VStack, private readonly state: EditorState, private readonly store: ProjectStore) {
|
||||
constructor(app: Application, root: VStack, state: EditorState, store: ProjectStore) {
|
||||
this.viewport = new SceneViewport(state);
|
||||
this.gameView = new GameViewport(state);
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.inspector = new Inspector(state);
|
||||
this.play = new PlaySession(state, this);
|
||||
// A run holds a copy of the project, so it has to end before a different one is loaded.
|
||||
this.projects = new ProjectSession(state, store, () => {
|
||||
if (this.play.isPlaying) this.play.stop();
|
||||
});
|
||||
|
||||
this.graphEditor.traceProvider = (assetId) => this.play.traceFor(assetId);
|
||||
this.graphEditor.timeProvider = () => this.play.time;
|
||||
|
||||
this.toolbar = new Toolbar(state, {
|
||||
openProjectMenu: (anchor) => this.openProjectMenu(anchor),
|
||||
openSceneMenu: (anchor) => this.openSceneMenu(anchor),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play() : this.stop()),
|
||||
openProjectMenu: (anchor) => this.projects.openProjectMenu(anchor),
|
||||
openSceneMenu: (anchor) => this.projects.openSceneMenu(anchor),
|
||||
togglePlay: () => (state.mode === 'edit' ? this.play.play() : this.play.stop()),
|
||||
});
|
||||
root.add(this.toolbar);
|
||||
|
||||
this.workspace = new HStack();
|
||||
this.viewport = new Viewport(state);
|
||||
this.gameView = new Viewport(state, { game: true });
|
||||
this.graphEditor = new GraphEditor(state);
|
||||
this.graphEditor.traceProvider = (assetId) => this.behaviors?.traceFor(assetId) ?? null;
|
||||
this.graphEditor.timeProvider = () => this.world?.time ?? 0;
|
||||
this.leftColumn = new VStack({ gap: 1 });
|
||||
this.leftColumn.add(new AssetPanel(state, () => this.viewport.worldCenter()), 1);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
this.leftColumn.add(this.hierarchy, 1);
|
||||
this.inspector = new Inspector(state);
|
||||
|
||||
// Centre: a tab strip (Scene | Game | Graph) above the scene view, the game view or the graph editor.
|
||||
this.centre = new VStack();
|
||||
@@ -97,12 +99,12 @@ export class Editor {
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
this.leftSplitter.onDrag = (delta) => {
|
||||
this.leftWidth = Math.max(160, Math.min(500, this.leftWidth + delta));
|
||||
this.leftWidth = Math.max(MIN_LEFT_WIDTH, Math.min(MAX_LEFT_WIDTH, this.leftWidth + delta));
|
||||
this.leftColumn.minWidth = this.leftWidth;
|
||||
this.workspace.relayout();
|
||||
};
|
||||
this.rightSplitter.onDrag = (delta) => {
|
||||
this.rightWidth = Math.max(200, Math.min(600, this.rightWidth - delta));
|
||||
this.rightWidth = Math.max(MIN_RIGHT_WIDTH, Math.min(MAX_RIGHT_WIDTH, this.rightWidth - delta));
|
||||
this.inspector.minWidth = this.rightWidth;
|
||||
this.workspace.relayout();
|
||||
};
|
||||
@@ -115,14 +117,14 @@ export class Editor {
|
||||
root.resize(app.screen.width, app.screen.height);
|
||||
|
||||
attachAutosave(state, store);
|
||||
installShortcuts(state, { play: () => this.play(), stop: () => this.stop() });
|
||||
installShortcuts(state, { play: () => this.play.play(), stop: () => this.play.stop() });
|
||||
app.ticker.add((ticker) => this.tick(ticker.deltaMS / 1000));
|
||||
// Selecting a graph node switches to the graph; selecting a behavior does not force it.
|
||||
state.on('selection', () => {
|
||||
if (state.selection?.kind === 'node' && this.tab !== 'graph') this.showTab('graph');
|
||||
});
|
||||
// While playing, every edit (command, undo, scrub preview) is mirrored into the world at once.
|
||||
state.on('project', () => this.world?.syncFromModel());
|
||||
state.on('project', () => this.play.syncFromModel());
|
||||
}
|
||||
|
||||
showTab(tab: CentreTab): void {
|
||||
@@ -135,144 +137,26 @@ export class Editor {
|
||||
ui().focus.validate();
|
||||
}
|
||||
|
||||
// --- project lifecycle -----------------------------------------------------------------------
|
||||
|
||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||||
async boot(): Promise<void> {
|
||||
const wanted = new URLSearchParams(location.search).get('project');
|
||||
try {
|
||||
if (wanted) {
|
||||
await this.open(wanted);
|
||||
return;
|
||||
}
|
||||
await this.openFirstOrCreate();
|
||||
} catch (error) {
|
||||
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
boot(): Promise<void> {
|
||||
return this.projects.boot();
|
||||
}
|
||||
|
||||
private async open(id: string): Promise<void> {
|
||||
if (this.world) this.stop();
|
||||
const loaded = await this.store.load(id);
|
||||
this.state.loadProject(loaded.project, loaded.revision);
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('project', id);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
// --- PlayHost --------------------------------------------------------------------------------
|
||||
|
||||
private async openFirstOrCreate(): Promise<void> {
|
||||
const list = await this.store.list();
|
||||
if (list[0]) await this.open(list[0].id);
|
||||
else await this.createProject('My first project');
|
||||
}
|
||||
|
||||
private async createProject(name: string): Promise<void> {
|
||||
const created = await this.store.create(name);
|
||||
await this.open(created.project.id);
|
||||
}
|
||||
|
||||
private async deleteCurrentProject(): Promise<void> {
|
||||
if (this.world) this.stop();
|
||||
await this.store.delete(this.state.project.id);
|
||||
await this.openFirstOrCreate();
|
||||
}
|
||||
|
||||
private openProjectMenu(anchor: Widget): void {
|
||||
const open = async () => {
|
||||
const list = await this.store.list();
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'New project', onSelect: () => void this.createProject(`Project ${list.length + 1}`) },
|
||||
...list.map((p) => ({
|
||||
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
|
||||
onSelect: () => void this.open(p.id),
|
||||
disabled: p.id === this.state.project.id,
|
||||
})),
|
||||
{
|
||||
label: 'Delete this project…',
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrentProject() },
|
||||
]).openCentered(),
|
||||
},
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
};
|
||||
void open().catch((error) => this.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
private openSceneMenu(anchor: Widget): void {
|
||||
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
|
||||
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
|
||||
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
|
||||
// --- play mode -------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Play: the state switches to a runtime copy of the project, the world is built from that
|
||||
* copy and shown in the Game tab. All panels stay open and edit the copy – the world picks
|
||||
* those edits up immediately, and nothing reaches the saved model.
|
||||
*/
|
||||
private play(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
ui().focus.blur();
|
||||
const runtime = this.state.enterPlay();
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
this.input.attach();
|
||||
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(this.behaviors);
|
||||
this.gameView.setWorld(world);
|
||||
showGame(): void {
|
||||
this.showTab('game');
|
||||
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/** Stop: the world and the runtime copy are discarded; the saved model returns untouched. */
|
||||
private stop(): void {
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
this.input.detach();
|
||||
ui().focus.blur();
|
||||
world?.destroy();
|
||||
this.gameView.setWorld(null);
|
||||
this.state.exitPlay();
|
||||
leaveGame(): void {
|
||||
if (this.tab === 'game') this.showTab('scene');
|
||||
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
// --- frame -----------------------------------------------------------------------------------
|
||||
|
||||
private tick(dt: number): void {
|
||||
this.measureFps(dt);
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
this.input.endFrame();
|
||||
if (world.stopRequested) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
// Restart game: throw the run away and start the scene again from the saved model.
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.graphEditor.visible) this.graphEditor.redraw();
|
||||
this.play.tick(dt);
|
||||
}
|
||||
|
||||
/** Counts rendered frames (edit and play alike) and refreshes the toolbar readout twice a second. */
|
||||
@@ -288,14 +172,6 @@ export class Editor {
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
worldSnapshot(): WorldSnapshot | null {
|
||||
const world = this.world;
|
||||
if (!world) return null;
|
||||
return {
|
||||
entities: world.entities.map((e) => {
|
||||
const w = worldPositionOf(e);
|
||||
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
|
||||
}),
|
||||
log: [...world.log],
|
||||
};
|
||||
return this.play.snapshot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Owns everything that only exists while the project is playing: the world, its systems, the
|
||||
* keyboard and the per-frame step.
|
||||
*
|
||||
* The key rule lives here: play never touches the saved model. `state.enterPlay()` swaps in a
|
||||
* runtime copy, the world is built from that copy, edits made while playing go into it, and
|
||||
* `exitPlay()` throws it away again.
|
||||
*/
|
||||
import { worldPositionOf } from '../core/graph';
|
||||
import { BehaviorSystem, type Trace } from '../runtime/systems/BehaviorSystem';
|
||||
import { CollisionSystem } from '../runtime/systems/CollisionSystem';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
import type { GameViewport } from '../viewport/GameViewport';
|
||||
import type { GraphEditor } from './graph/GraphEditor';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
/** Facts about the running world for the check scripts. */
|
||||
export interface WorldSnapshot {
|
||||
entities: { objectId: string; assetId: string; name: string; x: number; y: number; worldX: number; worldY: number; parent: string; props: Record<string, unknown> }[];
|
||||
log: string[];
|
||||
}
|
||||
|
||||
/** What a play session needs from the surrounding editor, and nothing more. */
|
||||
export interface PlayHost {
|
||||
gameView: GameViewport;
|
||||
graphEditor: GraphEditor;
|
||||
/** Bring the game view to the front – play has started. */
|
||||
showGame(): void;
|
||||
/** Leave the game view if it is the one on screen – play has stopped. */
|
||||
leaveGame(): void;
|
||||
}
|
||||
|
||||
export class PlaySession {
|
||||
private world: World | null = null;
|
||||
private behaviors: BehaviorSystem | null = null;
|
||||
private readonly input = new Input();
|
||||
|
||||
constructor(private readonly state: EditorState, private readonly host: PlayHost) {}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.world !== null;
|
||||
}
|
||||
|
||||
/** Live execution data for the graph editor (null when not playing). */
|
||||
traceFor(assetId: string): Trace | null {
|
||||
return this.behaviors?.traceFor(assetId) ?? null;
|
||||
}
|
||||
|
||||
get time(): number {
|
||||
return this.world?.time ?? 0;
|
||||
}
|
||||
|
||||
/** Mirrors an edit made while playing into the running world at once. */
|
||||
syncFromModel(): void {
|
||||
this.world?.syncFromModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a run: the state switches to a runtime copy, the world is built from it and shown
|
||||
* in the Game tab. All panels stay open and edit the copy.
|
||||
*/
|
||||
play(): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
ui().focus.blur();
|
||||
const runtime = this.state.enterPlay();
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
this.input.attach();
|
||||
// Collisions are detected first so behaviors see this frame's enter/stay/exit events.
|
||||
const collisions = new CollisionSystem();
|
||||
this.behaviors = new BehaviorSystem(runtime, this.input, collisions);
|
||||
world.addSystem(collisions);
|
||||
world.addSystem(this.behaviors);
|
||||
this.host.gameView.setWorld(world);
|
||||
this.host.showGame();
|
||||
this.state.setNotice(world.firstOfType('camera') ? 'Playing · edits are not saved · Esc stops' : 'This scene has no camera – nothing is rendered · Esc stops');
|
||||
if (world.stopRequested) this.stop();
|
||||
}
|
||||
|
||||
/** Ends the run: the world and the runtime copy are discarded, the saved model returns untouched. */
|
||||
stop(): void {
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
this.input.detach();
|
||||
ui().focus.blur();
|
||||
world?.destroy();
|
||||
this.host.gameView.setWorld(null);
|
||||
this.state.exitPlay();
|
||||
this.host.leaveGame();
|
||||
const lastError = world?.log.find((line) => line.startsWith('Error in behavior'));
|
||||
this.state.setNotice(lastError ?? '');
|
||||
}
|
||||
|
||||
/** One frame. Does nothing when not playing. */
|
||||
tick(dt: number): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
this.input.endFrame();
|
||||
if (world.stopRequested) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
// Restart game: throw the run away and start the scene again from the saved model.
|
||||
if (world.restartRequested) {
|
||||
this.stop();
|
||||
this.play();
|
||||
return;
|
||||
}
|
||||
// Mirror what the systems did into the runtime copy so the panels show live values.
|
||||
// A destroyed entity removes its object – that is a structural change the lists must see.
|
||||
if (world.syncToModel()) this.state.preview();
|
||||
this.state.runtimeTick();
|
||||
if (this.host.graphEditor.visible) this.host.graphEditor.redraw();
|
||||
}
|
||||
|
||||
/** Read-only view of the running world for the check scripts. */
|
||||
snapshot(): WorldSnapshot | null {
|
||||
const world = this.world;
|
||||
if (!world) return null;
|
||||
return {
|
||||
entities: world.entities.map((e) => {
|
||||
const w = worldPositionOf(e);
|
||||
return { objectId: e.objectId, assetId: e.assetId, name: e.name, x: e.x, y: e.y, worldX: w.x, worldY: w.y, parent: e.parent?.objectId ?? '', props: { ...e.props } };
|
||||
}),
|
||||
log: [...world.log],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Loading, creating, switching and deleting projects, plus the two toolbar menus that trigger
|
||||
* it. Talks only to the store and the editor state – no widgets of its own beyond the menus.
|
||||
*/
|
||||
import { SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||||
import { listScenes } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { Dialog, Menu, type MenuItem, type Widget } from '../ui';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export class ProjectSession {
|
||||
/**
|
||||
* `beforeSwitch` runs before a different project is loaded or the current one is deleted –
|
||||
* a run in progress has to end first, since it holds a copy of the project being replaced.
|
||||
*/
|
||||
constructor(private readonly state: EditorState, private readonly store: ProjectStore, private readonly beforeSwitch: () => void) {}
|
||||
|
||||
/** Opens the project named in the URL, else the first one on the server, else a new one. */
|
||||
async boot(): Promise<void> {
|
||||
const wanted = new URLSearchParams(location.search).get('project');
|
||||
try {
|
||||
if (wanted) {
|
||||
await this.open(wanted);
|
||||
return;
|
||||
}
|
||||
await this.openFirstOrCreate();
|
||||
} catch (error) {
|
||||
this.state.setNotice(`Could not load project: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async open(id: string): Promise<void> {
|
||||
this.beforeSwitch();
|
||||
const loaded = await this.store.load(id);
|
||||
this.state.loadProject(loaded.project, loaded.revision);
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set('project', id);
|
||||
history.replaceState(null, '', url);
|
||||
}
|
||||
|
||||
private async openFirstOrCreate(): Promise<void> {
|
||||
const list = await this.store.list();
|
||||
if (list[0]) await this.open(list[0].id);
|
||||
else await this.create('My first project');
|
||||
}
|
||||
|
||||
private async create(name: string): Promise<void> {
|
||||
const created = await this.store.create(name);
|
||||
await this.open(created.project.id);
|
||||
}
|
||||
|
||||
private async deleteCurrent(): Promise<void> {
|
||||
this.beforeSwitch();
|
||||
await this.store.delete(this.state.project.id);
|
||||
await this.openFirstOrCreate();
|
||||
}
|
||||
|
||||
openProjectMenu(anchor: Widget): void {
|
||||
const open = async () => {
|
||||
const list = await this.store.list();
|
||||
const items: MenuItem[] = [
|
||||
{ label: 'New project', onSelect: () => void this.create(`Project ${list.length + 1}`) },
|
||||
...list.map((p) => ({
|
||||
label: p.id === this.state.project.id ? `● ${p.name}` : p.name,
|
||||
onSelect: () => void this.open(p.id),
|
||||
disabled: p.id === this.state.project.id,
|
||||
})),
|
||||
{
|
||||
label: 'Delete this project…',
|
||||
danger: true,
|
||||
onSelect: () =>
|
||||
new Dialog(`Delete project "${this.state.project.name}"?`, 'All assets and scenes are removed permanently.', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => void this.deleteCurrent() },
|
||||
]).openCentered(),
|
||||
},
|
||||
];
|
||||
new Menu(items).openBelow(anchor);
|
||||
};
|
||||
void open().catch((error) => this.state.setNotice(String(error)));
|
||||
}
|
||||
|
||||
openSceneMenu(anchor: Widget): void {
|
||||
const items: MenuItem[] = listScenes(this.state.project).map((scene) => ({
|
||||
label: scene.id === this.state.project.activeSceneId ? `● ${scene.name}` : scene.name,
|
||||
onSelect: () => this.state.execute(new SetActiveSceneCommand(scene.id)),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No scenes – create one in Assets', onSelect: () => {}, disabled: true });
|
||||
new Menu(items).openBelow(anchor);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
* 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.
|
||||
*/
|
||||
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
import { AddColliderCommand } from '../core/commands/colliderCommands';
|
||||
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { findAsset, findUsages } from '../core/model/Project';
|
||||
import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||||
@@ -27,3 +30,26 @@ export function addCollider(state: EditorState, sceneId: string, objectId: strin
|
||||
export function removeObjects(state: EditorState, sceneId: string, objectIds: readonly string[]): boolean {
|
||||
return state.execute(new RemoveSceneObjectsCommand(sceneId, objectIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks before deleting an asset, spelling out what goes with it. Both ways of deleting (the
|
||||
* asset list's context menu and the Delete key) go through here, so the warning cannot differ
|
||||
* depending on how the user triggered it.
|
||||
*/
|
||||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const asset = findAsset(state.project, assetId);
|
||||
if (!asset) return;
|
||||
const usages = findUsages(state.project, assetId).length;
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
? `${usages} placed container(s) are removed together with their content.`
|
||||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||||
]).openCentered();
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Application, Container } from 'pixi.js';
|
||||
import { Text } from 'pixi.js';
|
||||
import { type Application, type Container, Text } from 'pixi.js';
|
||||
import type { Editor } from './Editor';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ import type { BehaviorAsset } from '../../core/model/Asset';
|
||||
import { findAsset } from '../../core/model/Project';
|
||||
import type { Trace } from '../../runtime/systems/BehaviorSystem';
|
||||
import { TRACE_FADE } from '../../runtime/systems/BehaviorSystem';
|
||||
import { Menu, type Size, Theme, textStyle, Widget } from '../../ui';
|
||||
import { clamp, drawGrid, Menu, type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
import { NodePalette } from './NodePalette';
|
||||
import { NodeView, portColor } from './NodeView';
|
||||
import { NODE_WIDTH, NodeView, portColor, TITLE_HEIGHT } from './NodeView';
|
||||
|
||||
const GRID_STEP = 24;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 8;
|
||||
const MIN_ZOOM = 0.25;
|
||||
const MAX_ZOOM = 2.5;
|
||||
/** Stand-in node height for centring the view – real nodes vary with their port count. */
|
||||
const NODE_HEIGHT_ESTIMATE = 80;
|
||||
|
||||
interface PortAnchor {
|
||||
nodeId: string;
|
||||
@@ -63,7 +69,7 @@ export class GraphEditor extends Widget {
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToGraph(local.x, local.y);
|
||||
this.zoom = Math.max(0.25, Math.min(2.5, this.zoom * Math.exp(-e.deltaY * 0.0015)));
|
||||
this.zoom = clamp(this.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = this.screenToGraph(local.x, local.y);
|
||||
this.pan.x += (after.x - before.x) * this.zoom;
|
||||
this.pan.y += (after.y - before.y) * this.zoom;
|
||||
@@ -148,13 +154,13 @@ export class GraphEditor extends Widget {
|
||||
this.needsFit = false;
|
||||
const asset = this.asset();
|
||||
if (!asset || asset.graph.nodes.length === 0) {
|
||||
this.pan = { x: this.w / 2 - 80, y: this.h / 3 };
|
||||
this.pan = { x: this.w / 2 - NODE_WIDTH / 2, y: this.h / 3 };
|
||||
this.zoom = 1;
|
||||
} else {
|
||||
const xs = asset.graph.nodes.map((n) => n.x);
|
||||
const ys = asset.graph.nodes.map((n) => n.y);
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + 176) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + 80) / 2;
|
||||
const cx = (Math.min(...xs) + Math.max(...xs) + NODE_WIDTH) / 2;
|
||||
const cy = (Math.min(...ys) + Math.max(...ys) + NODE_HEIGHT_ESTIMATE) / 2;
|
||||
this.zoom = 1;
|
||||
this.pan = { x: this.w / 2 - cx, y: this.h / 2 - cy };
|
||||
}
|
||||
@@ -306,7 +312,7 @@ export class GraphEditor extends Widget {
|
||||
const c = Theme.colors;
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(c.graphBackground);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
this.drawGrid();
|
||||
this.syncGrid();
|
||||
const asset = this.asset();
|
||||
this.canvas.visible = asset !== null;
|
||||
if (!asset) {
|
||||
@@ -359,15 +365,13 @@ export class GraphEditor extends Widget {
|
||||
this.edges.stroke({ width: hot ? 4 : type === 'exec' ? 2.5 : 2, color: hot ? Theme.colors.hot : valid ? portColor(type) : Theme.colors.danger, alpha: type === 'exec' || hot ? 1 : 0.85 });
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.zoom;
|
||||
if (step < 8) return;
|
||||
const startX = ((this.pan.x % step) + step) % step;
|
||||
const startY = ((this.pan.y % step) + step) % step;
|
||||
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||||
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.04 });
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
// `pan` is where the graph origin sits on screen – but the graph has no origin worth
|
||||
// marking, so no axis lines.
|
||||
drawGrid(this.grid, { originX: this.pan.x, originY: this.pan.y, step, w: this.w, h: this.h, alpha: 0.04 });
|
||||
}
|
||||
|
||||
// --- test hooks -----------------------------------------------------------------------------
|
||||
@@ -385,7 +389,7 @@ export class GraphEditor extends Widget {
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
if (!view) return null;
|
||||
const g = view.toGlobal({ x: view.w / 2, y: 12 });
|
||||
const g = view.toGlobal({ x: view.w / 2, y: TITLE_HEIGHT / 2 });
|
||||
return { x: g.x, y: g.y };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const CATEGORY_ORDER: NodeCategory[] = ['event', 'flow', 'variable', 'data', 'ac
|
||||
export class NodePalette extends Popup {
|
||||
private readonly search: TextField;
|
||||
private readonly list = new ListView();
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private filtered: NodeDef[] = [];
|
||||
/** Guards against a second pick per opening (e.g. Enter and a row click racing). */
|
||||
private picked = false;
|
||||
@@ -25,10 +26,9 @@ export class NodePalette extends Popup {
|
||||
},
|
||||
});
|
||||
this.content.add(this.search);
|
||||
const scroll = new ScrollContainer();
|
||||
scroll.minHeight = 260;
|
||||
scroll.setContent(this.list);
|
||||
this.content.add(scroll, 1);
|
||||
this.scroll.minHeight = 260;
|
||||
this.scroll.setContent(this.list);
|
||||
this.content.add(this.scroll, 1);
|
||||
this.list.rowHeight = Theme.rowHeight - 2;
|
||||
this.list.emptyText = 'No matching node';
|
||||
this.list.onSelect = (id) => {
|
||||
@@ -50,7 +50,7 @@ export class NodePalette extends Popup {
|
||||
.filter((d) => !query || d.label.toLowerCase().includes(query) || d.type.toLowerCase().includes(query) || d.description.toLowerCase().includes(query))
|
||||
.sort((a, b) => CATEGORY_ORDER.indexOf(a.category) - CATEGORY_ORDER.indexOf(b.category) || a.label.localeCompare(b.label));
|
||||
this.list.setItems(this.filtered.map((d) => ({ id: d.type, label: d.label, hint: d.category })));
|
||||
(this.content.items[1] as ScrollContainer | undefined)?.relayoutContent();
|
||||
this.scroll.relayoutContent();
|
||||
}
|
||||
|
||||
private pick(def: NodeDef): void {
|
||||
|
||||
@@ -6,7 +6,11 @@ import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Theme, textStyle, type TextStyleToken, Widget } from '../../ui';
|
||||
import { keyLabel } from '../inspector/fieldFactory';
|
||||
|
||||
/** Node geometry in graph units; the editor relies on it for edge anchors and hit tests. */
|
||||
/**
|
||||
* Node geometry in graph units – what the node lays itself out, draws and hit-tests with.
|
||||
* Exported as the single source of these numbers: edge anchors have to land exactly on the
|
||||
* ports drawn here.
|
||||
*/
|
||||
export const NODE_WIDTH = 176;
|
||||
export const TITLE_HEIGHT = 24;
|
||||
export const ROW_HEIGHT = 20;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { Label, Panel, ScrollContainer, Theme, ui, VStack } from '../../ui';
|
||||
import type { EditorState, Selection } from '../state';
|
||||
import { type EditorState, sameSelection, type Selection } from '../state';
|
||||
import { assetSignature, buildAssetSection, refreshAssetSection } from './assetSection';
|
||||
import type { InspectorHost } from './host';
|
||||
import { buildNodeSection, nodeSignature, refreshNodeSection } from './nodeSection';
|
||||
@@ -22,7 +22,6 @@ export class Inspector extends Panel {
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ title: 'Inspector', padding: 0, gap: 0 });
|
||||
this.minWidth = 220;
|
||||
this.host = this.emptyHost();
|
||||
this.scroll.setContent(this.host.body);
|
||||
this.content.add(this.scroll, 1);
|
||||
@@ -75,7 +74,7 @@ export class Inspector extends Panel {
|
||||
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
|
||||
private refreshValues(): void {
|
||||
const s = this.state.selection;
|
||||
if (!sameTarget(s, this.builtFor) || this.currentSignature() !== this.signature) {
|
||||
if (!sameSelection(s, this.builtFor) || this.currentSignature() !== this.signature) {
|
||||
this.rebuild();
|
||||
return;
|
||||
}
|
||||
@@ -87,12 +86,3 @@ export class Inspector extends Panel {
|
||||
if (!ok) this.rebuild();
|
||||
}
|
||||
}
|
||||
|
||||
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 && a.objectIds.join(',') === b.objectIds.join(',');
|
||||
if (a.kind === 'node' && b.kind === 'node') return a.assetId === b.assetId && a.nodeId === b.nodeId;
|
||||
return false;
|
||||
}
|
||||
@@ -2,13 +2,13 @@ import { RenameAssetCommand, ResetAssetPropertyCommand, SetAssetPropertyCommand
|
||||
import { nextVariableName } from '../../core/commands/naming';
|
||||
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../core/commands/variableCommands';
|
||||
import type { Asset, BehaviorAsset } from '../../core/model/Asset';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import { COMMON_PROPS, type PropertyDef, schemaForAsset, VARIABLE_KINDS, type VariableKind } from '../../core/properties/PropertySchema';
|
||||
import { isModified, resolveAssetProps } from '../../core/properties/resolve';
|
||||
import { Button, HStack, Label, Menu, TextField, Theme } from '../../ui';
|
||||
import { Button, HStack, Label, Menu, Spacer, TextField, Theme } from '../../ui';
|
||||
import { createField, createFieldRow, type FieldHandlers, labelledRow } from './fieldFactory';
|
||||
import { addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
const VARIABLE_KIND_LABELS: Record<VariableKind, string> = { number: 'Number', boolean: 'Boolean', text: 'Text' };
|
||||
const VARIABLE_KIND_MARKS: Record<VariableKind, string> = { number: '#', boolean: '✓', text: 'Aa' };
|
||||
@@ -71,23 +71,13 @@ function buildProps(host: InspectorHost, asset: Asset, schema: readonly Property
|
||||
|
||||
/** Previews straight into the asset while editing; the command records the value from before the preview. */
|
||||
function assetHandlers(host: InspectorHost, assetId: string, def: PropertyDef): FieldHandlers {
|
||||
let original: PropertyValue | undefined;
|
||||
return {
|
||||
onInput: (value) => {
|
||||
const target = findAsset(host.state.project, assetId);
|
||||
if (!target) return;
|
||||
if (original === undefined) original = target.props[def.key];
|
||||
target.props[def.key] = value;
|
||||
host.state.preview();
|
||||
},
|
||||
onChange: (value) => {
|
||||
const target = findAsset(host.state.project, assetId);
|
||||
if (!target) return;
|
||||
if (original !== undefined) target.props[def.key] = original;
|
||||
original = undefined;
|
||||
host.state.execute(new SetAssetPropertyCommand(assetId, def.key, value));
|
||||
},
|
||||
};
|
||||
return previewingHandlers<Asset>(host, {
|
||||
target: () => findAsset(host.state.project, assetId),
|
||||
read: (asset) => asset.props[def.key],
|
||||
write: (asset, value) => (asset.props[def.key] = value),
|
||||
// No `remove`: an asset bag is dense – loading fills the defaults, a new variable brings its own.
|
||||
commit: (value) => new SetAssetPropertyCommand(assetId, def.key, value),
|
||||
});
|
||||
}
|
||||
|
||||
/** Variables of a behavior: kind mark, name, default value, remove – plus an "add" menu. */
|
||||
@@ -126,7 +116,7 @@ function buildVariables(host: InspectorHost, asset: BehaviorAsset): void {
|
||||
);
|
||||
const field = createField(def, values[def.key] ?? def.default, assetHandlers(host, asset.id, def));
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Label(''), 1);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
row.add(new Button('×', { variant: 'ghost', width: Theme.controlHeight, onClick: () => host.state.execute(new RemoveVariableCommand(asset.id, def.key)) }));
|
||||
host.fields.set(def.key, { row, setValue: field.setValue, setModified: () => {} });
|
||||
host.body.add(row);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, TextField, Theme, ui, type Widget } from '../../ui';
|
||||
import { Button, Checkbox, ColorField, HStack, Label, Menu, NumberField, Spacer, TextField, Theme, ui, type Widget } from '../../ui';
|
||||
|
||||
export interface FieldHandlers {
|
||||
/** Live value while dragging/typing – apply as a preview, no undo entry. */
|
||||
@@ -21,10 +21,10 @@ export interface FieldRow {
|
||||
}
|
||||
|
||||
/** Width of the label column every inspector row aligns to. */
|
||||
export const FIELD_LABEL_WIDTH = 64;
|
||||
const FIELD_LABEL_WIDTH = 64;
|
||||
|
||||
/** One way of resetting or applying a value ("Reset to asset", "Apply to container", …). */
|
||||
export interface FieldAction {
|
||||
interface FieldAction {
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
||||
const { row, label } = labelledRow(def.label);
|
||||
const field = createField(def, value, handlers);
|
||||
row.add(field.widget, field.grow ? 1 : 0);
|
||||
if (!field.grow) row.add(new Label(''), 1);
|
||||
if (!field.grow) row.add(new Spacer(), 1);
|
||||
|
||||
const actions: { button: Button; glyph: string; available: FieldActions }[] = [];
|
||||
const addActions = (glyph: string, available: FieldActions): void => {
|
||||
@@ -107,7 +107,7 @@ export function createFieldRow(def: PropertyDef, value: PropertyValue, handlers:
|
||||
};
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
interface Field {
|
||||
widget: Widget;
|
||||
/** Whether the widget should take the remaining row width. */
|
||||
grow: boolean;
|
||||
|
||||
@@ -2,12 +2,12 @@ import { RemoveNodeCommand, SetNodeParamCommand } from '../../core/commands/grap
|
||||
import { edgesInto, type GraphNode, inlineInputDef, inlineInputs, nodeDef } from '../../core/graph';
|
||||
import type { Asset } from '../../core/model/Asset';
|
||||
import { isPlaceableType } from '../../core/model/AssetTypes';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
||||
import { Button, Label } from '../../ui';
|
||||
import { createFieldRow, type FieldHandlers } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
/** Structure-defining facts of a node selection: type, connected inputs, declared variables. */
|
||||
export function nodeSignature(project: Project, assetId: string, nodeId: string): string {
|
||||
@@ -83,30 +83,13 @@ function resolveParamDef(project: Project, param: PropertyDef, asset: Asset): Pr
|
||||
|
||||
/** Previews straight into the node's params; the command records the value from before the preview. */
|
||||
function paramHandlers(host: InspectorHost, assetId: string, nodeId: string, key: string): FieldHandlers {
|
||||
let original: PropertyValue | undefined;
|
||||
let had = false;
|
||||
return {
|
||||
onInput: (value) => {
|
||||
const target = currentNode(host.state.project, assetId, nodeId);
|
||||
if (!target) return;
|
||||
if (original === undefined) {
|
||||
had = target.params[key] !== undefined;
|
||||
original = target.params[key] ?? value;
|
||||
}
|
||||
target.params[key] = value;
|
||||
host.state.preview();
|
||||
},
|
||||
onChange: (value) => {
|
||||
const target = currentNode(host.state.project, assetId, nodeId);
|
||||
if (!target) return;
|
||||
if (original !== undefined) {
|
||||
if (had) target.params[key] = original;
|
||||
else delete target.params[key];
|
||||
}
|
||||
original = undefined;
|
||||
host.state.execute(new SetNodeParamCommand(assetId, nodeId, key, value));
|
||||
},
|
||||
};
|
||||
return previewingHandlers<GraphNode>(host, {
|
||||
target: () => currentNode(host.state.project, assetId, nodeId),
|
||||
read: (node) => node.params[key],
|
||||
write: (node, value) => (node.params[key] = value),
|
||||
remove: (node) => delete node.params[key],
|
||||
commit: (value) => new SetNodeParamCommand(assetId, nodeId, key, value),
|
||||
});
|
||||
}
|
||||
|
||||
function currentNode(project: Project, assetId: string, nodeId: string): GraphNode | undefined {
|
||||
|
||||
@@ -21,11 +21,12 @@ import type { Axis, PropertyValue } from '../../core/model/Properties';
|
||||
import { findAsset, findScene, type Project } from '../../core/model/Project';
|
||||
import type { SceneObject } from '../../core/model/SceneObject';
|
||||
import { COMMON_PROPS, isCommonProp, type PropertyDef, schemaForAsset } from '../../core/properties/PropertySchema';
|
||||
import { deviatesFromAsset, deviatesFromTemplate, isOverridden, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { deviatesFromAsset, deviatesFromTemplate, resolveSceneObjectProps } from '../../core/properties/resolve';
|
||||
import { Button, Menu, type MenuItem, TextField } from '../../ui';
|
||||
import { addCollider, createContainer, removeObjects } from '../actions';
|
||||
import { createFieldRow, type FieldActions, type FieldRow, labelledRow } from './fieldFactory';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
import { previewingHandlers } from './previewHandlers';
|
||||
|
||||
const POSITION_AXES: readonly Axis[] = ['x', 'y', 'z'];
|
||||
const OFFSET_AXES = ['x', 'y'] as const;
|
||||
@@ -180,32 +181,22 @@ interface ComponentSpec<V extends object> {
|
||||
|
||||
/**
|
||||
* A number row editing one component of a vector on the object (position, editor offset).
|
||||
* Scrubbing previews straight into the model; the command is built from the value before the
|
||||
* preview so undo returns there, not to the last previewed frame.
|
||||
* The row edits one axis, the command carries the whole vector: the other components come
|
||||
* from the target after the preview was taken back, so they are the pre-preview ones.
|
||||
*/
|
||||
function componentRow<V extends object>(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec<V>): void {
|
||||
let original: V | undefined;
|
||||
const withAxis = (vector: V, next: PropertyValue): V => ({ ...vector, [spec.axis]: Number(next) });
|
||||
const row = createFieldRow(
|
||||
{ key: spec.key, label: spec.label, kind: 'number', default: 0, step: 1 },
|
||||
value,
|
||||
{
|
||||
onInput: (next) => {
|
||||
const target = currentObject(host.state, sceneId, objectId);
|
||||
if (!target) return;
|
||||
if (!original) original = { ...spec.read(target) };
|
||||
spec.write(target, withAxis(spec.read(target), next));
|
||||
host.state.preview();
|
||||
},
|
||||
onChange: (next) => {
|
||||
const target = currentObject(host.state, sceneId, objectId);
|
||||
if (!target) return;
|
||||
const previous = original ?? { ...spec.read(target) };
|
||||
if (original) spec.write(target, { ...original });
|
||||
original = undefined;
|
||||
host.state.execute(spec.command(withAxis(previous, next)));
|
||||
},
|
||||
},
|
||||
previewingHandlers<SceneObject, V>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
// Copied: the snapshot outlives the scrub, the vector in the model does not.
|
||||
read: (object) => ({ ...spec.read(object) }),
|
||||
write: spec.write,
|
||||
toModel: (next, object) => withAxis(spec.read(object), next),
|
||||
commit: spec.command,
|
||||
}),
|
||||
spec.reset,
|
||||
spec.apply,
|
||||
);
|
||||
@@ -224,34 +215,17 @@ function componentRow<V extends object>(host: InspectorHost, sceneId: string, ob
|
||||
* the container is placed, never at draw time.
|
||||
*/
|
||||
function propertyRow(host: InspectorHost, sceneId: string, objectId: string, def: PropertyDef, values: Record<string, PropertyValue>): FieldRow {
|
||||
let original: PropertyValue | undefined;
|
||||
let hadOverride = false;
|
||||
const bases = () => deviations(host.state, sceneId, objectId, def.key);
|
||||
const row = createFieldRow(
|
||||
def,
|
||||
values[def.key] ?? def.default,
|
||||
{
|
||||
onInput: (value) => {
|
||||
const target = currentObject(host.state, sceneId, objectId);
|
||||
if (!target) return;
|
||||
if (original === undefined) {
|
||||
hadOverride = isOverridden(target, def.key);
|
||||
original = target.overrides[def.key] ?? values[def.key] ?? def.default;
|
||||
}
|
||||
target.overrides[def.key] = value;
|
||||
host.state.preview();
|
||||
},
|
||||
onChange: (value) => {
|
||||
const target = currentObject(host.state, sceneId, objectId);
|
||||
if (!target) return;
|
||||
if (original !== undefined) {
|
||||
if (hadOverride) target.overrides[def.key] = original;
|
||||
else delete target.overrides[def.key];
|
||||
}
|
||||
original = undefined;
|
||||
host.state.execute(new SetOverrideCommand(sceneId, objectId, def.key, value));
|
||||
},
|
||||
},
|
||||
previewingHandlers<SceneObject>(host, {
|
||||
target: () => currentObject(host.state, sceneId, objectId),
|
||||
read: (object) => object.overrides[def.key],
|
||||
write: (object, value) => (object.overrides[def.key] = value),
|
||||
remove: (object) => delete object.overrides[def.key],
|
||||
commit: (value) => new SetOverrideCommand(sceneId, objectId, def.key, value),
|
||||
}),
|
||||
() => {
|
||||
const { template, asset } = bases();
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import type { FieldHandlers } from './fieldFactory';
|
||||
import type { InspectorHost } from './host';
|
||||
|
||||
/** Where an inspector row finds the value it edits, and what committing it means. */
|
||||
export interface PreviewedValue<Target, Model = PropertyValue> {
|
||||
/** The model object holding the value, or undefined when the selection is gone – then the row does nothing. */
|
||||
target: () => Target | undefined;
|
||||
/** The stored value; undefined means the key is not set on the target at all. */
|
||||
read: (target: Target) => Model | undefined;
|
||||
write: (target: Target, value: Model) => void;
|
||||
/** Unsets the key again; needed wherever `read` can return undefined. */
|
||||
remove?: (target: Target) => void;
|
||||
/** The widget value as the model stores it (default: unchanged). Called on the target as it is at that moment. */
|
||||
toModel?: (input: PropertyValue, target: Target) => Model;
|
||||
commit: (value: Model) => Command;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row handlers that preview into the model while editing and take that preview back before
|
||||
* committing.
|
||||
*
|
||||
* Scrubbing has to be visible at once, so the value goes straight into the model and is only
|
||||
* redrawn (`preview`), without an undo entry. A command records the value it finds at execute
|
||||
* time, so committing right away would make the last previewed frame the undo target: the
|
||||
* value from before the preview is written back first – including "the key did not exist",
|
||||
* via `remove` – and only then is the command executed.
|
||||
*/
|
||||
export function previewingHandlers<Target, Model = PropertyValue>(host: InspectorHost, spec: PreviewedValue<Target, Model>): FieldHandlers {
|
||||
const toModel = spec.toModel ?? ((input: PropertyValue) => input as unknown as Model);
|
||||
let previewed = false;
|
||||
let before: Model | undefined;
|
||||
return {
|
||||
onInput: (input) => {
|
||||
const target = spec.target();
|
||||
if (!target) return;
|
||||
if (!previewed) {
|
||||
previewed = true;
|
||||
before = spec.read(target);
|
||||
}
|
||||
spec.write(target, toModel(input, target));
|
||||
host.state.preview();
|
||||
},
|
||||
onChange: (input) => {
|
||||
const target = spec.target();
|
||||
if (!target) return;
|
||||
if (previewed) {
|
||||
if (before === undefined) spec.remove?.(target);
|
||||
else spec.write(target, before);
|
||||
}
|
||||
previewed = false;
|
||||
before = undefined;
|
||||
// Built after the restore: `toModel` may fill the rest of a compound value from the target.
|
||||
host.state.execute(spec.commit(toModel(input, target)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { CreateAssetCommand, DeleteAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
|
||||
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
|
||||
import { nextAssetName } from '../../core/commands/naming';
|
||||
import { PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, hasColorSwatch } 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 { findAsset } from '../../core/model/Project';
|
||||
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer } from '../../ui';
|
||||
import { confirmDeleteAsset } from '../actions';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
|
||||
/** Lists all assets of the project; creates, places and deletes them. */
|
||||
@@ -84,25 +85,7 @@ export class AssetPanel extends Panel {
|
||||
} else {
|
||||
items.push({ label: 'Place in scene', onSelect: () => this.place(id), disabled: !this.state.activeScene });
|
||||
}
|
||||
items.push({ label: 'Delete…', danger: true, onSelect: () => this.confirmDelete(id) });
|
||||
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
||||
new Menu(items).open(x, y);
|
||||
}
|
||||
|
||||
private confirmDelete(id: string): void {
|
||||
const asset = findAsset(this.state.project, id);
|
||||
if (!asset) return;
|
||||
const usages = findUsages(this.state.project, id).length;
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: 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, [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => this.state.execute(new DeleteAssetCommand(id)) },
|
||||
]).openCentered();
|
||||
}
|
||||
}
|
||||
+3
-13
@@ -1,8 +1,6 @@
|
||||
import { DeleteAssetCommand } from '../core/commands/assetCommands';
|
||||
import { RemoveNodeCommand } from '../core/commands/graphCommands';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { Dialog, ui } from '../ui';
|
||||
import { createContainer, removeObjects } from './actions';
|
||||
import { ui } from '../ui';
|
||||
import { confirmDeleteAsset, createContainer, removeObjects } from './actions';
|
||||
import type { EditorState } from './state';
|
||||
|
||||
export interface ShortcutActions {
|
||||
@@ -40,15 +38,7 @@ export function installShortcuts(state: EditorState, actions: ShortcutActions):
|
||||
const s = state.selection;
|
||||
if (s?.kind === 'object') removeObjects(state, s.sceneId, s.objectIds);
|
||||
else if (s?.kind === 'node') state.execute(new RemoveNodeCommand(s.assetId, s.nodeId));
|
||||
else if (s?.kind === 'asset') {
|
||||
const asset = findAsset(state.project, s.assetId);
|
||||
if (asset) {
|
||||
new Dialog(`Delete "${asset.name}"?`, 'Placed instances are removed as well.', [
|
||||
{ label: 'Cancel' },
|
||||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(s.assetId)) },
|
||||
]).openCentered();
|
||||
}
|
||||
}
|
||||
else if (s?.kind === 'asset') confirmDeleteAsset(state, s.assetId);
|
||||
return s !== null;
|
||||
}
|
||||
if (mod && event.key === 'Enter' && state.mode === 'edit') {
|
||||
|
||||
+23
-12
@@ -136,8 +136,28 @@ export class EditorState {
|
||||
* In play mode the command changes the runtime copy only (see `enterPlay`).
|
||||
*/
|
||||
execute(command: Command): boolean {
|
||||
try {
|
||||
return this.runEdit(() => {
|
||||
this.commands.execute(command);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
this.runEdit(() => this.commands.undo());
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.runEdit(() => this.commands.redo());
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one history operation. A `CommandError` is the documented way for a command to
|
||||
* reject an edit, so it becomes a notice; anything else is a real defect and keeps
|
||||
* travelling. Undo and redo go through here too – they can reject just like `execute`.
|
||||
*/
|
||||
private runEdit(operation: () => boolean): boolean {
|
||||
try {
|
||||
if (!operation()) return false;
|
||||
} catch (error) {
|
||||
if (error instanceof CommandError) {
|
||||
this.setNotice(error.message);
|
||||
@@ -149,16 +169,6 @@ export class EditorState {
|
||||
return true;
|
||||
}
|
||||
|
||||
undo(): void {
|
||||
if (!this.commands.undo()) return;
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
if (!this.commands.redo()) return;
|
||||
this.markDirty();
|
||||
}
|
||||
|
||||
/**
|
||||
* The model was mutated directly (live preview while scrubbing). Notifies listeners
|
||||
* but does not touch the undo stack or the save status – the committing command does.
|
||||
@@ -246,7 +256,8 @@ export class EditorState {
|
||||
}
|
||||
}
|
||||
|
||||
function sameSelection(a: Selection, b: Selection): boolean {
|
||||
/** Whether two selections mean the same thing (same kind, same ids, same order). */
|
||||
export 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;
|
||||
|
||||
+5
-12
@@ -4,6 +4,7 @@ import type { Asset } from '../core/model/Asset';
|
||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||
import { instantiateContainer } from '../core/model/container';
|
||||
import { findObject } from '../core/model/hierarchy';
|
||||
import { descendantsBelow } from '../core/model/walk';
|
||||
import { findAsset, findScene, type Project } from '../core/model/Project';
|
||||
import { createSceneObject, type SceneObject } from '../core/model/SceneObject';
|
||||
import { footprintContains } from '../core/properties/bounds';
|
||||
@@ -71,7 +72,7 @@ export class World {
|
||||
const keep = new Set<Entity>();
|
||||
for (const object of scene.objects) {
|
||||
const asset = findAsset(this.project, object.assetId);
|
||||
if (!asset || asset.type === 'scene') continue;
|
||||
if (!asset || !isPlaceableType(asset.type)) continue;
|
||||
keep.add(this.materialize(object, asset));
|
||||
}
|
||||
for (let i = this.entities.length - 1; i >= 0; i--) if (!keep.has(this.entities[i]!)) this.entities.splice(i, 1);
|
||||
@@ -121,7 +122,7 @@ export class World {
|
||||
entity.parent = parentEntity;
|
||||
for (const child of created.slice(1)) {
|
||||
const childAsset = findAsset(this.project, child.assetId);
|
||||
if (!childAsset || childAsset.type === 'scene') continue;
|
||||
if (!childAsset || !isPlaceableType(childAsset.type)) continue;
|
||||
this.materialize(child, childAsset).parent = this.findByObjectId(child.parentId) ?? null;
|
||||
}
|
||||
this.structureChanged = true;
|
||||
@@ -144,7 +145,7 @@ export class World {
|
||||
for (let i = scene.objects.length - 1; i >= 0; i--) {
|
||||
const object = scene.objects[i]!;
|
||||
const asset = findAsset(this.project, object.assetId);
|
||||
if (asset && asset.type !== 'scene' && !alive.has(object.id)) scene.objects.splice(i, 1);
|
||||
if (asset && isPlaceableType(asset.type) && !alive.has(object.id)) scene.objects.splice(i, 1);
|
||||
}
|
||||
for (const entity of this.entities) {
|
||||
const object = findObject(scene, entity.objectId);
|
||||
@@ -219,15 +220,7 @@ export class World {
|
||||
|
||||
/** Everything parented below an entity, at any depth. */
|
||||
descendantsOf(entity: Entity): Entity[] {
|
||||
const result: Entity[] = [];
|
||||
const visit = (parent: Entity) => {
|
||||
for (const child of this.childrenOf(parent)) {
|
||||
result.push(child);
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(entity);
|
||||
return result;
|
||||
return descendantsBelow(entity, (parent) => this.childrenOf(parent), (child) => child);
|
||||
}
|
||||
|
||||
findByObjectId(objectId: string): Entity | undefined {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isHandleEnabled, worldPositionOf } from '../../core/graph';
|
||||
import { parentChain } from '../../core/model/walk';
|
||||
import { axisCorrection, colliderShape, colliderSize, isTrigger, type PlaneAxis, shapesOverlap } from '../../core/properties/collider';
|
||||
import type { Entity } from '../Entity';
|
||||
import type { World } from '../World';
|
||||
@@ -136,7 +137,7 @@ function penetration(a: Entity, b: Entity, axis: PlaneAxis, dir: 1 | -1): number
|
||||
|
||||
/** Whether `collider` belongs to `entity` or to something parented (at any depth) under it. */
|
||||
function travelsWith(collider: Entity, entity: Entity): boolean {
|
||||
for (let e: Entity | null = collider; e; e = e.parent) if (e === entity) return true;
|
||||
for (const e of parentChain(collider, (c) => c.parent)) if (e === entity) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -159,7 +160,7 @@ export function bodyOf(collider: Entity): Entity {
|
||||
}
|
||||
|
||||
/** A collider stands for itself; any other entity stands for its direct collider children. */
|
||||
export function collidersOf(world: World, entity: Entity): Entity[] {
|
||||
function collidersOf(world: World, entity: Entity): Entity[] {
|
||||
if (entity.type === 'collider') return [entity];
|
||||
return world.childrenOf(entity).filter((e) => e.type === 'collider' && isHandleEnabled(e));
|
||||
}
|
||||
@@ -37,12 +37,9 @@ export const Theme = {
|
||||
accentText: 0xffffff,
|
||||
text: 0xe6e8ee,
|
||||
textMuted: 0x8a90a0,
|
||||
textDisabled: 0x5a5f6d,
|
||||
selection: 0x2b4a80,
|
||||
override: 0xf0b13d,
|
||||
danger: 0xe0553f,
|
||||
viewport: 0x121316,
|
||||
grid: 0x1f2126,
|
||||
gizmo: 0x4c8dff,
|
||||
/** Behavior symbols and owner links. */
|
||||
behavior: 0xb066ff,
|
||||
|
||||
@@ -19,3 +19,36 @@ export function drawControlBox(g: Graphics, w: number, h: number, options: Contr
|
||||
g.roundRect(x, y, w, h, radius).fill(fill);
|
||||
g.roundRect(x + 0.5, y + 0.5, w - 1, h - 1, radius).stroke({ width: 1, color: border });
|
||||
}
|
||||
|
||||
export interface GridOptions {
|
||||
/** Where the origin of the drawn space sits on screen, in the same pixels as `w`/`h`. */
|
||||
originX: number;
|
||||
originY: number;
|
||||
/** Spacing in screen pixels (world/graph step times zoom). */
|
||||
step: number;
|
||||
w: number;
|
||||
h: number;
|
||||
alpha: number;
|
||||
/** Alpha of the two lines through the origin; omitted = no axes (the graph has no origin to show). */
|
||||
axisAlpha?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The background grid of the scene view and the graph editor. Drawn in white at a low alpha
|
||||
* rather than in a fixed colour, so it reads on whatever background the scene sets.
|
||||
* `g` is cleared by the caller, which also decides when the step is too dense to be useful.
|
||||
*/
|
||||
export function drawGrid(g: Graphics, options: GridOptions): void {
|
||||
const { originX, originY, step, w, h, alpha, axisAlpha } = options;
|
||||
const startX = ((originX % step) + step) % step;
|
||||
const startY = ((originY % step) + step) % step;
|
||||
for (let x = startX; x < w; x += step) g.moveTo(x, 0).lineTo(x, h);
|
||||
for (let y = startY; y < h; y += step) g.moveTo(0, y).lineTo(w, y);
|
||||
g.stroke({ width: 1, color: GRID_COLOR, alpha });
|
||||
if (axisAlpha === undefined) return;
|
||||
const axis = { width: 1, color: GRID_COLOR, alpha: axisAlpha };
|
||||
if (originX >= 0 && originX <= w) g.moveTo(originX, 0).lineTo(originX, h).stroke(axis);
|
||||
if (originY >= 0 && originY <= h) g.moveTo(0, originY).lineTo(w, originY).stroke(axis);
|
||||
}
|
||||
|
||||
const GRID_COLOR = 0xffffff;
|
||||
+2
-2
@@ -2,8 +2,8 @@ export { Widget, Box, type Size } from './Widget';
|
||||
export { HStack, VStack, Spacer, Stack, type StackOptions } from './layout/Stack';
|
||||
export { Theme, textStyle, type TextStyleToken } from './Theme';
|
||||
export { initUI, ui, type UIContext } from './UIContext';
|
||||
export { drawControlBox, type ControlBoxOptions } from './draw';
|
||||
export { clamp, centreY } from './math';
|
||||
export { drawControlBox, drawGrid, type ControlBoxOptions, type GridOptions } from './draw';
|
||||
export { clamp, centreY, zoomFactor } from './math';
|
||||
export { Label } from './widgets/Label';
|
||||
export { Button } from './widgets/Button';
|
||||
export { Checkbox } from './widgets/Checkbox';
|
||||
|
||||
@@ -7,3 +7,12 @@ export function clamp(value: number, lo: number, hi: number): number {
|
||||
export function centreY(containerHeight: number, itemHeight: number): number {
|
||||
return Math.round((containerHeight - itemHeight) / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom multiplier for one wheel event. Exponential, so scrolling up and back down by the same
|
||||
* amount lands exactly where it started. The caller clamps the result into its own range –
|
||||
* the scene view and the graph editor allow very different zoom levels.
|
||||
*/
|
||||
export function zoomFactor(deltaY: number): number {
|
||||
return Math.exp(-deltaY * 0.0015);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* The playing view: the running world seen through the scene's camera. The camera's frame
|
||||
* defines what is visible and is the only thing drawn – it is letterboxed into the widget and
|
||||
* everything outside it is masked away. Without a world or without a camera nothing renders.
|
||||
*
|
||||
* Nothing here edits: the view reports the pointer to the world (so graphs can use Mouse
|
||||
* position, Is hovered and On click) and otherwise only draws.
|
||||
*/
|
||||
import { type FederatedPointerEvent, Text } from 'pixi.js';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
import { textStyle } from '../ui';
|
||||
import { collectWorldItems } from './items';
|
||||
import { cameraView, isInside, screenToWorld, type View } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
export class GameViewport extends WorldViewport {
|
||||
/** Shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
private playWorld: World | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
this.hint.eventMode = 'none';
|
||||
this.hint.anchor.set(0.5);
|
||||
this.addChild(this.hint);
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
this.reportPointer(e);
|
||||
this.playWorld?.setPointerDown(true);
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.reportPointer(e));
|
||||
this.on('pointerup', () => this.playWorld?.setPointerDown(false));
|
||||
this.on('pointerupoutside', () => this.playWorld?.setPointerDown(false));
|
||||
}
|
||||
|
||||
/** The world to draw (null = not playing). */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override currentView(): View | null {
|
||||
const camera = this.playWorld?.firstOfType('camera');
|
||||
return camera ? cameraView(camera, this.w, this.h) : null;
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
// Only reached with a view, which requires a world with a camera.
|
||||
return this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR };
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return LETTERBOX_COLOR;
|
||||
}
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.hint.visible = !this.playWorld;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands the mouse position to the running world in world units. Outside the camera frame
|
||||
* counts as "not in view" – that region is letterbox, not game.
|
||||
*/
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
if (!world || !view) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = screenToWorld(view, local.x, local.y);
|
||||
world.movePointer(point.x, point.y, isInside(view, local.x, local.y));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* The editable view of a scene: the model drawn with a grid, selection outlines, drag-to-move
|
||||
* (all selected objects at once), rubber-band selection on empty space, Ctrl-click to add or
|
||||
* remove, middle-drag or Shift-drag panning and wheel zoom.
|
||||
*
|
||||
* It shows `state.project` – in play mode that is the runtime copy, so the live positions are
|
||||
* visible and stay fully editable.
|
||||
*/
|
||||
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../core/model/hierarchy';
|
||||
import type { Position, Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf } from '../core/properties/bounds';
|
||||
import { type EditorState, objectSelection } from '../editor/state';
|
||||
import { clamp, drawGrid, type Size, Theme, textStyle, zoomFactor } from '../ui';
|
||||
import { collectSceneItems, type DrawItem } from './items';
|
||||
import { dashedLine } from './symbols';
|
||||
import { freeView, screenToWorld, type View, worldToScreen } from './view';
|
||||
import { type Frame, WorldViewport } from './WorldViewport';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
/** Pixels the pointer must travel before a press on an object starts moving it. */
|
||||
const DRAG_THRESHOLD = 2;
|
||||
const GRID_STEP = 100;
|
||||
/** Below this on-screen spacing the grid is noise rather than orientation, so it is dropped. */
|
||||
const MIN_GRID_STEP = 12;
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 8;
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
export class SceneViewport extends WorldViewport {
|
||||
private readonly grid = new Graphics();
|
||||
/** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */
|
||||
private readonly links = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
/** 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: ObjectDrag | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state);
|
||||
// Grid behind the content, links below the symbols, gizmos above everything clipped.
|
||||
this.addChildAt(this.grid, 1);
|
||||
this.world.addChildAt(this.links, 1);
|
||||
this.content.addChild(this.gizmos);
|
||||
this.links.eventMode = 'none';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('load', () => {
|
||||
this.view = { x: 0, y: 0, zoom: 1 };
|
||||
this.redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the view (used to place assets). */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
|
||||
// --- the view -------------------------------------------------------------------------------
|
||||
|
||||
protected override currentView(): View {
|
||||
return freeView(this.view.x, this.view.y, this.view.zoom, this.w, this.h);
|
||||
}
|
||||
|
||||
protected override collectFrame(): Frame {
|
||||
return collectSceneItems(this.state, Theme.colors.viewport);
|
||||
}
|
||||
|
||||
protected override get baseColor(): number {
|
||||
return Theme.colors.viewport;
|
||||
}
|
||||
|
||||
/** Without a scene there is nothing to frame – the neutral base colour stands alone. */
|
||||
protected override paintsBackground(): boolean {
|
||||
return Boolean(this.state.activeScene);
|
||||
}
|
||||
|
||||
protected override get itemsInteractive(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- pointer --------------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.panning) {
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
|
||||
this.redraw();
|
||||
} else if (this.dragging) {
|
||||
const local = this.toLocal(e.global);
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
const dy = (local.y - this.dragging.pointer.y) / this.view.zoom;
|
||||
if (!this.dragging.moved && Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
|
||||
this.dragging.moved = true;
|
||||
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).
|
||||
for (const item of this.dragging.items) {
|
||||
const object = findObject(scene, 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();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
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 scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (!object) continue;
|
||||
const next = { ...object.position };
|
||||
object.position = { ...item.start };
|
||||
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
const local = this.toLocal(e.global);
|
||||
const before = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.zoom = clamp(this.view.zoom * zoomFactor(e.deltaY), MIN_ZOOM, MAX_ZOOM);
|
||||
const after = screenToWorld(this.currentView(), local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene && findObject(scene, objectId);
|
||||
if (!scene || !object) return;
|
||||
e.stopPropagation();
|
||||
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 items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, 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 = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn position lies inside it. */
|
||||
private selectInMarquee(marquee: Marquee): void {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
const view = this.currentView();
|
||||
const a = screenToWorld(view, marquee.start.x, marquee.start.y);
|
||||
const b = screenToWorld(view, 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.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
|
||||
this.state.select(objectSelection(scene.id, ids));
|
||||
}
|
||||
|
||||
// --- decorations ----------------------------------------------------------------------------
|
||||
|
||||
protected override beforeItems(): void {
|
||||
this.syncGrid();
|
||||
this.gizmos.clear();
|
||||
}
|
||||
|
||||
protected override afterItems(items: DrawItem[], view: View): void {
|
||||
this.drawParentLinks(items, view.scale);
|
||||
this.drawGizmos(items, view);
|
||||
}
|
||||
|
||||
protected override syncDecorations(item: DrawItem, onScreen: boolean, scale: number, size: Size): void {
|
||||
this.syncLabel(item, onScreen, scale, size);
|
||||
}
|
||||
|
||||
protected override disposeDecorations(id: string): void {
|
||||
this.labels.get(id)?.destroy();
|
||||
this.labels.delete(id);
|
||||
}
|
||||
|
||||
private syncGrid(): void {
|
||||
this.grid.clear();
|
||||
const step = GRID_STEP * this.view.zoom;
|
||||
if (step < MIN_GRID_STEP) return;
|
||||
const origin = worldToScreen(this.currentView(), 0, 0);
|
||||
drawGrid(this.grid, { originX: origin.x, originY: origin.y, step, w: this.w, h: this.h, alpha: 0.05, axisAlpha: 0.15 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol (behavior, camera) to its parent. Sprites show
|
||||
* their nesting in the hierarchy panel instead, to keep the scene uncluttered; colliders sit
|
||||
* on their body anyway, so they get no line either.
|
||||
*/
|
||||
private drawParentLinks(items: DrawItem[], scale: number): void {
|
||||
this.links.clear();
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
for (const item of items) {
|
||||
if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||||
const parent = byId.get(item.parentId);
|
||||
if (!parent || !parent.enabled) continue;
|
||||
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
|
||||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
||||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
/** Name label next to a symbol. */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: Size): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
if (label) label.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
label = new Text({ text: '', style: textStyle('small') });
|
||||
label.eventMode = 'none';
|
||||
this.labels.set(item.id, label);
|
||||
this.overlay.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
label.text = item.name;
|
||||
label.scale.set(1 / scale);
|
||||
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
|
||||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||
}
|
||||
|
||||
/** 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?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
const tl = worldToScreen(view, item.x - w / 2, item.y - h / 2);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
import { Container, type FederatedPointerEvent, type FederatedWheelEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { CompoundCommand } from '../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand } from '../core/commands/sceneObjectCommands';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../core/model/hierarchy';
|
||||
import { type Position, sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf, textAnchor } from '../core/properties/bounds';
|
||||
import { cameraFrame, rectAround, rectsIntersect, type WorldRect } from '../core/properties/camera';
|
||||
import { type EditorState, objectSelection } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
import { type Size, Theme, textStyle, Widget } from '../ui';
|
||||
import { collectSceneItems, collectWorldItems, type DrawItem } from './items';
|
||||
import { dashedLine, drawItem } from './symbols';
|
||||
|
||||
/** Pixels the pointer must travel before a press on empty space becomes a rubber band. */
|
||||
const MARQUEE_THRESHOLD = 3;
|
||||
const GRID_STEP = 100;
|
||||
const LETTERBOX_COLOR = 0x000000;
|
||||
|
||||
/**
|
||||
* How world coordinates map onto the viewport: the world point (`x`, `y`) sits at the
|
||||
* centre of the screen rectangle `rect` (viewport-local pixels) at `scale` pixels per
|
||||
* world unit. In the scene view the rectangle is the whole viewport; in the game view it is
|
||||
* the camera's frame fitted into the viewport (letterboxed).
|
||||
*/
|
||||
interface View {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
export interface ViewportOptions {
|
||||
/**
|
||||
* Game view: draws the running world through the scene's camera, no editing. The default
|
||||
* (scene view) draws the model – in play mode that is the runtime copy, so it shows the
|
||||
* live positions and stays fully editable.
|
||||
*/
|
||||
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
|
||||
* 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;
|
||||
private readonly bg = new Graphics();
|
||||
private readonly grid = new Graphics();
|
||||
/** Game view only: shown while no world is running. */
|
||||
private readonly hint = new Text({ text: 'Press ▶ Play to run the scene here', style: textStyle('muted') });
|
||||
/** Everything that may extend past the viewport (world and gizmos) is clipped through `content`. */
|
||||
private readonly content = new Container();
|
||||
private readonly world = new Container();
|
||||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||
private readonly objects = new Container();
|
||||
/**
|
||||
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can never
|
||||
* hide them, no matter what z-order the scene ends up with.
|
||||
*/
|
||||
private readonly overlay = new Container();
|
||||
/** Dashed parent links (behavior → owner), drawn in world units beneath the symbols. */
|
||||
private readonly links = new Graphics();
|
||||
private readonly contentMask = new Graphics();
|
||||
private readonly gizmos = new Graphics();
|
||||
private readonly nodes = new Map<string, Graphics>();
|
||||
private readonly labels = new Map<string, Text>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private playWorld: World | null = null;
|
||||
/** Scene 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: 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();
|
||||
this.game = options.game ?? false;
|
||||
this.world.addChild(this.objects, this.links, this.overlay);
|
||||
// Decorations must not swallow hits meant for the objects beneath them.
|
||||
this.links.eventMode = 'none';
|
||||
this.hint.eventMode = 'none';
|
||||
this.hint.anchor.set(0.5);
|
||||
this.content.addChild(this.world, this.gizmos);
|
||||
this.addChild(this.bg, this.grid, this.content, this.contentMask, this.hint);
|
||||
this.content.mask = this.contentMask;
|
||||
this.cursor = 'default';
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => this.onPointerDown(e));
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => this.onPointerMove(e));
|
||||
this.on('pointerup', () => this.onPointerUp());
|
||||
this.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.on('wheel', (e: FederatedWheelEvent) => this.onWheel(e));
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('load', () => { this.view = { x: 0, y: 0, zoom: 1 }; this.redraw(); });
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('mode', () => this.redraw());
|
||||
// Every tick moves things: redraw the live view when it is on screen.
|
||||
state.on('runtime', () => { if (this.visible) this.redraw(); });
|
||||
}
|
||||
|
||||
/** Game view: the world to draw (null = not playing). Ignored by the scene view. */
|
||||
setWorld(world: World | null): void {
|
||||
this.playWorld = world;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** World coordinates at the centre of the current scene view (used to place assets). */
|
||||
worldCenter(): Vec2 {
|
||||
return { x: Math.round(this.view.x), y: Math.round(this.view.y) };
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** Whether anything is rendered and how many items are on screen. */
|
||||
debugInfo(): { rendering: boolean; visibleItems: number } {
|
||||
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
||||
}
|
||||
|
||||
/** Which layer an item is drawn in: symbols live in `overlay`, above all `objects`. */
|
||||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||||
const node = this.nodes.get(objectId);
|
||||
if (!node) return null;
|
||||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||||
}
|
||||
|
||||
/** Object ids in the order they are drawn, back to front. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || !view) return null;
|
||||
const local = this.worldToScreen(item.x, item.y, view);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- pointer ---------------------------------------------------------------------------------
|
||||
|
||||
private onPointerDown(e: FederatedPointerEvent): void {
|
||||
if (this.game) {
|
||||
if (e.button === 0) {
|
||||
this.reportPointer(e);
|
||||
this.playWorld?.setPointerDown(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.button === 1 || (e.button === 0 && e.target === this && e.shiftKey)) {
|
||||
this.panning = { startX: e.global.x, startY: e.global.y, viewX: this.view.x, viewY: this.view.y };
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerMove(e: FederatedPointerEvent): void {
|
||||
if (this.game) {
|
||||
this.reportPointer(e);
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
this.view.x = this.panning.viewX - (e.global.x - this.panning.startX) / this.view.zoom;
|
||||
this.view.y = this.panning.viewY - (e.global.y - this.panning.startY) / this.view.zoom;
|
||||
this.redraw();
|
||||
} else if (this.dragging) {
|
||||
const local = this.toLocal(e.global);
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
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 scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// A world-space delta is a local-space delta as well (no rotation/scale in the hierarchy).
|
||||
for (const item of this.dragging.items) {
|
||||
const object = findObject(scene, 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();
|
||||
}
|
||||
}
|
||||
|
||||
private onPointerUp(): void {
|
||||
if (this.game) {
|
||||
this.playWorld?.setPointerDown(false);
|
||||
return;
|
||||
}
|
||||
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 scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
// The preview moved the objects already; put them back so the command records the start.
|
||||
const moves: MoveSceneObjectCommand[] = [];
|
||||
for (const item of drag.items) {
|
||||
const object = findObject(scene, item.objectId);
|
||||
if (!object) continue;
|
||||
const next = { ...object.position };
|
||||
object.position = { ...item.start };
|
||||
moves.push(new MoveSceneObjectCommand(drag.sceneId, item.objectId, next));
|
||||
}
|
||||
if (moves.length === 1) this.state.execute(moves[0]!);
|
||||
else if (moves.length > 1) this.state.execute(new CompoundCommand('Move objects', moves));
|
||||
}
|
||||
|
||||
private onWheel(e: FederatedWheelEvent): void {
|
||||
if (this.game) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const before = this.screenToWorld(local.x, local.y);
|
||||
const factor = Math.exp(-e.deltaY * 0.0015);
|
||||
this.view.zoom = Math.max(0.1, Math.min(8, this.view.zoom * factor));
|
||||
const after = this.screenToWorld(local.x, local.y);
|
||||
this.view.x += before.x - after.x;
|
||||
this.view.y += before.y - after.y;
|
||||
this.redraw();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
private onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (this.game || e.button !== 0) return;
|
||||
const scene = this.state.activeScene;
|
||||
const object = scene && findObject(scene, objectId);
|
||||
if (!scene || !object) return;
|
||||
e.stopPropagation();
|
||||
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 items = subtreeRoots(scene, this.state.selectedObjectIds)
|
||||
.map((id) => findObject(scene, 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 = { sceneId: scene.id, items, pointer: { x: local.x, y: local.y }, moved: false };
|
||||
}
|
||||
|
||||
/** Rubber band released: selects every object whose drawn 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.enabled && i.x >= minX && i.x <= maxX && i.y >= minY && i.y <= maxY).map((i) => i.id);
|
||||
this.state.select(objectSelection(scene.id, ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Game view: hands the mouse position to the running world in world units, so graphs can
|
||||
* use it (Mouse position, Is hovered, On click). Outside the camera frame counts as "not
|
||||
* in view" – that region is letterbox, not game.
|
||||
*/
|
||||
private reportPointer(e: FederatedPointerEvent): void {
|
||||
const world = this.playWorld;
|
||||
const view = this.currentView();
|
||||
if (!world || !view) return;
|
||||
const local = this.toLocal(e.global);
|
||||
const point = this.screenToWorld(local.x, local.y, view);
|
||||
const inside = local.x >= view.rect.x && local.x <= view.rect.x + view.rect.w && local.y >= view.rect.y && local.y <= view.rect.y + view.rect.h;
|
||||
world.movePointer(point.x, point.y, inside);
|
||||
}
|
||||
|
||||
// --- transforms ------------------------------------------------------------------------------
|
||||
|
||||
private editView(): View {
|
||||
return { x: this.view.x, y: this.view.y, scale: this.view.zoom, rect: { x: 0, y: 0, w: this.w, h: this.h } };
|
||||
}
|
||||
|
||||
/** The active mapping, or null when the game view has no world or no camera (nothing to show). */
|
||||
private currentView(): View | null {
|
||||
if (!this.game) return this.editView();
|
||||
const camera = this.playWorld?.firstOfType('camera');
|
||||
if (!camera) return null;
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(this.w / frame.w, this.h / frame.h);
|
||||
const w = frame.w * scale;
|
||||
const h = frame.h * scale;
|
||||
return { x: camera.x, y: camera.y, scale, rect: { x: (this.w - w) / 2, y: (this.h - h) / 2, w, h } };
|
||||
}
|
||||
|
||||
/** World region covered by a view. */
|
||||
private worldBounds(view: View): WorldRect {
|
||||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||||
}
|
||||
|
||||
private screenToWorld(sx: number, sy: number, view: View = this.editView()): Vec2 {
|
||||
const cx = view.rect.x + view.rect.w / 2;
|
||||
const cy = view.rect.y + view.rect.h / 2;
|
||||
return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y };
|
||||
}
|
||||
|
||||
private worldToScreen(wx: number, wy: number, view: View = this.editView()): Vec2 {
|
||||
const cx = view.rect.x + view.rect.w / 2;
|
||||
const cy = view.rect.y + view.rect.h / 2;
|
||||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||||
}
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
const editing = !this.game;
|
||||
const view = this.currentView();
|
||||
|
||||
// Base fill: letterbox black in the game view, neutral in the scene view without a scene.
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(editing ? Theme.colors.viewport : LETTERBOX_COLOR);
|
||||
this.drawGrid(editing);
|
||||
this.gizmos.clear();
|
||||
this.contentMask.clear();
|
||||
this.content.visible = view !== null;
|
||||
this.hint.visible = this.game && !this.playWorld;
|
||||
this.hint.position.set(this.w / 2, this.h / 2);
|
||||
this.visibleCount = 0;
|
||||
if (!view) return;
|
||||
|
||||
const { items, background } = this.game
|
||||
? this.playWorld ? collectWorldItems(this.playWorld) : { items: [], background: LETTERBOX_COLOR }
|
||||
: collectSceneItems(this.state, Theme.colors.viewport);
|
||||
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);
|
||||
|
||||
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
|
||||
this.world.scale.set(view.scale);
|
||||
const bounds = this.worldBounds(view);
|
||||
|
||||
// Sync display objects with items; anything outside the view is neither drawn nor hit-testable.
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
seen.add(item.id);
|
||||
const size = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
node = new Graphics();
|
||||
node.label = item.id;
|
||||
node.cursor = 'move';
|
||||
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||
this.nodes.set(item.id, node);
|
||||
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
|
||||
}
|
||||
node.visible = onScreen;
|
||||
node.eventMode = editing && onScreen ? 'static' : 'none';
|
||||
this.syncLabel(item, editing && onScreen, view.scale, size);
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
node.position.set(item.x, item.y);
|
||||
drawItem(node, item, size, view.scale);
|
||||
}
|
||||
this.drawParentLinks(items, editing, view.scale);
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (seen.has(id)) continue;
|
||||
this.texts.get(id)?.destroy();
|
||||
this.texts.delete(id);
|
||||
node.destroy();
|
||||
this.nodes.delete(id);
|
||||
this.labels.get(id)?.destroy();
|
||||
this.labels.delete(id);
|
||||
}
|
||||
this.syncDrawOrder(items);
|
||||
|
||||
if (editing) this.drawGizmos(items, view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
|
||||
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
|
||||
* deterministic and hierarchy changes are honoured too.
|
||||
*/
|
||||
private syncDrawOrder(items: DrawItem[]): void {
|
||||
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
||||
this.objects.removeChildren();
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashed line from every parented symbol (behavior, camera) to its parent (edit mode only).
|
||||
* Sprites show their nesting in the hierarchy panel instead, to keep the scene uncluttered;
|
||||
* colliders sit on their body anyway, so they get no line either.
|
||||
*/
|
||||
private drawParentLinks(items: DrawItem[], editing: boolean, scale: number): void {
|
||||
this.links.clear();
|
||||
if (!editing) return;
|
||||
const byId = new Map(items.map((i) => [i.id, i]));
|
||||
for (const item of items) {
|
||||
if (!isSymbolType(item.type) || !item.parentId || item.type === 'collider' || !item.enabled) continue;
|
||||
const parent = byId.get(item.parentId);
|
||||
if (!parent || !parent.enabled) continue;
|
||||
const color = item.type === 'behavior' ? Theme.colors.behavior : Theme.colors.textMuted;
|
||||
dashedLine(this.links, item.x, item.y, parent.x, parent.y, 8 / scale, 6 / scale);
|
||||
this.links.stroke({ width: 1.5 / scale, color, alpha: 0.8 });
|
||||
this.links.circle(parent.x, parent.y, 3 / scale).fill(color);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
if (text) text.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
|
||||
text.eventMode = 'none';
|
||||
this.texts.set(item.id, text);
|
||||
this.objects.addChild(text);
|
||||
}
|
||||
text.visible = true;
|
||||
text.position.set(item.x, item.y);
|
||||
text.text = String(item.props.text ?? '');
|
||||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||||
if (text.style.fontSize !== size) text.style.fontSize = size;
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
if (text.style.fill !== color) text.style.fill = color;
|
||||
text.anchor.set(textAnchor(item.props), 0.5);
|
||||
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
|
||||
if (text.resolution !== resolution) text.resolution = resolution;
|
||||
}
|
||||
|
||||
/** Name label next to a symbol (scene view only). */
|
||||
private syncLabel(item: DrawItem, show: boolean, scale: number, size: { w: number; h: number }): void {
|
||||
let label = this.labels.get(item.id);
|
||||
if (!show || !isSymbolType(item.type)) {
|
||||
if (label) label.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!label) {
|
||||
label = new Text({ text: '', style: textStyle('small') });
|
||||
label.eventMode = 'none';
|
||||
this.labels.set(item.id, label);
|
||||
this.overlay.addChild(label);
|
||||
}
|
||||
label.visible = true;
|
||||
label.text = item.name;
|
||||
label.scale.set(1 / scale);
|
||||
if (item.type === 'camera' || item.type === 'collider') label.position.set(item.x - size.w / 2, item.y - size.h / 2 - 16 / scale);
|
||||
else label.position.set(item.x + size.w / 2 + 4 / scale, item.y - 8 / scale);
|
||||
}
|
||||
|
||||
private drawGrid(editing: boolean): void {
|
||||
this.grid.clear();
|
||||
if (!editing) return;
|
||||
const step = GRID_STEP * this.view.zoom;
|
||||
if (step < 12) return;
|
||||
const origin = this.worldToScreen(0, 0);
|
||||
const startX = ((origin.x % step) + step) % step;
|
||||
const startY = ((origin.y % step) + step) % step;
|
||||
for (let x = startX; x < this.w; x += step) this.grid.moveTo(x, 0).lineTo(x, this.h);
|
||||
for (let y = startY; y < this.h; y += step) this.grid.moveTo(0, y).lineTo(this.w, y);
|
||||
this.grid.stroke({ width: 1, color: 0xffffff, alpha: 0.05 });
|
||||
if (origin.x >= 0 && origin.x <= this.w) this.grid.moveTo(origin.x, 0).lineTo(origin.x, this.h).stroke({ width: 1, color: 0xffffff, alpha: 0.15 });
|
||||
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?.kind === 'object') {
|
||||
for (const id of s.objectIds) {
|
||||
// Nothing is outlined for a switched-off object – it is not drawn either.
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item || !item.enabled) continue;
|
||||
const { w, h } = footprintOf(item.type, item.props);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* What the scene view and the game view have in common: turning a list of `DrawItem`s into
|
||||
* Pixi display objects, keeping them in z order, culling what is off screen and clipping the
|
||||
* whole thing to the view rectangle.
|
||||
*
|
||||
* The two differ in *where* the items come from and in what may be done with them, not in how
|
||||
* they are drawn – so the differences are the abstract members below, not `if` branches.
|
||||
*/
|
||||
import { Container, type FederatedPointerEvent, Graphics, Text, TextStyle } from 'pixi.js';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ, type Vec2 } from '../core/model/Properties';
|
||||
import { footprintOf, textAnchor } from '../core/properties/bounds';
|
||||
import { rectAround, rectsIntersect } from '../core/properties/camera';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import { type Size, Theme, Widget } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { drawItem } from './symbols';
|
||||
import { type View, worldBounds, worldToScreen } from './view';
|
||||
|
||||
/** What a redraw draws: the items and the colour behind them. */
|
||||
export interface Frame {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
}
|
||||
|
||||
export abstract class WorldViewport extends Widget {
|
||||
protected readonly bg = new Graphics();
|
||||
/** Everything that may extend past the viewport is clipped through this container. */
|
||||
protected readonly content = new Container();
|
||||
protected readonly world = new Container();
|
||||
/** Scene objects (sprites), ordered by `z` then hierarchy order (see `syncDrawOrder`). */
|
||||
protected readonly objects = new Container();
|
||||
/**
|
||||
* Editor symbols (camera icon, labels). Always drawn above `objects` so that a sprite can
|
||||
* never hide them, no matter what z-order the scene ends up with.
|
||||
*/
|
||||
protected readonly overlay = new Container();
|
||||
protected readonly contentMask = new Graphics();
|
||||
protected readonly nodes = new Map<string, Graphics>();
|
||||
/** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */
|
||||
private readonly texts = new Map<string, Text>();
|
||||
private visibleCount = 0;
|
||||
/** Items of the last redraw (for hit tests such as the rubber band). */
|
||||
protected lastItems: DrawItem[] = [];
|
||||
|
||||
protected constructor(protected readonly state: EditorState) {
|
||||
super();
|
||||
this.world.addChild(this.objects, this.overlay);
|
||||
this.content.addChild(this.world);
|
||||
this.addChild(this.bg, this.content, this.contentMask);
|
||||
this.content.mask = this.contentMask;
|
||||
this.cursor = 'default';
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('selection', () => this.redraw());
|
||||
state.on('mode', () => this.redraw());
|
||||
// Every tick moves things: redraw the live view when it is on screen.
|
||||
state.on('runtime', () => { if (this.visible) this.redraw(); });
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
// --- what the two views differ in ------------------------------------------------------------
|
||||
|
||||
/** The active mapping, or null when there is nothing to show. */
|
||||
protected abstract currentView(): View | null;
|
||||
|
||||
/** The items to draw and the colour behind them. Only called when `currentView()` is set. */
|
||||
protected abstract collectFrame(): Frame;
|
||||
|
||||
/** Colour of the whole widget, behind and around the view rectangle. */
|
||||
protected abstract get baseColor(): number;
|
||||
|
||||
/** Whether the view rectangle is filled with the frame's background colour. */
|
||||
protected paintsBackground(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether item nodes take pointer events (only the editable view lets objects be grabbed). */
|
||||
protected get itemsInteractive(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Runs before the items are drawn, with the view (null = nothing to show). */
|
||||
protected beforeItems(_view: View | null): void {}
|
||||
|
||||
/** Runs after the items are drawn – decorations that belong on top. */
|
||||
protected afterItems(_items: DrawItem[], _view: View): void {}
|
||||
|
||||
/** Per item, every redraw: anything drawn beside the item itself (labels). */
|
||||
protected syncDecorations(_item: DrawItem, _onScreen: boolean, _scale: number, _size: Size): void {}
|
||||
|
||||
/** An item disappeared: drop whatever `syncDecorations` created for it. */
|
||||
protected disposeDecorations(_id: string): void {}
|
||||
|
||||
protected onItemPointerDown(_objectId: string, _e: FederatedPointerEvent): void {}
|
||||
|
||||
// --- test hooks ------------------------------------------------------------------------------
|
||||
|
||||
/** Whether anything is rendered and how many items are on screen. */
|
||||
debugInfo(): { rendering: boolean; visibleItems: number } {
|
||||
return { rendering: this.currentView() !== null, visibleItems: this.visibleCount };
|
||||
}
|
||||
|
||||
/** Which layer an item is drawn in: symbols live in `overlay`, above all `objects`. */
|
||||
layerOf(objectId: string): 'objects' | 'overlay' | null {
|
||||
const node = this.nodes.get(objectId);
|
||||
if (!node) return null;
|
||||
return node.parent === this.overlay ? 'overlay' : 'objects';
|
||||
}
|
||||
|
||||
/** Object ids in the order they are drawn, back to front. */
|
||||
drawOrder(): string[] {
|
||||
return this.objects.children.filter((node) => node.visible).map((node) => node.label);
|
||||
}
|
||||
|
||||
/** Global screen position of an object's drawn position, null when not drawn. */
|
||||
objectScreen(objectId: string): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || !view) return null;
|
||||
const local = worldToScreen(view, item.x, item.y);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
// --- drawing ---------------------------------------------------------------------------------
|
||||
|
||||
override redraw(): void {
|
||||
const view = this.currentView();
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(this.baseColor);
|
||||
this.beforeItems(view);
|
||||
this.contentMask.clear();
|
||||
this.content.visible = view !== null;
|
||||
this.visibleCount = 0;
|
||||
if (!view) return;
|
||||
|
||||
const { items, background } = this.collectFrame();
|
||||
this.lastItems = items;
|
||||
const { rect } = view;
|
||||
if (this.paintsBackground()) 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);
|
||||
|
||||
this.world.position.set(rect.x + rect.w / 2 - view.x * view.scale, rect.y + rect.h / 2 - view.y * view.scale);
|
||||
this.world.scale.set(view.scale);
|
||||
const bounds = worldBounds(view);
|
||||
|
||||
// Sync display objects with items; anything outside the view is neither drawn nor hit-testable.
|
||||
const seen = new Set<string>();
|
||||
for (const item of items) {
|
||||
seen.add(item.id);
|
||||
const size = footprintOf(item.type, item.props);
|
||||
// A switched-off object is not drawn at all – in the scene view as in the game. It stays
|
||||
// in the hierarchy panel, which is where it is selected and switched back on.
|
||||
const onScreen = item.enabled && rectsIntersect(rectAround(item.x, item.y, size.w, size.h), bounds);
|
||||
let node = this.nodes.get(item.id);
|
||||
if (!node) {
|
||||
node = new Graphics();
|
||||
node.label = item.id;
|
||||
node.cursor = 'move';
|
||||
node.on('pointerdown', (e: FederatedPointerEvent) => this.onItemPointerDown(item.id, e));
|
||||
this.nodes.set(item.id, node);
|
||||
(isSymbolType(item.type) ? this.overlay : this.objects).addChild(node);
|
||||
}
|
||||
node.visible = onScreen;
|
||||
node.eventMode = this.itemsInteractive && onScreen ? 'static' : 'none';
|
||||
this.syncDecorations(item, onScreen, view.scale, size);
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
node.position.set(item.x, item.y);
|
||||
drawItem(node, item, size, view.scale);
|
||||
}
|
||||
this.afterItems(items, view);
|
||||
for (const [id, node] of this.nodes) {
|
||||
if (seen.has(id)) continue;
|
||||
this.texts.get(id)?.destroy();
|
||||
this.texts.delete(id);
|
||||
node.destroy();
|
||||
this.nodes.delete(id);
|
||||
this.disposeDecorations(id);
|
||||
}
|
||||
this.syncDrawOrder(items);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the object nodes so that Pixi draws them by ascending `z`, ties in hierarchy
|
||||
* order. Done explicitly (instead of Pixi's lazy `sortableChildren`) so the order is
|
||||
* deterministic and hierarchy changes are honoured too.
|
||||
*/
|
||||
private syncDrawOrder(items: DrawItem[]): void {
|
||||
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
||||
this.objects.removeChildren();
|
||||
for (const item of ordered) {
|
||||
const node = this.nodes.get(item.id);
|
||||
if (node) this.objects.addChild(node);
|
||||
// The rendered text is a sibling (Graphics may not have children), kept right above its box.
|
||||
const text = this.texts.get(item.id);
|
||||
if (text) this.objects.addChild(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content of a text object: a Pixi Text kept next to the item's node. Its resolution follows
|
||||
* the zoom in coarse steps – re-rendering the glyph texture on every wheel notch would be
|
||||
* wasteful, blurry text is worse.
|
||||
*/
|
||||
private syncText(item: DrawItem, onScreen: boolean, scale: number): void {
|
||||
let text = this.texts.get(item.id);
|
||||
if (item.type !== 'text' || !onScreen) {
|
||||
if (text) text.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!text) {
|
||||
text = new Text({ text: '', style: new TextStyle({ fontFamily: Theme.font, fontSize: 32, fill: 0xffffff }) });
|
||||
text.eventMode = 'none';
|
||||
this.texts.set(item.id, text);
|
||||
this.objects.addChild(text);
|
||||
}
|
||||
text.visible = true;
|
||||
text.position.set(item.x, item.y);
|
||||
text.text = String(item.props.text ?? '');
|
||||
const size = Math.max(1, Number(item.props.fontSize ?? 32));
|
||||
if (text.style.fontSize !== size) text.style.fontSize = size;
|
||||
const color = Number(item.props.color ?? 0xffffff);
|
||||
if (text.style.fill !== color) text.style.fill = color;
|
||||
text.anchor.set(textAnchor(item.props), 0.5);
|
||||
const resolution = Math.min(4, Math.max(1, Math.round(scale * 2) / 2 * 2));
|
||||
if (text.resolution !== resolution) text.resolution = resolution;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
import { isHandleEnabled, worldPositionOf } from '../core/graph';
|
||||
import type { AssetType } from '../core/model/AssetTypes';
|
||||
import { type AssetType, isSymbolType } from '../core/model/AssetTypes';
|
||||
import { editorPosition, treeOrder } from '../core/model/hierarchy';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import { findAsset } from '../core/model/Project';
|
||||
import { enabledAlongChain } from '../core/properties/enabled';
|
||||
import { resolveSceneObjectProps } from '../core/properties/resolve';
|
||||
import type { World } from '../runtime/World';
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import type { EditorState } from '../editor/state';
|
||||
import type { World } from '../runtime/World';
|
||||
|
||||
/** What the viewport draws: the same shape whether it comes from the model or the world. */
|
||||
export interface DrawItem {
|
||||
@@ -29,7 +28,7 @@ export interface DrawItem {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface DrawList {
|
||||
interface DrawList {
|
||||
items: DrawItem[];
|
||||
background: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* How world coordinates map onto a viewport. Pure geometry – no Pixi, no editor state – so
|
||||
* both viewports share one definition of "where does this world point land on screen".
|
||||
*/
|
||||
import type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||
import { cameraFrame, rectAround, type WorldRect } from '../core/properties/camera';
|
||||
|
||||
/**
|
||||
* The world point (`x`, `y`) sits at the centre of the screen rectangle `rect` (viewport-local
|
||||
* pixels) at `scale` pixels per world unit. In the scene view the rectangle is the whole
|
||||
* viewport; in the game view it is the camera's frame fitted into it (letterboxed).
|
||||
*/
|
||||
export interface View {
|
||||
x: number;
|
||||
y: number;
|
||||
scale: number;
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
}
|
||||
|
||||
/** Free-panning view: the whole viewport shows the world around (`x`, `y`) at `zoom`. */
|
||||
export function freeView(x: number, y: number, zoom: number, w: number, h: number): View {
|
||||
return { x, y, scale: zoom, rect: { x: 0, y: 0, w, h } };
|
||||
}
|
||||
|
||||
/**
|
||||
* The camera's frame scaled to fit inside `w`×`h` and centred – the remaining margin is the
|
||||
* letterbox, which is not part of the game area (see `isInside`).
|
||||
*/
|
||||
export function cameraView(camera: { x: number; y: number; props: PropertyBag }, w: number, h: number): View {
|
||||
const frame = cameraFrame(camera.props);
|
||||
const scale = Math.min(w / frame.w, h / frame.h);
|
||||
const fw = frame.w * scale;
|
||||
const fh = frame.h * scale;
|
||||
return { x: camera.x, y: camera.y, scale, rect: { x: (w - fw) / 2, y: (h - fh) / 2, w: fw, h: fh } };
|
||||
}
|
||||
|
||||
export function screenToWorld(view: View, sx: number, sy: number): Vec2 {
|
||||
const cx = view.rect.x + view.rect.w / 2;
|
||||
const cy = view.rect.y + view.rect.h / 2;
|
||||
return { x: (sx - cx) / view.scale + view.x, y: (sy - cy) / view.scale + view.y };
|
||||
}
|
||||
|
||||
export function worldToScreen(view: View, wx: number, wy: number): Vec2 {
|
||||
const cx = view.rect.x + view.rect.w / 2;
|
||||
const cy = view.rect.y + view.rect.h / 2;
|
||||
return { x: (wx - view.x) * view.scale + cx, y: (wy - view.y) * view.scale + cy };
|
||||
}
|
||||
|
||||
/** World region the view covers – what a redraw has to consider at all. */
|
||||
export function worldBounds(view: View): WorldRect {
|
||||
return rectAround(view.x, view.y, view.rect.w / view.scale, view.rect.h / view.scale);
|
||||
}
|
||||
|
||||
/** Whether a viewport-local point lies on the drawn rectangle rather than in the letterbox. */
|
||||
export function isInside(view: View, sx: number, sy: number): boolean {
|
||||
const { x, y, w, h } = view.rect;
|
||||
return sx >= x && sx <= x + w && sy >= y && sy <= y + h;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* The undo contract every "write one key into a property bag" command shares: a key that was
|
||||
* not in the bag before the edit must be *absent* again afterwards, not present as undefined.
|
||||
* Serialization and the "is this an override?" checks in the inspector go by key presence.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ResetAssetPropertyCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
|
||||
import { SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { ClearOverrideCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { graphEditor, makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
describe('bag value commands', () => {
|
||||
it('undoing an override leaves no key behind', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const box = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, box, 'width', 33));
|
||||
expect(fixture.object(box).overrides).toEqual({ width: 33 });
|
||||
|
||||
fixture.stack.undo();
|
||||
expect('width' in fixture.object(box).overrides).toBe(false);
|
||||
});
|
||||
|
||||
it('undoing a cleared override puts the old value back', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const box = place(fixture, fixture.spriteId, { x: 0, y: 0 });
|
||||
fixture.stack.execute(new SetOverrideCommand(fixture.sceneId, box, 'width', 33));
|
||||
fixture.stack.execute(new ClearOverrideCommand(fixture.sceneId, box, 'width'));
|
||||
expect('width' in fixture.object(box).overrides).toBe(false);
|
||||
|
||||
fixture.stack.undo();
|
||||
expect(fixture.object(box).overrides.width).toBe(33);
|
||||
});
|
||||
|
||||
it('coerces on set and restores the untouched default on undo of a reset', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const sprite = () => findAsset(fixture.project, fixture.spriteId)!;
|
||||
// 'width' is a number property, so a numeric string is coerced.
|
||||
fixture.stack.execute(new SetAssetPropertyCommand(fixture.spriteId, 'width', '250'));
|
||||
expect(sprite().props.width).toBe(250);
|
||||
|
||||
fixture.stack.execute(new ResetAssetPropertyCommand(fixture.spriteId, 'width'));
|
||||
expect(sprite().props.width).toBe(100);
|
||||
fixture.stack.undo();
|
||||
expect(sprite().props.width).toBe(250);
|
||||
});
|
||||
|
||||
it('restores a node param that had no value before', () => {
|
||||
const fixture = makeSpriteScene();
|
||||
const behavior = makeBehavior(fixture, 'B');
|
||||
// `Owner` declares no params, so 'value' is an inline input value: stored as given.
|
||||
const node = graphEditor(fixture, behavior).addNode('Owner');
|
||||
|
||||
fixture.stack.execute(new SetNodeParamCommand(behavior.id, node.id, 'value', 7));
|
||||
expect(node.params.value).toBe(7);
|
||||
|
||||
fixture.stack.undo();
|
||||
expect('value' in node.params).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { AddNodeCommand, DisconnectCommand, RemoveNodeCommand, SetNodeParamCommand } from '../../src/core/commands/graphCommands';
|
||||
import { AddVariableCommand, RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import { GraphInterpreter, allNodeDefs, findNode, portsCompatible } from '../../src/core/graph';
|
||||
import type { BehaviorAsset } from '../../src/core/model/Asset';
|
||||
@@ -296,3 +297,16 @@ describe('behavior serialization', () => {
|
||||
expect(cleaned.graph.edges.length).toBe(behavior.graph.edges.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unknown node types', () => {
|
||||
it('are rejected as a CommandError by execute, not thrown from the constructor', () => {
|
||||
const fixture = makeProject();
|
||||
const behavior = makeBehavior(fixture, 'B');
|
||||
// Building the command must not throw: callers construct it outside the editor's
|
||||
// error handling (`state.execute(new AddNodeCommand(…))`), so a throw here would crash.
|
||||
const command = new AddNodeCommand(behavior.id, 'DoesNotExist', 0, 0);
|
||||
expect(command.label).toBe('Add DoesNotExist');
|
||||
expect(() => command.execute(fixture.project)).toThrow(CommandError);
|
||||
expect(behavior.graph.nodes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Container assets hold placed objects just like scenes do. Anything that walks "all placed
|
||||
* objects" has to cover both – the tests here pin the places that used to look at scenes only.
|
||||
* The history tests pin that a rejected undo leaves the stacks usable.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Command } from '../../src/core/commands/Command';
|
||||
import { CommandError } from '../../src/core/commands/Command';
|
||||
import { CommandStack } from '../../src/core/commands/CommandStack';
|
||||
import { CreateContainerCommand } from '../../src/core/commands/containerCommands';
|
||||
import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
|
||||
import { RemoveVariableCommand, RenameVariableCommand } from '../../src/core/commands/variableCommands';
|
||||
import type { ContainerAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset, findUsages, objectHolders } from '../../src/core/model/Project';
|
||||
import { makeBehavior, makeSpriteScene, place } from '../helpers';
|
||||
|
||||
/** A behavior with a `speed` variable, placed and overridden to 42, then wrapped in a container. */
|
||||
function containerWithOverride() {
|
||||
const f = makeSpriteScene();
|
||||
const behavior = makeBehavior(f, 'B', ['speed']);
|
||||
const objectId = place(f, behavior.id, { x: 0, y: 0 });
|
||||
f.stack.execute(new SetOverrideCommand(f.sceneId, objectId, 'speed', 42));
|
||||
const container = new CreateContainerCommand(f.sceneId, [objectId]);
|
||||
f.stack.execute(container);
|
||||
const template = () => (findAsset(f.project, container.assetId) as ContainerAsset).objects[0]!;
|
||||
return { ...f, behaviorId: behavior.id, objectId, template };
|
||||
}
|
||||
|
||||
describe('object holders', () => {
|
||||
it('counts scenes and container assets alike', () => {
|
||||
const { project } = containerWithOverride();
|
||||
expect(objectHolders(project).map((h) => h.type).sort()).toEqual(['container', 'scene']);
|
||||
});
|
||||
|
||||
it('finds usages inside container templates, not just in scenes', () => {
|
||||
const { project, behaviorId, template } = containerWithOverride();
|
||||
// The placed object and the template copy the container was built from.
|
||||
expect(findUsages(project, behaviorId)).toHaveLength(2);
|
||||
expect(template().overrides.speed).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RemoveVariableCommand', () => {
|
||||
it('clears the override on container templates too, and puts it back on undo', () => {
|
||||
const { stack, behaviorId, objectId, sceneOf, template } = containerWithOverride();
|
||||
const placed = () => sceneOf().objects.find((o) => o.id === objectId)!;
|
||||
|
||||
stack.execute(new RemoveVariableCommand(behaviorId, 'speed'));
|
||||
expect(placed().overrides.speed).toBeUndefined();
|
||||
expect(template().overrides.speed).toBeUndefined();
|
||||
|
||||
stack.undo();
|
||||
expect(placed().overrides.speed).toBe(42);
|
||||
expect(template().overrides.speed).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RenameVariableCommand', () => {
|
||||
it('renames the override on container templates too', () => {
|
||||
const { stack, behaviorId, template } = containerWithOverride();
|
||||
stack.execute(new RenameVariableCommand(behaviorId, 'speed', 'velocity'));
|
||||
expect(template().overrides.speed).toBeUndefined();
|
||||
expect(template().overrides.velocity).toBe(42);
|
||||
|
||||
stack.undo();
|
||||
expect(template().overrides.speed).toBe(42);
|
||||
expect(template().overrides.velocity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
/** Applies a marker to the project; its undo refuses to run. */
|
||||
class RefusingUndo implements Command {
|
||||
readonly label = 'Refusing';
|
||||
execute(project: { name: string }): void {
|
||||
project.name = 'executed';
|
||||
}
|
||||
undo(): void {
|
||||
throw new CommandError('target is gone');
|
||||
}
|
||||
}
|
||||
|
||||
describe('CommandStack', () => {
|
||||
it('keeps a command on the undo stack when its undo is rejected', () => {
|
||||
const { project, stack } = makeSpriteScene();
|
||||
stack.execute(new RefusingUndo());
|
||||
expect(stack.canUndo).toBe(true);
|
||||
|
||||
expect(() => stack.undo()).toThrow(CommandError);
|
||||
// Not swallowed by the history: still undoable, and not queued for redo.
|
||||
expect(stack.canUndo).toBe(true);
|
||||
expect(stack.canRedo).toBe(false);
|
||||
expect(project.name).toBe('executed');
|
||||
});
|
||||
|
||||
it('keeps a command on the redo stack when its redo is rejected', () => {
|
||||
const project = { formatVersion: 1, id: 'p', name: 'P', assets: [], activeSceneId: '' };
|
||||
const stack = new CommandStack(project);
|
||||
let failing = false;
|
||||
stack.execute({
|
||||
label: 'Toggle',
|
||||
execute: () => {
|
||||
if (failing) throw new CommandError('nope');
|
||||
},
|
||||
undo: () => {},
|
||||
});
|
||||
stack.undo();
|
||||
failing = true;
|
||||
|
||||
expect(() => stack.redo()).toThrow(CommandError);
|
||||
expect(stack.canRedo).toBe(true);
|
||||
expect(stack.canUndo).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { removeById, removeFirst, restoreAt } from '../../src/core/util/list';
|
||||
|
||||
const items = () => [{ id: 'a' }, { id: 'b' }, { id: 'c' }];
|
||||
|
||||
describe('removeFirst', () => {
|
||||
it('takes the match out and reports the index it had', () => {
|
||||
const list = items();
|
||||
expect(removeFirst(list, (i) => i.id === 'b')).toEqual({ item: { id: 'b' }, index: 1 });
|
||||
expect(list.map((i) => i.id)).toEqual(['a', 'c']);
|
||||
});
|
||||
|
||||
it('stops at the first match', () => {
|
||||
const list = [{ id: 'a' }, { id: 'a' }];
|
||||
expect(removeFirst(list, (i) => i.id === 'a')?.index).toBe(0);
|
||||
expect(list).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the list untouched when nothing matches', () => {
|
||||
const list = items();
|
||||
expect(removeFirst(list, (i) => i.id === 'zz')).toBeUndefined();
|
||||
expect(list.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('round-trips with restoreAt', () => {
|
||||
const list = items();
|
||||
const removed = removeFirst(list, (i) => i.id === 'b');
|
||||
restoreAt(list, removed ? [removed] : []);
|
||||
expect(list.map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeById', () => {
|
||||
it('returns a new list without that item and does not mutate the old one', () => {
|
||||
const list = items();
|
||||
const next = removeById(list, 'a');
|
||||
expect(next.map((i) => i.id)).toEqual(['b', 'c']);
|
||||
expect(list).toHaveLength(3);
|
||||
expect(next).not.toBe(list);
|
||||
});
|
||||
|
||||
it('keeps every item when the id is unknown', () => {
|
||||
expect(removeById(items(), 'zz').map((i) => i.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AddColliderCommand } from '../../src/core/commands/colliderCommands';
|
||||
import { type EntityHandle, worldPositionOf } from '../../src/core/graph';
|
||||
import { hasParentCycle, parentChain } from '../../src/core/model/walk';
|
||||
import { makeSpriteScene, place, startWorld } from '../helpers';
|
||||
|
||||
interface Node {
|
||||
id: string;
|
||||
parent: Node | null;
|
||||
}
|
||||
|
||||
const parentOf = (node: Node) => node.parent;
|
||||
|
||||
/** a → b → c, where c is parented back to a (only reachable through a broken document or a bug). */
|
||||
function cycle(): { a: Node; b: Node; c: Node } {
|
||||
const a: Node = { id: 'a', parent: null };
|
||||
const b: Node = { id: 'b', parent: a };
|
||||
const c: Node = { id: 'c', parent: b };
|
||||
a.parent = c;
|
||||
return { a, b, c };
|
||||
}
|
||||
|
||||
describe('parentChain', () => {
|
||||
it('yields the item and its ancestors, nearest first', () => {
|
||||
const a: Node = { id: 'a', parent: null };
|
||||
const b: Node = { id: 'b', parent: a };
|
||||
expect([...parentChain(b, parentOf)].map((n) => n.id)).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
it('terminates on a cyclic parent chain, visiting every item once', () => {
|
||||
const { c } = cycle();
|
||||
expect([...parentChain(c, parentOf)].map((n) => n.id)).toEqual(['c', 'b', 'a']);
|
||||
});
|
||||
|
||||
it('reports a cycle only when the parents never reach a root', () => {
|
||||
const { c } = cycle();
|
||||
expect(hasParentCycle(c, parentOf)).toBe(true);
|
||||
expect(hasParentCycle({ id: 'lone', parent: null }, parentOf)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cycle-safe walks in the runtime', () => {
|
||||
it('world position of a cyclic chain terminates and counts each ancestor once', () => {
|
||||
const a: EntityHandle = { id: 'a', assetId: 'x', name: 'a', x: 1, y: 10, z: 0, props: {}, parent: null };
|
||||
const b: EntityHandle = { id: 'b', assetId: 'x', name: 'b', x: 2, y: 20, z: 0, props: {}, parent: a };
|
||||
a.parent = b;
|
||||
expect(worldPositionOf(b)).toEqual({ x: 3, y: 30, z: 0 });
|
||||
});
|
||||
|
||||
// Guards `travelsWith`: before the shared iterator it walked parents without a seen-set and hung here.
|
||||
it('moveAndSlide terminates when a collider sits in a cyclic parent chain', () => {
|
||||
const f = makeSpriteScene();
|
||||
const moverId = place(f, f.spriteId, { x: 0, y: 0 });
|
||||
const wallId = place(f, f.spriteId, { x: 200, y: 0 });
|
||||
const moverCollider = new AddColliderCommand(f.sceneId, moverId);
|
||||
const wallCollider = new AddColliderCommand(f.sceneId, wallId);
|
||||
f.stack.execute(moverCollider);
|
||||
f.stack.execute(wallCollider);
|
||||
const { world, collisions } = startWorld(f, { collisions: true });
|
||||
const mover = world.findByObjectId(moverId)!;
|
||||
const wall = world.findByObjectId(wallId)!;
|
||||
const wallColliderEntity = world.findByObjectId(wallCollider.objectId)!;
|
||||
// Close the loop: the wall's collider is its child and the wall is parented to that collider.
|
||||
wall.parent = wallColliderEntity;
|
||||
|
||||
expect(collisions.moveAndSlide(world, mover, 10, 0)).toEqual({ blockedX: false, blockedY: false });
|
||||
expect(mover.x).toBe(10);
|
||||
}, 2000);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { clamp, centreY, zoomFactor } from '../../src/ui/math';
|
||||
|
||||
describe('clamp', () => {
|
||||
it('keeps a value inside the range', () => {
|
||||
expect(clamp(5, 0, 10)).toBe(5);
|
||||
expect(clamp(-1, 0, 10)).toBe(0);
|
||||
expect(clamp(11, 0, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it('lets the lower bound win when the range is inverted', () => {
|
||||
// A popup wider than the screen: pinning it to the left edge beats pinning it to the right.
|
||||
expect(clamp(5, 10, 0)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('centreY', () => {
|
||||
it('centres on whole pixels', () => {
|
||||
expect(centreY(30, 20)).toBe(5);
|
||||
expect(centreY(31, 20)).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoomFactor', () => {
|
||||
it('zooms in for a negative delta and out for a positive one', () => {
|
||||
expect(zoomFactor(-100)).toBeGreaterThan(1);
|
||||
expect(zoomFactor(100)).toBeLessThan(1);
|
||||
expect(zoomFactor(0)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns exactly to the starting zoom after scrolling back', () => {
|
||||
// Why exponential: the scene view and the graph editor both rely on this cancelling out.
|
||||
expect(1 * zoomFactor(-120) * zoomFactor(120)).toBeCloseTo(1, 12);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* The viewport transforms used to be private methods of a Pixi widget and could only be
|
||||
* checked through the browser. They are plain geometry, so they are pinned here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { cameraView, freeView, isInside, screenToWorld, worldBounds, worldToScreen } from '../../src/viewport/view';
|
||||
|
||||
describe('freeView', () => {
|
||||
it('puts the view centre in the middle of the widget', () => {
|
||||
const view = freeView(50, -20, 2, 800, 600);
|
||||
expect(worldToScreen(view, 50, -20)).toEqual({ x: 400, y: 300 });
|
||||
expect(screenToWorld(view, 400, 300)).toEqual({ x: 50, y: -20 });
|
||||
});
|
||||
|
||||
it('scales distances by the zoom', () => {
|
||||
const view = freeView(0, 0, 2, 800, 600);
|
||||
expect(worldToScreen(view, 100, 0)).toEqual({ x: 600, y: 300 });
|
||||
});
|
||||
|
||||
it('round-trips any point', () => {
|
||||
const view = freeView(13, -7, 0.35, 640, 480);
|
||||
const back = screenToWorld(view, ...Object.values(worldToScreen(view, 123, -456)) as [number, number]);
|
||||
expect(back.x).toBeCloseTo(123, 9);
|
||||
expect(back.y).toBeCloseTo(-456, 9);
|
||||
});
|
||||
|
||||
it('covers the widget in world units', () => {
|
||||
const bounds = worldBounds(freeView(0, 0, 2, 800, 600));
|
||||
expect(bounds).toEqual({ left: -200, top: -150, right: 200, bottom: 150 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cameraView', () => {
|
||||
const camera = { x: 10, y: 20, props: { width: 640, height: 360 } };
|
||||
|
||||
it('fits the camera frame into the widget and centres it', () => {
|
||||
// 640×360 into 800×600: width is the tighter fit (1.25 vs 1.67), so bars top and bottom.
|
||||
const view = cameraView(camera, 800, 600);
|
||||
expect(view.scale).toBeCloseTo(1.25, 9);
|
||||
expect(view.rect).toEqual({ x: 0, y: 75, w: 800, h: 450 });
|
||||
// The camera position sits at the centre of the frame, not of the widget.
|
||||
expect(worldToScreen(view, 10, 20)).toEqual({ x: 400, y: 300 });
|
||||
});
|
||||
|
||||
it('reports the letterbox as outside', () => {
|
||||
const view = cameraView(camera, 800, 600);
|
||||
expect(isInside(view, 400, 300)).toBe(true);
|
||||
expect(isInside(view, 400, 20)).toBe(false); // top bar
|
||||
expect(isInside(view, 400, 580)).toBe(false); // bottom bar
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user