import { createEmptyGraph } from '../graph/Graph'; import { newId } from '../ids'; import { starterInstrumentGraph } from '../instrument/nodes'; import type { Asset, FontAsset, SoundAsset, StateMachineAsset, TextureAsset, TilemapLayer, VariablesAsset, } from '../model/Asset'; import type { AssetType } from '../model/AssetTypes'; import { linearCurve } from '../model/curve'; import { isFontFile } from '../model/font'; import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart'; import { isSoundFile } from '../model/sound'; import { removeSubtrees } from '../model/hierarchy'; import { dropReferencesToGone, renamedReference, shownObjectIds, swapReferences } from '../model/objectRefs'; import type { PropertyBag, PropertyValue } from '../model/Properties'; import { objectHolders, type Project } from '../model/Project'; import { type Scope, scopeOf, setScope, setSharedScope, type SharedScope, sharedScopeOf, } from '../model/Scope'; import type { SceneObject } from '../model/SceneObject'; import { isTextureFile } from '../model/texture'; import { cellsWithout } from '../model/tilemap'; import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema'; import { type Removed, removeById, removeFirst, restoreAt } from '../util/list'; import { Recorder } from '../util/recorder'; import { type Command, CommandError } from './Command'; import { nextAssetName } from './naming'; import { requireAsset, requirePropDef, requireStateMachine, requireVariablesAsset } from './require'; import { SetBagValueCommand } from './SetBagValueCommand'; export function buildAsset(type: AssetType, name: string, id = newId()): Asset { const base = { id, name, props: initialPropsFor(type) }; switch (type) { case 'texture': return buildTextureAsset(name, { file: '', width: 0, height: 0 }, id); case 'scene': return { ...base, type: 'scene', objects: [] }; case 'camera': return { ...base, type: 'camera' }; case 'clip': return { ...base, type: 'clip' }; case 'sprite': return { ...base, type: 'sprite' }; case 'text': return { ...base, type: 'text' }; case 'behavior': return { ...base, type: 'behavior', variables: [], graph: createEmptyGraph() }; case 'variables': return { ...base, type: 'variables', variables: [] }; case 'struct': return { ...base, type: 'struct', variables: [] }; case 'collider': return { ...base, type: 'collider' }; case 'rigidbody': return { ...base, type: 'rigidbody' }; case 'joint': return { ...base, type: 'joint' }; case 'container': return { ...base, type: 'container', objects: [] }; // A drawing starts empty: the pen tool of #129 sets the first shape down, on its one layer. case 'vectorart': return { ...base, type: 'vectorart', layers: [{ id: newId(), name: 'Layer 1', items: [] }] }; // A canvas with nothing to paint on is useless, so it starts with one layer on one frame. case 'pixelart': return { ...base, type: 'pixelart', width: PIXEL_CANVAS_DEFAULT, height: PIXEL_CANVAS_DEFAULT, layers: [{ id: newId(), name: 'Layer 1', opacity: 1 }], frames: [{ id: newId(), cels: {} }], }; case 'palette': return { ...base, type: 'palette', colors: [...DEFAULT_PALETTE] }; case 'animation': return { ...base, type: 'animation', root: '', tracks: [] }; // Linear until someone bends it: a key that picks a new curve still moves as it did. case 'curve': return { ...base, type: 'curve', points: linearCurve() }; case 'tile': return { ...base, type: 'tile', pictures: [] }; // A tilemap without a layer could not be painted on, so it starts with one. case 'tilemap': return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] }; case 'statemachine': return { ...base, type: 'statemachine', graph: createEmptyGraph() }; case 'shader': return { ...base, type: 'shader', graph: createEmptyGraph() }; case 'shaderfunction': return { ...base, type: 'shaderfunction', graph: createEmptyGraph() }; case 'font': return { ...base, type: 'font', file: '' }; case 'sound': return { ...base, type: 'sound', file: '' }; case 'soundfx': return { ...base, type: 'soundfx', graph: createEmptyGraph() }; case 'instrument': return { ...base, type: 'instrument', graph: starterInstrumentGraph() }; case 'pattern': return { ...base, type: 'pattern', notes: [] }; case 'particles': return { ...base, type: 'particles', graph: createEmptyGraph() }; } } /** 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 = ''; /** `props` replace schema defaults and pass through the schema like any other edit. */ constructor( type: AssetType, name: string, private readonly props: PropertyBag = {}, ) { this.asset = buildAsset(type, name); this.label = `Create ${type} "${name}"`; } execute(project: Project): void { for (const [key, value] of Object.entries(this.props)) this.asset.props[key] = coerceValue(requirePropDef(project, this.asset, key), value); 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; } } /** * A copy of an asset right behind it (#297), named the way a new one would be. What other assets * point at stays with the original. Ids a copy cannot share get fresh ones: frames are named across * the document, and objects get new ids the way pasted ones do (references among them follow). */ export class DuplicateAssetCommand implements Command { readonly label = 'Duplicate asset'; readonly copyId = newId(); private copy?: Asset; constructor(private readonly assetId: string) {} execute(project: Project): void { const original = requireAsset(project, this.assetId); this.copy ??= copyOf(original, this.copyId, nextAssetName(project, original.name)); project.assets.splice(project.assets.indexOf(original) + 1, 0, this.copy); } undo(project: Project): void { project.assets = removeById(project.assets, this.copyId); } } function copyOf(original: Asset, id: string, name: string): Asset { const copy = { ...structuredClone(original), id, name }; if (copy.type === 'texture' || copy.type === 'pixelart') for (const frame of copy.frames) frame.id = newId(); if (copy.type === 'scene' || copy.type === 'container') { const ids = new Map(copy.objects.map((o) => [o.id, newId()])); for (const object of copy.objects) { object.id = ids.get(object.id)!; object.parentId = ids.get(object.parentId) ?? object.parentId; } swapReferences(copy.objects, (ref) => renamedReference(ref, ids), new Recorder()); } return copy; } /** The upload result a texture asset is built from (see `TextureStore.upload`). */ export interface TextureImage { file: string; width: number; height: number; } /** * Apart from `buildAsset` so callers get a `TextureAsset` rather than the `Asset` union. * * The three fields are copied out one by one rather than spread: callers pass the whole upload * result, which carries `bytes` and – since original names are kept – a `name` of its own. A * spread would put the *file's* name where the asset's belongs and silently overrule the caller. */ function buildTextureAsset(name: string, image: TextureImage, id = newId()): TextureAsset { return { id, name, props: defaultsFor('texture'), type: 'texture', file: image.file, width: image.width, height: image.height, frames: [], }; } /** * The file is uploaded before the command runs; this only records the result, so undo and redo * never touch the network. */ abstract class ImportFileCommand implements Command { readonly label: string; constructor( readonly asset: A, private readonly fileNoun: string, private readonly isStored: (file: string) => boolean, ) { this.label = `Import ${asset.type} "${asset.name}"`; } execute(project: Project): void { // The name is the hash the server derived from the bytes; anything else was never uploaded. if (!this.isStored(this.asset.file)) throw new CommandError(`"${this.asset.file}" is not a stored ${this.fileNoun} file`); project.assets.push(this.asset); } undo(project: Project): void { project.assets = removeById(project.assets, this.asset.id); } } export class CreateTextureCommand extends ImportFileCommand { constructor(name: string, image: TextureImage) { super(buildTextureAsset(name, image), 'image', isTextureFile); } } function buildFontAsset(name: string, file: string): FontAsset { return { id: newId(), name, props: defaultsFor('font'), type: 'font', file }; } export class CreateFontCommand extends ImportFileCommand { constructor(name: string, file: string) { super(buildFontAsset(name, file), 'font', isFontFile); } } function buildSoundAsset(name: string, file: string): SoundAsset { return { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file }; } export class CreateSoundCommand extends ImportFileCommand { constructor(name: string, file: string) { super(buildSoundAsset(name, file), 'sound', isSoundFile); } } /** * Deletes an asset and, as a cascade, every scene object referencing it, together with the objects * parented below those – in container templates as well as in scenes. A tile takes the cells it is * painted on along (#268), which would otherwise stay in the file and draw nothing. A reference to * an object that goes is emptied, as when the object is removed by itself (#286). */ export class DeleteAssetCommand implements Command { readonly label = 'Delete asset'; private removed?: Removed; private removedObjects: { holderId: string; removed: Removed[] }[] = []; private emptied: { layer: TilemapLayer; cells: Record }[] = []; private readonly references = new Recorder(); private previousActiveScene = ''; constructor(private readonly assetId: string) {} execute(project: Project): void { const asset = requireAsset(project, this.assetId); this.removedObjects = []; this.emptied = []; const before = shownObjectIds(project); if (asset.type === 'tile') for (const map of project.assets) { if (map.type !== 'tilemap') continue; for (const layer of map.layers) { const kept = cellsWithout(layer.cells, this.assetId); if (!kept) continue; this.emptied.push({ layer, cells: layer.cells }); layer.cells = kept; } } // 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); dropReferencesToGone(project, before, this.references); 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; this.references.revert(); 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); } for (const { layer, cells } of this.emptied) layer.cells = cells; 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; } } const SCOPE_LABELS: Record = { local: 'local', global: 'global', scene: 'per scene', }; /** * A `global` machine keeps one current state for the whole run instead of one per behavior * instance; a `scene` machine shares one too, but starts over with every scene (#164). */ export class SetMachineScopeCommand implements Command { readonly label: string; private previous: Scope = 'local'; constructor( private readonly assetId: string, private readonly scope: Scope, ) { this.label = `Make state machine ${SCOPE_LABELS[scope]}`; } execute(project: Project): void { const asset = requireStateMachine(project, this.assetId); this.previous = scopeOf(asset); setScope(asset, this.scope); } undo(project: Project): void { setScope(requireStateMachine(project, this.assetId), this.previous); } } /** Whether a Variables asset keeps its values past the end of the run (#166). */ export class SetVariablesPersistentCommand implements Command { readonly label: string; private previous = false; constructor( private readonly assetId: string, private readonly persistent: boolean, ) { this.label = persistent ? 'Make variables persistent' : 'Make variables per run'; } execute(project: Project): void { const asset = requireVariablesAsset(project, this.assetId); this.previous = asset.persistent === true; setPersistent(asset, this.persistent); } undo(project: Project): void { setPersistent(requireVariablesAsset(project, this.assetId), this.previous); } } function setPersistent(asset: VariablesAsset, on: boolean): void { if (on) asset.persistent = true; else delete asset.persistent; } /** * How long one set of shared values lives: `global` for the whole run, `scene` built again from the * declarations with every scene that is loaded (#164). Going to `scene` takes the persistent switch * off, because the two say opposite things about the same values and the scope is what was just asked for. */ export class SetVariablesScopeCommand implements Command { readonly label: string; private previous: SharedScope = 'global'; private wasPersistent = false; constructor( private readonly assetId: string, private readonly scope: SharedScope, ) { this.label = scope === 'scene' ? 'Make variables per scene' : 'Make variables global'; } execute(project: Project): void { const asset = requireVariablesAsset(project, this.assetId); this.previous = sharedScopeOf(asset); this.wasPersistent = asset.persistent === true; setSharedScope(asset, this.scope); if (this.scope === 'scene') setPersistent(asset, false); } undo(project: Project): void { const asset = requireVariablesAsset(project, this.assetId); setSharedScope(asset, this.previous); setPersistent(asset, this.wasPersistent); } } /** * Which state a machine starts in, as a state node's id – `''` takes the mark off again, and the * machine is back to starting in no state. Only one state can be the start because there is only * one field to hold it. */ export class SetMachineStartCommand implements Command { readonly label: string; private previous = ''; constructor( private readonly assetId: string, private readonly start: string, ) { this.label = start ? 'Set start state' : 'Remove start state'; } execute(project: Project): void { const asset = requireStateMachine(project, this.assetId); this.previous = asset.start ?? ''; this.write(asset, this.start); } undo(project: Project): void { this.write(requireStateMachine(project, this.assetId), this.previous); } /** Kept out of the document when there is none, the way a `local` scope is. */ private write(asset: StateMachineAsset, start: string): void { if (start) asset.start = start; else delete asset.start; } } 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; } } export class SetAssetPropertyCommand extends AssetPropertyCommand { constructor( assetId: string, key: string, private readonly value: PropertyValue, ) { super(assetId, key, `Set ${key}`); } protected override computeNext(asset: Asset, project: Project): PropertyValue { return coerceValue(requirePropDef(project, asset, this.key), this.value); } } export class ResetAssetPropertyCommand extends AssetPropertyCommand { constructor(assetId: string, key: string) { super(assetId, key, `Reset ${key}`); } protected override computeNext(asset: Asset, project: Project): PropertyValue { return initialOf(requirePropDef(project, asset, this.key)); } } /** * Names the scene a real run begins with, or takes that name back (`''`). A deleted scene is not * cleaned up here on purpose: the id then simply reads as none, and an undo of the deletion brings * the start scene back with it. */ export class SetStartSceneCommand implements Command { readonly label = 'Set start scene'; private previous = ''; constructor(private readonly sceneId: string) {} execute(project: Project): void { if (this.sceneId) { const asset = requireAsset(project, this.sceneId); if (asset.type !== 'scene') throw new CommandError(`${asset.name} is not a scene`); } this.previous = project.startSceneId; project.startSceneId = this.sceneId; } undo(project: Project): void { project.startSceneId = this.previous; } } /** * Switches an editor plugin on or off for this project (#119). It is an undo step like any other * change to the project: the switch changes the project, and the editor follows the project. */ export class SetPluginActiveCommand implements Command { readonly label: string; private previous: string[] = []; constructor( private readonly file: string, private readonly active: boolean, ) { this.label = `Switch plugin ${active ? 'on' : 'off'}`; } execute(project: Project): void { this.previous = [...project.plugins]; const rest = this.previous.filter((f) => f !== this.file); project.plugins = this.active ? [...rest, this.file].sort() : rest; } undo(project: Project): void { project.plugins = [...this.previous]; } } 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; } }