Files
feuerwerk/src/editor/actions.ts
T
adminandClaude Opus 5.5 daeffe0f9d
game-maker / gate (push) Successful in 3m47s
game-maker / suite (push) Failing after 14m20s
Route the tracks of a song through a graph (#350)
Every track of a song went straight into its output. Now the Song tab
has a second view, Routing: a graph with a node per track and one
Output, the links between them sends. A track can go into other
tracks, into several at once, and a link has its own volume and the
point of the track it taps.

- The song asset has a graph like an instrument (SongTrack, SongOutput).
  heldSongGraph keeps it in step with the tracks on load and in
  SetSongCommand: a node per track named after it, one output, a new
  track and every track of an old song linked to the output. Links of
  gone tracks, doubled links and links closing a loop are dropped.
- A link carries an optional send (volume in dB, tap). The inspector
  shows Volume and Mode for a picked link of a song: Post volume (Post
  pan), Pre volume (Post FX), Pre volume (Pre FX). SetSendCommand.
- NodeDef has two new flags. `fixed` nodes are never added or deleted
  by hand. Links between `acyclic` nodes may not close a loop, checked
  in canConnect, dropTargetPort and ConnectCommand.
- songBuses gives each track its sends instead of one `into`, and a
  bus `song:<song>:<track>:#items` for its own items. That bus is what
  solo closes on a track that only passes a soloed one on.
- Mute and solo now act on the sends (trackGates). A muted track passes
  nothing on, on any tap. A soloed track is heard with what goes into
  it, through the tracks it goes through, their own items silent.
  Only sends on a way through a soloed track stay open, so a track
  going into it and into the output is heard through it only.
- SoundLibrary: a bus has either `into` or sends. Each send is tap,
  delay, level, target. busLatency takes the latest way (arrivalOf),
  and each send is held back so all ways of a track arrive together
  (sendDelays). Taps are wired again whenever a chain comes or goes.
- The Song tab has Timeline and Routing in its header. The routing
  uses GraphCanvas with an empty palette. A track linked nowhere shows
  "Goes nowhere". Delete takes links, never a track node.
- Game playings route the same way, the output being the master of
  the playing.

Decided without asking: a send ranges -60 to +12 dB like a track,
default 0 dB Post. Mute closes the sends, the track's own level stays.

Found on the way: the inspector built the Mode row with the send as it
was when the rows were made. Setting Volume and then Mode wrote the old
volume back. Both rows now read the current send when they change.

Tests:
- Unit song-routing (new): 13 green. Sound library: 4 new, 94 green.
  11 counter-probes (loop on load, canConnect, solo, solo past the
  soloed track, mute, preFx latency, graph sync, delete guard, delays,
  taps, glide), each red.
- Existing tests adjusted to the new bus names and send shape: song,
  bus-fx, song-play, graph registry, edge description.
- e2e 65 on melektaus-atx: ALL PASSED, 43 checks (5 new). The first
  run caught the inspector bug. Counter-probe with the old closure:
  2 FAILED.
- Gate on melektaus-atx: typecheck, lint, format clean, 2 tests red
  (registry rule, edge shape), fixed and rerun locally: 56 green.
- Not rerun after the per-send solo fix: e2e 65 and the gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:18:09 +02:00

290 lines
13 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.
/**
* 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 { 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 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 '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 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.`;
}