A sound FX for one place in a song had to be a project asset, which then stood in every picker. Now every pick of a sound FX in a song can make one the song keeps for that place alone, on top of the embedded assets of the first two parts. - Create sound FX sits at the end of the Sound FX row of the song, a track and an item, and of the menu of a track's FX button. It makes "Sound FX 1", 2, … per song with the Input wired to the Output, picks it and opens it. That is one undo step. - Embed a copy, while the place names a project FX, gives the place a copy under the same name. - While the place names its own, the menu shows it with the mark and offers Edit sound FX and Make project asset. - Picking anything else lets it go in the same undo step. For a track or an item `settleEmbedded` did that already. For the song's own row `AssetPropertyCommand` now settles the song after `fx`. - The mark `•` moved to the core. `assetRefLabel` puts it in front of an embedded asset, so the inspector rows show it like the timeline. - `FieldHandlers.moreChoices` lets a caller end a reference menu with entries of its own. `embeddedPatternName` is `embeddedName` with a base now. Tests: - Unit own-fx (new): 6 green, 6 counter-checks each red - Unit embedded and 80 neighbouring files: 1434 green - e2e 65 (melektaus-atx): ALL PASSED, 51 checks (2 new) - Gate (melektaus-atx): CHECK EXIT 0, 270 files, 4178 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
680 lines
24 KiB
TypeScript
680 lines
24 KiB
TypeScript
import { createEmptyGraph } from '../graph/Graph';
|
||
import { newId } from '../ids';
|
||
import { starterInstrumentGraph } from '../instrument/nodes';
|
||
import type {
|
||
Asset,
|
||
FontAsset,
|
||
SongAsset,
|
||
SoundAsset,
|
||
StateMachineAsset,
|
||
TextureAsset,
|
||
TilemapLayer,
|
||
VariablesAsset,
|
||
} from '../model/Asset';
|
||
import type { AssetType } from '../model/AssetTypes';
|
||
import { clockFollowers, embeddedIn } from '../music/embedded';
|
||
import { defaultTracks } from '../music/song';
|
||
import { heldSongGraph } from '../music/routing';
|
||
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, type PropertyValue, setBagValue } 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 { ASSET_FX_KEY } from '../properties/assetFx';
|
||
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
||
import { type Removed, removeById, removeFirst, removeWhere, 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';
|
||
import { SetSongCommand } from './songCommands';
|
||
|
||
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 'song': {
|
||
const tracks = defaultTracks();
|
||
return { ...base, type: 'song', tracks, items: [], graph: heldSongGraph(undefined, tracks) };
|
||
}
|
||
case 'bus':
|
||
return { ...base, type: 'bus' };
|
||
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. `owner` makes
|
||
* it an asset the song of that id keeps to itself (#355).
|
||
*/
|
||
constructor(
|
||
type: AssetType,
|
||
name: string,
|
||
private readonly props: PropertyBag = {},
|
||
owner = '',
|
||
) {
|
||
this.asset = buildAsset(type, name);
|
||
if (owner) this.asset.owner = owner;
|
||
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;
|
||
/** What a song keeps to itself, copied for the copy (#355). */
|
||
private owned: Asset[] = [];
|
||
|
||
constructor(private readonly assetId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
const original = requireAsset(project, this.assetId);
|
||
if (!this.copy) {
|
||
this.copy = copyOf(original, this.copyId, nextAssetName(project, original.name));
|
||
if (this.copy.type === 'song') this.owned = copyEmbedded(project, original.id, this.copy);
|
||
}
|
||
project.assets.splice(project.assets.indexOf(original) + 1, 0, this.copy);
|
||
project.assets.push(...this.owned);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
const gone = new Set([this.copyId, ...this.owned.map((a) => a.id)]);
|
||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||
}
|
||
}
|
||
|
||
/** Copies of the assets `from` keeps to itself, owned by `song`, whose places are pointed at them. */
|
||
function copyEmbedded(project: Project, from: string, song: SongAsset): Asset[] {
|
||
const ids = new Map(embeddedIn(project, from).map((a) => [a.id, newId()]));
|
||
const renamed = (id: string) => ids.get(id) ?? id;
|
||
for (const track of song.tracks) track.fx = renamed(track.fx);
|
||
for (const item of song.items) {
|
||
item.asset = renamed(item.asset);
|
||
item.fx = renamed(item.fx);
|
||
}
|
||
const fx = song.props[ASSET_FX_KEY];
|
||
if (typeof fx === 'string') song.props[ASSET_FX_KEY] = renamed(fx);
|
||
return embeddedIn(project, from).map((a) => ({ ...structuredClone(a), id: renamed(a.id), owner: song.id }));
|
||
}
|
||
|
||
/** A copy is a project asset of its own, even one of something a song kept to itself. */
|
||
function copyOf(original: Asset, id: string, name: string): Asset {
|
||
const copy = { ...structuredClone(original), id, name };
|
||
delete copy.owner;
|
||
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<A extends Asset & { file: string }> 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<TextureAsset> {
|
||
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<FontAsset> {
|
||
constructor(name: string, file: string) {
|
||
super(buildFontAsset(name, file), 'font', isFontFile);
|
||
}
|
||
}
|
||
|
||
function buildSoundAsset(name: string, file: string, owner: string): SoundAsset {
|
||
const sound: SoundAsset = { id: newId(), name, props: defaultsFor('sound'), type: 'sound', file };
|
||
if (owner) sound.owner = owner;
|
||
return sound;
|
||
}
|
||
|
||
/** `owner` makes it a sound the song of that id keeps to itself (#355). */
|
||
export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
|
||
constructor(name: string, file: string, owner = '') {
|
||
super(buildSoundAsset(name, file, owner), 'sound', isSoundFile);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Makes an asset a song kept to itself a project asset (#355): it shows in the lists, and a name a
|
||
* listed asset has already gets a number.
|
||
*/
|
||
export class MakeProjectAssetCommand implements Command {
|
||
readonly label = 'Make project asset';
|
||
private previous: { owner: string; name: string } | null = null;
|
||
|
||
constructor(private readonly assetId: string) {}
|
||
|
||
execute(project: Project): void {
|
||
const asset = requireAsset(project, this.assetId);
|
||
if (!asset.owner) throw new CommandError(`"${asset.name}" is a project asset already`);
|
||
this.previous = { owner: asset.owner, name: asset.name };
|
||
delete asset.owner;
|
||
asset.name = nextAssetName(project, asset.name, asset.id);
|
||
}
|
||
|
||
undo(project: Project): void {
|
||
if (!this.previous) return;
|
||
const asset = requireAsset(project, this.assetId);
|
||
asset.owner = this.previous.owner;
|
||
asset.name = this.previous.name;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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<Asset>;
|
||
/** What a song kept to itself (#355) goes with it. */
|
||
private owned: Removed<Asset>[] = [];
|
||
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
|
||
private emptied: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
|
||
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);
|
||
this.owned = removeWhere(project.assets, (a) => a.owner === 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.owned);
|
||
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<Scope, string> = {
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* An embedded pattern follows the tempo and the meter of its song (#355), in the same undo step. A
|
||
* song that picks another sound FX lets go of the one it kept to itself, in that step too.
|
||
*/
|
||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||
private followed: { id: string; previous: PropertyValue | undefined }[] = [];
|
||
private settled: SetSongCommand | null = null;
|
||
|
||
protected constructor(
|
||
private readonly assetId: string,
|
||
key: string,
|
||
label: string,
|
||
) {
|
||
super(key, label);
|
||
}
|
||
|
||
override execute(project: Project): void {
|
||
super.execute(project);
|
||
const asset = requireAsset(project, this.assetId);
|
||
this.followed = clockFollowers(project, asset, this.key).map((pattern) => {
|
||
const previous = pattern.props[this.key];
|
||
setBagValue(pattern.props, this.key, asset.props[this.key]);
|
||
return { id: pattern.id, previous };
|
||
});
|
||
this.settled =
|
||
asset.type === 'song' && this.key === ASSET_FX_KEY ? new SetSongCommand(asset.id, {}) : null;
|
||
this.settled?.execute(project);
|
||
}
|
||
|
||
override undo(project: Project): void {
|
||
this.settled?.undo(project);
|
||
for (const { id, previous } of this.followed)
|
||
setBagValue(requireAsset(project, id).props, this.key, previous);
|
||
super.undo(project);
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|