diff --git a/README.md b/README.md index a8cab735..b5618ba7 100644 --- a/README.md +++ b/README.md @@ -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//.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 diff --git a/src/core/commands/Command.ts b/src/core/commands/Command.ts index 3e8a73d1..3ee37122 100644 --- a/src/core/commands/Command.ts +++ b/src/core/commands/Command.ts @@ -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; diff --git a/src/core/commands/CommandStack.ts b/src/core/commands/CommandStack.ts index 941c77df..e7cc08b5 100644 --- a/src/core/commands/CommandStack.ts +++ b/src/core/commands/CommandStack.ts @@ -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; } diff --git a/src/core/commands/SetBagValueCommand.ts b/src/core/commands/SetBagValueCommand.ts new file mode 100644 index 00000000..abd321c2 --- /dev/null +++ b/src/core/commands/SetBagValueCommand.ts @@ -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 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); + } +} diff --git a/src/core/commands/applyCommands.ts b/src/core/commands/applyCommands.ts index 5a32e679..ce46b53f 100644 --- a/src/core/commands/applyCommands.ts +++ b/src/core/commands/applyCommands.ts @@ -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 { + read(object: SceneObject): T; + write(object: SceneObject, value: T): void; +} + +const overrideSlot = (key: string): Slot => ({ + read: (object) => object.overrides[key], + write: (object, value) => setBagValue(object.overrides, key, value), +}); + +const positionSlot = (axis: Axis): Slot => ({ + 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 { object: SceneObject; - previous: PropertyValue | undefined; + slot: Slot; + 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(project: Project, link: TemplateLink, slots: readonly Slot[], values: readonly T[]): SlotMemo[] { + const before = slots.map((slot) => slot.read(link.template)); + const memos: SlotMemo[] = 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(memos: readonly SlotMemo[]): 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[] = []; 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[] = []; 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[] = []; 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); } } diff --git a/src/core/commands/assetCommands.ts b/src/core/commands/assetCommands.ts index 7bd21864..1a6c9ca5 100644 --- a/src/core/commands/assetCommands.ts +++ b/src/core/commands/assetCommands.ts @@ -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 { + 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; } } diff --git a/src/core/commands/colliderCommands.ts b/src/core/commands/colliderCommands.ts index 0037c8a8..cd9114ec 100644 --- a/src/core/commands/colliderCommands.ts +++ b/src/core/commands/colliderCommands.ts @@ -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); } } diff --git a/src/core/commands/containerCommands.ts b/src/core/commands/containerCommands.ts index 75b75ad4..2d3c5feb 100644 --- a/src/core/commands/containerCommands.ts +++ b/src/core/commands/containerCommands.ts @@ -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); } } diff --git a/src/core/commands/graphCommands.ts b/src/core/commands/graphCommands.ts index 8ffa08c2..d2913aa7 100644 --- a/src/core/commands/graphCommands.ts +++ b/src/core/commands/graphCommands.ts @@ -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 { + 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); } } diff --git a/src/core/commands/require.ts b/src/core/commands/require.ts index 46b8303f..801ba559 100644 --- a/src/core/commands/require.ts +++ b/src/core/commands/require.ts @@ -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); diff --git a/src/core/commands/sceneObjectCommands.ts b/src/core/commands/sceneObjectCommands.ts index 9dc77103..f68ff955 100644 --- a/src/core/commands/sceneObjectCommands.ts +++ b/src/core/commands/sceneObjectCommands.ts @@ -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 { + 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; } } diff --git a/src/core/commands/variableCommands.ts b/src/core/commands/variableCommands.ts index a27d0bb4..a5f0eafc 100644 --- a/src/core/commands/variableCommands.ts +++ b/src/core/commands/variableCommands.ts @@ -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; 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; diff --git a/src/core/graph/Graph.ts b/src/core/graph/Graph.ts index cffc77fb..b26bcf4c 100644 --- a/src/core/graph/Graph.ts +++ b/src/core/graph/Graph.ts @@ -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); diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index fffad756..898aff31 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -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([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`). */ diff --git a/src/core/model/Project.ts b/src/core/model/Project.ts index e4d1fbbb..88efc374 100644 --- a/src/core/model/Project.ts +++ b/src/core/model/Project.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); } diff --git a/src/core/model/container.ts b/src/core/model/container.ts index 72240421..452f4023 100644 --- a/src/core/model/container.ts +++ b/src/core/model/container.ts @@ -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. */ diff --git a/src/core/model/hierarchy.ts b/src/core/model/hierarchy.ts index db55c320..41a79eff 100644 --- a/src/core/model/hierarchy.ts +++ b/src/core/model/hierarchy.ts @@ -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([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). */ diff --git a/src/core/model/walk.ts b/src/core/model/walk.ts new file mode 100644 index 00000000..be539c72 --- /dev/null +++ b/src/core/model/walk.ts @@ -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(start: T, parentOf: (item: T) => T | null | undefined): Generator { + const seen = new Set(); + 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(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(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(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; +} diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index a4d98e67..7b6a0c8a 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { +const ASSET_SCHEMAS: Record = { scene: [{ key: 'backgroundColor', label: 'Background', kind: 'color', default: 0x2a2d34 }], camera: [ { key: 'width', label: 'View width', kind: 'number', default: 640, min: 16, step: 1 }, diff --git a/src/core/properties/enabled.ts b/src/core/properties/enabled.ts index a91e637f..116c6db1 100644 --- a/src/core/properties/enabled.ts +++ b/src/core/properties/enabled.ts @@ -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(start: T, propsOf: (item: T) => PropertyBag, parentOf: (item: T) => T | null): boolean { - const seen = new Set(); - 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; } diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 7b4e2d7b..22b3ea2e 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -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): 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([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; } diff --git a/src/core/util/list.ts b/src/core/util/list.ts index 0a10f254..91d03c90 100644 --- a/src/core/util/list.ts +++ b/src/core/util/list.ts @@ -4,6 +4,17 @@ export interface Removed { 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(list: T[], predicate: (item: T) => boolean): Removed | 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(list: T[], predicate: (item: T) => boolean): Removed[] { const removed: Removed[] = []; @@ -18,3 +29,11 @@ export function removeWhere(list: T[], predicate: (item: T) => boolean): Remo export function restoreAt(list: T[], removed: readonly Removed[]): 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(list: readonly T[], id: string): T[] { + return list.filter((item) => item.id !== id); +} diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 7f5d1211..2ea785de 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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 }[]; - 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; 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 { - 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 { + return this.projects.boot(); } - private async open(id: string): Promise { - 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 { - 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 { - const created = await this.store.create(name); - await this.open(created.project.id); - } - - private async deleteCurrentProject(): Promise { - 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(); } } diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts new file mode 100644 index 00000000..f00e175f --- /dev/null +++ b/src/editor/PlaySession.ts @@ -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 }[]; + 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], + }; + } +} diff --git a/src/editor/ProjectSession.ts b/src/editor/ProjectSession.ts new file mode 100644 index 00000000..62c618c9 --- /dev/null +++ b/src/editor/ProjectSession.ts @@ -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 { + 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 { + 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 { + 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 { + const created = await this.store.create(name); + await this.open(created.project.id); + } + + private async deleteCurrent(): Promise { + 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); + } +} diff --git a/src/editor/actions.ts b/src/editor/actions.ts index 8096afe4..77554b13 100644 --- a/src/editor/actions.ts +++ b/src/editor/actions.ts @@ -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(); +} diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index dc3edf0a..ccb32182 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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'; diff --git a/src/editor/graph/GraphEditor.ts b/src/editor/graph/GraphEditor.ts index 24d1e0b2..b7f3fc58 100644 --- a/src/editor/graph/GraphEditor.ts +++ b/src/editor/graph/GraphEditor.ts @@ -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 }; } } diff --git a/src/editor/graph/NodePalette.ts b/src/editor/graph/NodePalette.ts index 1807154b..658f62b1 100644 --- a/src/editor/graph/NodePalette.ts +++ b/src/editor/graph/NodePalette.ts @@ -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 { diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index f79d9b1f..73d684ac 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -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; diff --git a/src/editor/inspector/Inspector.ts b/src/editor/inspector/Inspector.ts index bc0a47ac..5146d7e3 100644 --- a/src/editor/inspector/Inspector.ts +++ b/src/editor/inspector/Inspector.ts @@ -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; -} diff --git a/src/editor/inspector/assetSection.ts b/src/editor/inspector/assetSection.ts index dbee4747..d4345568 100644 --- a/src/editor/inspector/assetSection.ts +++ b/src/editor/inspector/assetSection.ts @@ -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 = { number: 'Number', boolean: 'Boolean', text: 'Text' }; const VARIABLE_KIND_MARKS: Record = { 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(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); diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 15e5e6ee..4bb1894a 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -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; diff --git a/src/editor/inspector/nodeSection.ts b/src/editor/inspector/nodeSection.ts index 0c17052b..f9e859bb 100644 --- a/src/editor/inspector/nodeSection.ts +++ b/src/editor/inspector/nodeSection.ts @@ -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(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 { diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index aa001565..29a6fcfe 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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 { /** * 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(host: InspectorHost, sceneId: string, objectId: string, value: number, spec: ComponentSpec): 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(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(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): 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(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 [ diff --git a/src/editor/inspector/previewHandlers.ts b/src/editor/inspector/previewHandlers.ts new file mode 100644 index 00000000..78c19761 --- /dev/null +++ b/src/editor/inspector/previewHandlers.ts @@ -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 { + /** 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(host: InspectorHost, spec: PreviewedValue): 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))); + }, + }; +} diff --git a/src/editor/panels/AssetPanel.ts b/src/editor/panels/AssetPanel.ts index cf902cc3..7f3625fb 100644 --- a/src/editor/panels/AssetPanel.ts +++ b/src/editor/panels/AssetPanel.ts @@ -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(); - } } diff --git a/src/editor/shortcuts.ts b/src/editor/shortcuts.ts index 159dba3d..544861b1 100644 --- a/src/editor/shortcuts.ts +++ b/src/editor/shortcuts.ts @@ -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') { diff --git a/src/editor/state.ts b/src/editor/state.ts index 851f3ba9..b6481f45 100644 --- a/src/editor/state.ts +++ b/src/editor/state.ts @@ -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; diff --git a/src/runtime/World.ts b/src/runtime/World.ts index c42de33c..4c8a0bcd 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -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(); 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 { diff --git a/src/runtime/systems/CollisionSystem.ts b/src/runtime/systems/CollisionSystem.ts index bb8502d3..393a84e8 100644 --- a/src/runtime/systems/CollisionSystem.ts +++ b/src/runtime/systems/CollisionSystem.ts @@ -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)); } diff --git a/src/ui/Theme.ts b/src/ui/Theme.ts index bdb8f882..43653502 100644 --- a/src/ui/Theme.ts +++ b/src/ui/Theme.ts @@ -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, diff --git a/src/ui/draw.ts b/src/ui/draw.ts index d718ca93..c2e1fdc6 100644 --- a/src/ui/draw.ts +++ b/src/ui/draw.ts @@ -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; diff --git a/src/ui/index.ts b/src/ui/index.ts index 9478e482..e4bbe1fd 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -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'; diff --git a/src/ui/math.ts b/src/ui/math.ts index 59522522..d90c852e 100644 --- a/src/ui/math.ts +++ b/src/ui/math.ts @@ -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); +} diff --git a/src/viewport/GameViewport.ts b/src/viewport/GameViewport.ts new file mode 100644 index 00000000..772897cb --- /dev/null +++ b/src/viewport/GameViewport.ts @@ -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)); + } +} diff --git a/src/viewport/SceneViewport.ts b/src/viewport/SceneViewport.ts new file mode 100644 index 00000000..e0f74adb --- /dev/null +++ b/src/viewport/SceneViewport.ts @@ -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(); + /** 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 => 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 }); + } + } +} diff --git a/src/viewport/Viewport.ts b/src/viewport/Viewport.ts deleted file mode 100644 index 8014e2d4..00000000 --- a/src/viewport/Viewport.ts +++ /dev/null @@ -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(); - private readonly labels = new Map(); - /** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */ - private readonly texts = new Map(); - 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 => 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(); - 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 }); - } - } -} diff --git a/src/viewport/WorldViewport.ts b/src/viewport/WorldViewport.ts new file mode 100644 index 00000000..f186cfe4 --- /dev/null +++ b/src/viewport/WorldViewport.ts @@ -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(); + /** Rendered content of text objects: a sibling of the item's node, kept next to it in z. */ + private readonly texts = new Map(); + 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(); + 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; + } +} diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 95873aec..8cafdbd6 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -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; } diff --git a/src/viewport/view.ts b/src/viewport/view.ts new file mode 100644 index 00000000..56801850 --- /dev/null +++ b/src/viewport/view.ts @@ -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; +} diff --git a/tests/core/bag-commands.test.ts b/tests/core/bag-commands.test.ts new file mode 100644 index 00000000..f0553a16 --- /dev/null +++ b/tests/core/bag-commands.test.ts @@ -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); + }); +}); diff --git a/tests/core/graph.test.ts b/tests/core/graph.test.ts index 2150843d..aab7dc08 100644 --- a/tests/core/graph.test.ts +++ b/tests/core/graph.test.ts @@ -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); + }); +}); diff --git a/tests/core/holders.test.ts b/tests/core/holders.test.ts new file mode 100644 index 00000000..ffd1f499 --- /dev/null +++ b/tests/core/holders.test.ts @@ -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); + }); +}); diff --git a/tests/core/list.test.ts b/tests/core/list.test.ts new file mode 100644 index 00000000..61e876d9 --- /dev/null +++ b/tests/core/list.test.ts @@ -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']); + }); +}); diff --git a/tests/core/walk.test.ts b/tests/core/walk.test.ts new file mode 100644 index 00000000..cfd0d09d --- /dev/null +++ b/tests/core/walk.test.ts @@ -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); +}); diff --git a/tests/ui/math.test.ts b/tests/ui/math.test.ts new file mode 100644 index 00000000..cafc19aa --- /dev/null +++ b/tests/ui/math.test.ts @@ -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); + }); +}); diff --git a/tests/viewport/view.test.ts b/tests/viewport/view.test.ts new file mode 100644 index 00000000..7df51cab --- /dev/null +++ b/tests/viewport/view.test.ts @@ -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 + }); +});