import { createEmptyGraph } from '../graph/Graph'; import { newId } from '../ids'; import type { Asset } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import { removeSubtrees } from '../model/hierarchy'; 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, 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 { const base = { id, name, props: defaultsFor(type) }; switch (type) { case 'scene': return { ...base, type: 'scene', objects: [] }; case 'camera': return { ...base, type: 'camera' }; case 'sprite': return { ...base, type: 'sprite' }; case 'text': return { ...base, type: 'text' }; case 'behavior': return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; case 'collider': return { ...base, type: 'collider' }; case 'container': return { ...base, type: 'container', objects: [] }; } } /** Adds a new asset; the first scene of a project becomes the active scene. */ export class CreateAssetCommand implements Command { readonly label: string; readonly asset: Asset; private previousActiveScene = ''; constructor(type: AssetType, name: string) { this.asset = buildAsset(type, name); this.label = `Create ${type} "${name}"`; } execute(project: Project): void { project.assets.push(this.asset); this.previousActiveScene = project.activeSceneId; if (this.asset.type === 'scene' && !project.activeSceneId) project.activeSceneId = this.asset.id; } undo(project: Project): void { project.assets = removeById(project.assets, this.asset.id); project.activeSceneId = this.previousActiveScene; } } /** * Deletes an asset and, as a cascade, every scene object referencing it – together with the * objects parented below those (a subtree is removed as a whole). Container templates * referencing the asset lose those objects the same way. */ export class DeleteAssetCommand implements Command { readonly label = 'Delete asset'; private removed?: Removed; /** Objects removed per scene or container asset (both hold a flat object list). */ private removedObjects: { holderId: string; removed: Removed[] }[] = []; private previousActiveScene = ''; constructor(private readonly assetId: string) {} execute(project: Project): void { 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 = 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'); project.activeSceneId = next ? next.id : ''; } } undo(project: Project): void { if (!this.removed) return; restoreAt(project.assets, [this.removed]); const holders = new Map(objectHolders(project).map((h) => [h.id, h])); for (const entry of this.removedObjects) { const holder = holders.get(entry.holderId); if (holder) restoreAt(holder.objects, entry.removed); } project.activeSceneId = this.previousActiveScene; } } export class RenameAssetCommand implements Command { readonly label = 'Rename asset'; private previousName = ''; constructor(private readonly assetId: string, private readonly name: string) {} execute(project: Project): void { const asset = requireAsset(project, this.assetId); this.previousName = asset.name; asset.name = this.name; } undo(project: Project): void { requireAsset(project, this.assetId).name = this.previousName; } } /** 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 extends AssetPropertyCommand { constructor(assetId: string, key: string, private readonly value: PropertyValue) { super(assetId, key, `Set ${key}`); } 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 extends AssetPropertyCommand { constructor(assetId: string, key: string) { super(assetId, key, `Reset ${key}`); } protected override computeNext(asset: Asset): PropertyValue { return requirePropDef(asset, this.key).default; } } export class SetActiveSceneCommand implements Command { readonly label = 'Switch scene'; private previous = ''; constructor(private readonly sceneId: string) {} execute(project: Project): void { const asset = requireAsset(project, this.sceneId); if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`); this.previous = project.activeSceneId; project.activeSceneId = this.sceneId; } undo(project: Project): void { project.activeSceneId = this.previous; } }