Files
feuerwerk/src/core/commands/assetCommands.ts
T

174 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Asset>;
/** Objects removed per scene or container asset (both hold a flat object list). */
private removedObjects: { holderId: string; removed: Removed<SceneObject>[] }[] = [];
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<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;
}
}
/** 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;
}
}