A sound could only be cut to one stretch, set on the asset itself. To
play two parts of one file, the file had to be imported twice. Now a
sound is always the whole file, and its clips are the stretches of it,
each with a name and a cut of its own.
- A clip is { id, name, props } on SoundAsset.clips. Its id shares the
generator with asset ids, so a sound field holds a sound or a clip,
as a texture field holds a texture or a frame.
- props are the cut: start, end, the fades and their curves, reverse.
Volume, loop, sound FX and bus stay on the sound for all its clips.
The cut rows left the sound's schema for CLIP_ROWS.
- findSound resolves either id. Play sound, the Sample of an
instrument, a Reverb impulse, song items, the scheduler and the test
sound of the Sound FX tab all go through it.
- Every sound field offers each sound followed by its clips, "Kick",
"Kick / Clip 1". A clip that is gone reads as "<id>?".
- The Sound tab has a strip under the ruler with the clips as bands.
A drag there makes a clip, a click picks one, beside the bands the
sound. Only the picked clip has handles. Reverse, fades and curves
in the header work on it and are greyed out without one.
- Whole file is gone from the header, as the user asked.
- Picking a clip is a selection of its own, like a frame. The
inspector shows its name, its cut rows and Delete clip. Delete
takes it away without asking, as with a frame. Ctrl+Z brings it
back.
- Copies get fresh clip ids: Duplicate, Embed a copy, a song
duplicated with its takes, and the copy a split or paste makes of
an embedded take. An item on a clip plays the same clip of its copy.
- A double-click on a song item playing a clip opens its sound with
the clip picked. The Sound row of an item on an embedded take
offers the take and its clips, which no list shows.
- A new take stays the whole sound. Clips are made by hand, as the
user decided.
- No migration in the code, as the user decided. The one project
with cut sounds was changed over by hand.
Deviation from the issue: deleting a used clip does not warn. A frame
does not warn either, and the issue had promised "like a frame".
Tests:
- Unit sound-clips (new): 11 green, 7 counter-checks each red
- Unit sounds, embedded: adjusted to clips, green
- e2e 34 (melektaus-atx): ALL PASSED, 90 checks (3 new, 12 rewritten)
- Counter-check in the browser: clips left out of the sound fields,
the picker check fails
- Gate (melektaus-atx): CHECK EXIT 0, 271 files, 4194 tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
14 KiB
TypeScript
300 lines
14 KiB
TypeScript
/**
|
||
* Editor actions that several places trigger the same way (inspector, hierarchy context
|
||
* menu, keyboard): they run the command and put the selection where the user expects it.
|
||
*/
|
||
import { RemoveAnimationKeysCommand, RemoveAnimationTracksCommand } from '../core/commands/animationCommands';
|
||
import { annotationNoun, RemoveAnnotationCommand } from '../core/commands/annotationCommands';
|
||
import { DeleteAssetCommand, SetActiveSceneCommand } from '../core/commands/assetCommands';
|
||
import {
|
||
AddColliderCommand,
|
||
AddJointCommand,
|
||
AddRigidbodyCommand,
|
||
colliderRefusal,
|
||
jointRefusal,
|
||
rigidbodyRefusal,
|
||
} from '../core/commands/colliderCommands';
|
||
import { oneStep } from '../core/commands/CompoundCommand';
|
||
import { CreateContainerCommand } from '../core/commands/containerCommands';
|
||
import { RemoveClipCommand } from '../core/commands/clipCommands';
|
||
import { RemoveFrameCommand } from '../core/commands/frameCommands';
|
||
import { DisconnectCommand, FoldEdgeCommand, RemoveNodeCommand } from '../core/commands/graphCommands';
|
||
import { RemoveSceneObjectsCommand } from '../core/commands/sceneObjectCommands';
|
||
import { RemoveVectorShapeCommand, RemoveVectorShapesCommand } from '../core/commands/vectorArtCommands';
|
||
import type { AssetType } from '../core/model/AssetTypes';
|
||
import { curveUsages } from '../core/model/curve';
|
||
import { structTypeUsages } from '../core/model/structTypes';
|
||
import { generatesPictures } from '../core/model/generated';
|
||
import { isTemplateContent } from '../core/model/instance';
|
||
import type { Command } from '../core/commands/Command';
|
||
import { findAnnotation, findNode, nodeDef } from '../core/graph';
|
||
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||
import { ownerSong } from '../core/music/embedded';
|
||
import type { SceneObject } from '../core/model/SceneObject';
|
||
import { pixelArtRefIds } from '../core/model/pixelart';
|
||
import { textureRefIds } from '../core/model/texture';
|
||
import {
|
||
animationsUsing,
|
||
cellsPainted,
|
||
tilesUsing,
|
||
findPropertyUsages,
|
||
findUsages,
|
||
} from '../core/model/usages';
|
||
import { Dialog } from '../ui';
|
||
import { removeSongItems, removeTrack } from './song/songActions';
|
||
import { type EditorState, graphSelection, objectSelection } from './state';
|
||
|
||
/**
|
||
* What a double-click on an asset does, wherever it happens (asset panel, "Open …" entries, a link
|
||
* in the inspector): open its tab – and a scene, whose tab is the scene view, becomes the active one.
|
||
*/
|
||
export function activateAsset(state: EditorState, assetId: string): void {
|
||
if (findAsset(state.project, assetId)?.type === 'scene') state.execute(new SetActiveSceneCommand(assetId));
|
||
state.openAsset(assetId);
|
||
}
|
||
|
||
/** Groups the objects into a new container asset placed in their stead; selects the container. */
|
||
export function createContainer(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||
const command = new CreateContainerCommand(holderId, objectIds);
|
||
if (!state.execute(command)) return false;
|
||
state.select(objectSelection(holderId, [command.objectId]));
|
||
return true;
|
||
}
|
||
|
||
/** The test `AddColliderCommand` makes; template content has no structure of its own to change. */
|
||
export function canAddCollider(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
|
||
return !isTemplateContent(holder, object.id) && colliderRefusal(project, holder, object) === null;
|
||
}
|
||
|
||
/**
|
||
* Gives the object a collider child – a new one sized like it, or the existing `assetId` (#222);
|
||
* selects the collider.
|
||
*/
|
||
export function addCollider(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
|
||
const command = new AddColliderCommand(holderId, objectId, assetId);
|
||
if (!state.execute(command)) return false;
|
||
state.select(objectSelection(holderId, [command.objectId]));
|
||
return true;
|
||
}
|
||
|
||
/** The test `AddRigidbodyCommand` makes; template content has no structure of its own to change. */
|
||
export function canAddRigidbody(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
|
||
return !isTemplateContent(holder, object.id) && rigidbodyRefusal(project, holder, object) === null;
|
||
}
|
||
|
||
/** Gives the object a rigidbody child, new or the existing `assetId`; selects the rigidbody. */
|
||
export function addRigidbody(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
|
||
const command = new AddRigidbodyCommand(holderId, objectId, assetId);
|
||
if (!state.execute(command)) return false;
|
||
state.select(objectSelection(holderId, [command.objectId]));
|
||
return true;
|
||
}
|
||
|
||
/** The test `AddJointCommand` makes; template content has no structure of its own to change. */
|
||
export function canAddJoint(project: Project, holder: ObjectHolder, object: SceneObject): boolean {
|
||
return !isTemplateContent(holder, object.id) && jointRefusal(project, holder, object) === null;
|
||
}
|
||
|
||
/** Gives the object a joint child (#154), new or the existing `assetId`; selects the joint. */
|
||
export function addJoint(state: EditorState, holderId: string, objectId: string, assetId = ''): boolean {
|
||
const command = new AddJointCommand(holderId, objectId, assetId);
|
||
if (!state.execute(command)) return false;
|
||
state.select(objectSelection(holderId, [command.objectId]));
|
||
return true;
|
||
}
|
||
|
||
/** Removes the objects (with their subtrees) as one undo step. */
|
||
export function removeObjects(state: EditorState, holderId: string, objectIds: readonly string[]): boolean {
|
||
return state.execute(new RemoveSceneObjectsCommand(holderId, objectIds));
|
||
}
|
||
|
||
/** Takes shapes of a vector art out as one undo step; the drawing itself stays selected. */
|
||
export function removeShapes(state: EditorState, assetId: string, shapeIds: readonly string[]): boolean {
|
||
if (shapeIds.length === 0) return false;
|
||
const removed = state.execute(
|
||
shapeIds.length === 1
|
||
? new RemoveVectorShapeCommand(assetId, shapeIds[0]!)
|
||
: new RemoveVectorShapesCommand(assetId, shapeIds),
|
||
);
|
||
if (removed) state.select({ kind: 'asset', assetId });
|
||
return removed;
|
||
}
|
||
|
||
/** Deletes graph nodes as one undo step, however many were selected. */
|
||
export function removeNodes(state: EditorState, assetId: string, nodeIds: readonly string[]): boolean {
|
||
const commands = nodeIds.map((id) => new RemoveNodeCommand(assetId, id));
|
||
return commands.length > 0 && state.execute(oneStep(`Delete ${commands.length} nodes`, commands));
|
||
}
|
||
|
||
/** What a link is called in this graph: between states it is a transition. */
|
||
export function linkNoun(state: EditorState, assetId: string): string {
|
||
return findGraphAsset(state.project, assetId)?.type === 'statemachine' ? 'transition' : 'link';
|
||
}
|
||
|
||
/**
|
||
* Hides the links as one undo step and lets go of them: a hidden link has no line left to be
|
||
* picked on, its mark at the port is the handle from then on.
|
||
*/
|
||
export function hideLinks(state: EditorState, assetId: string, edgeIds: readonly string[]): boolean {
|
||
const commands = edgeIds.map((id) => new FoldEdgeCommand(assetId, id, true));
|
||
if (
|
||
commands.length === 0 ||
|
||
!state.execute(oneStep(commands.length === 1 ? 'Hide link' : 'Hide links', commands))
|
||
)
|
||
return false;
|
||
state.select(
|
||
graphSelection(assetId, state.selectedNodeIds, state.selectedAnnotationIds) ?? { kind: 'asset', assetId },
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Deletes everything a graph selection holds – nodes, boxes and links alike – as one undo step,
|
||
* since one band caught them and one keypress asked for them to go. Boxes still take none of
|
||
* their nodes. Links go first, so one at a node that goes too is not asked for twice.
|
||
*/
|
||
export function removeGraphSelection(
|
||
state: EditorState,
|
||
assetId: string,
|
||
nodeIds: readonly string[],
|
||
annotationIds: readonly string[],
|
||
edgeIds: readonly string[] = [],
|
||
): boolean {
|
||
const graph = findGraphAsset(state.project, assetId)?.graph;
|
||
const links = (graph?.edges ?? []).filter((e) => edgeIds.includes(e.id));
|
||
// A node that comes and goes on its own – a track of a song (#350) – stays, the rest goes.
|
||
const removable = nodeIds.filter((id) => !nodeDef((graph && findNode(graph, id))?.type ?? '')?.fixed);
|
||
const commands: Command[] = [
|
||
...links.map((e) => new DisconnectCommand(assetId, e.id)),
|
||
...removable.map((id) => new RemoveNodeCommand(assetId, id)),
|
||
];
|
||
let noun = links.length === 1 ? linkNoun(state, assetId) : '';
|
||
for (const id of annotationIds) {
|
||
const found = graph && findAnnotation(graph, id);
|
||
if (!found) continue;
|
||
noun = annotationNoun(found.kind);
|
||
commands.push(new RemoveAnnotationCommand(assetId, found.kind, id));
|
||
}
|
||
if (commands.length === 0) {
|
||
if (nodeIds.length > 0) state.setNotice('These nodes come and go on their own and cannot be deleted');
|
||
return false;
|
||
}
|
||
const label =
|
||
commands.length === 1 && noun !== ''
|
||
? `Delete ${noun}`
|
||
: removable.length === commands.length
|
||
? `Delete ${commands.length} nodes`
|
||
: `Delete ${commands.length} items`;
|
||
return state.execute(oneStep(label, commands));
|
||
}
|
||
|
||
/**
|
||
* What Delete does to the selection; false when nothing is selected. Every kind is a case of its
|
||
* own, so a new kind of selection cannot slip through without saying what Delete takes away.
|
||
*/
|
||
export function removeSelection(state: EditorState): boolean {
|
||
const s = state.selection;
|
||
if (!s) return false;
|
||
switch (s.kind) {
|
||
case 'asset':
|
||
confirmDeleteAsset(state, s.assetId);
|
||
return true;
|
||
case 'object':
|
||
removeObjects(state, s.holderId, s.objectIds);
|
||
return true;
|
||
case 'node':
|
||
removeGraphSelection(state, s.assetId, s.nodeIds, s.annotationIds, s.edgeIds);
|
||
return true;
|
||
case 'annotation':
|
||
removeGraphSelection(state, s.assetId, [], s.annotationIds, s.edgeIds);
|
||
return true;
|
||
case 'edge':
|
||
removeGraphSelection(state, s.assetId, [], [], s.edgeIds);
|
||
return true;
|
||
case 'frame':
|
||
state.execute(new RemoveFrameCommand(s.assetId, s.frameId));
|
||
return true;
|
||
case 'clip':
|
||
state.execute(new RemoveClipCommand(s.assetId, s.clipId));
|
||
return true;
|
||
case 'shape':
|
||
removeShapes(state, s.assetId, s.shapeIds);
|
||
return true;
|
||
case 'track':
|
||
// The keys when some are picked, else the row itself – like a frame of a texture.
|
||
state.execute(
|
||
s.keys.length > 0
|
||
? new RemoveAnimationKeysCommand(s.assetId, s.keys)
|
||
: new RemoveAnimationTracksCommand(s.assetId, [s.trackId]),
|
||
);
|
||
return true;
|
||
case 'song':
|
||
// The items when some are picked, else the track itself and what lies on it.
|
||
if (s.itemIds.length > 0) removeSongItems(state, s.assetId, s.itemIds);
|
||
else removeTrack(state, s.assetId, s.trackId);
|
||
return true;
|
||
}
|
||
}
|
||
|
||
/** Asks before deleting an asset, spelling out what goes with it. */
|
||
export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||
const asset = findAsset(state.project, assetId);
|
||
if (!asset) return;
|
||
const song = ownerSong(state.project, asset);
|
||
if (song) {
|
||
state.setNotice(`"${asset.name}" lives in the song "${song.name}" and goes with what uses it there`);
|
||
return;
|
||
}
|
||
const usages = findUsages(state.project, assetId).length;
|
||
// A texture is never placed but worn through a property, so counting placed instances would
|
||
// report "unused". Its frames count as the texture itself.
|
||
const ids =
|
||
asset.type === 'texture'
|
||
? textureRefIds(asset)
|
||
: asset.type === 'pixelart'
|
||
? pixelArtRefIds(asset)
|
||
: [assetId];
|
||
const references = findPropertyUsages(state.project, ids).length;
|
||
// An asset that draws its own picture (#185) is worn the same way, only without frames.
|
||
const picture = asset.type === 'texture' || generatesPictures(asset.type);
|
||
const animations = picture ? animationsUsing(state.project, ids).length : 0;
|
||
const tiles = picture ? tilesUsing(state.project, ids).length : 0;
|
||
const eased = asset.type === 'curve' ? curveUsages(state.project, assetId) : { keys: 0, nodes: 0 };
|
||
const typed =
|
||
asset.type === 'struct' ? structTypeUsages(state.project, assetId) : { places: 0, lists: 0, nodes: 0 };
|
||
const lists = typed.lists > 0 ? `, ${typed.lists} list(s)` : '';
|
||
const painted = asset.type === 'tile' ? cellsPainted(state.project, assetId) : { tilemaps: 0, cells: 0 };
|
||
const detail =
|
||
asset.type === 'scene'
|
||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||
: painted.cells > 0
|
||
? `${painted.cells} cell(s) on ${painted.tilemaps} tilemap(s) are painted with it and become empty.`
|
||
: eased.keys > 0 || eased.nodes > 0
|
||
? `${eased.keys} animation key(s) and ${eased.nodes} Lerp node(s) use it and blend linearly instead.`
|
||
: typed.places > 0 || typed.lists > 0 || typed.nodes > 0
|
||
? `${typed.places} struct(s)${lists} and ${typed.nodes} node(s) use it and keep its fields as their own.`
|
||
: references > 0 || animations > 0 || tiles > 0
|
||
? wornDetail(references, animations, tiles, asset.type)
|
||
: usages === 0
|
||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||
: asset.type === 'container'
|
||
? `${usages} placed container(s) are removed together with their content.`
|
||
: `${usages} placed instance(s) are removed as well (scenes and container templates).`;
|
||
new Dialog(`Delete "${asset.name}"?`, detail, [
|
||
{ label: 'Cancel' },
|
||
{ label: 'Delete', danger: true, onClick: () => state.execute(new DeleteAssetCommand(assetId)) },
|
||
]).openCentered();
|
||
}
|
||
|
||
function wornDetail(sprites: number, animations: number, tiles: number, type: AssetType): string {
|
||
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
|
||
const who = [
|
||
sprites > 0 ? `${sprites} sprite(s)` : '',
|
||
animations > 0 ? `${animations} animation(s)` : '',
|
||
tiles > 0 ? `${tiles} tile(s)` : '',
|
||
].filter(Boolean);
|
||
// "a, b and c" rather than "a and b and c", which three kinds of user made reachable.
|
||
const list =
|
||
who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and ');
|
||
return `${list} reference it and show the plain color in its place.`;
|
||
}
|