A take used to land in the Assets panel as "Song – Take N", next to every other sound of the project. Now a song can keep a sound, a pattern or a sound FX to itself: an ordinary asset whose new field `owner` names the song. Everything that finds an asset by its id finds it, so playing, the tabs and the game need no change. Only the places that list assets leave it out. - `core/music/embedded.ts` holds the rules: which types can be embedded, `listedAssets`, the title "Song › Take 1", and the rows an embedded sound hides (Loop, Bus). - One user per embedded asset. `SetSongCommand` settles the song after every edit: a place naming an asset an earlier place claimed, or one of another song, gets a copy, and what nothing names any more goes in the same undo step. The outcome is worked out once, so a redo brings back the same copies. - A split, Ctrl+D and a paste get that for free. The clipboard keeps the embedded assets of the copied items, so a paste after a delete brings the take back. - Deleting a song deletes what it keeps. Duplicating it copies that and points the copy at the copies. A duplicate of an embedded asset is a project asset. - The item menu has Open, Make project asset and Embed a copy. - An embedded item carries a bullet in front of its name. The first pick was a diamond, which the gate's font test refused: the UI font has no glyph for it. - A take is "Take 1", 2, … numbered per song. With the song gone while it uploads, it becomes a project sound under a free name. - Left out of: the Assets panel, every asset picker, the sound FX menu of a track, the test sound of the Sound FX tab and the naming of new assets. Delete on an embedded asset only says where it lives. A ping lights up its song. - Loading drops an `owner` whose song is gone, the asset stays as a project asset. No migration: older projects have no owner. Tests: - Unit embedded (new): 13 green, 6 counter-checks each red - Unit take, song, song-edit, asset-fx, asset-ref, serialize and neighbours: 116 green - e2e 65 (melektaus-atx): ALL PASSED, 47 checks (1 new) - Counter-check e2e 65 (asset panel unfiltered): exactly that check red - Gate (melektaus-atx): CHECK EXIT 0, 269 files, 4167 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
647 lines
23 KiB
TypeScript
647 lines
23 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 { 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, 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 { 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';
|
||
|
||
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. */
|
||
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;
|
||
/** 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;
|
||
}
|
||
}
|
||
|
||
abstract class AssetPropertyCommand extends SetBagValueCommand<Asset> {
|
||
protected constructor(
|
||
private readonly assetId: string,
|
||
key: string,
|
||
label: string,
|
||
) {
|
||
super(key, label);
|
||
}
|
||
|
||
protected override resolveTarget(project: Project): Asset {
|
||
return requireAsset(project, this.assetId);
|
||
}
|
||
|
||
protected override bagOf(asset: Asset): PropertyBag {
|
||
return asset.props;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|