Files
feuerwerk/src/editor/state.ts
T
adminandClaude Opus 5.5 8f9c250eb6
game-maker / gate (push) Successful in 3m29s
game-maker / suite (push) Successful in 16m29s
Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from
a behavior. A song lays patterns and sounds out on tracks along one
time axis and plays them through the scheduler of #332, the third
piece of the music tools of #330.

- New asset type song under the heading Songs, with four empty tracks,
  120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows
  in the inspector. Tracks, items and the loop are the asset's data,
  held to their rules on load and in every command.
- A track has a name, volume, pan, mute and solo. Each is heard on a
  bus of its own, so a change reaches what is sounding at once.
- A pattern item repeats its pattern for as long as it is, with a
  transpose and another instrument if wanted. A sound item plays its
  sound as a clip at its own speed, round and round, through a sound
  FX if wanted. Both have a volume on top of their track's.
- Cut back at the left end or split, an item starts as far into what
  it plays as the cut is: ticks into a pattern, seconds into a sound.
- The tab shows the heads with M, S, volume and pan, the ruler of the
  Pattern tab and the items, a pattern with its notes, a sound with
  its waveform, a missing asset red. Patterns and sounds are dragged
  in from the Assets panel. Body, right and left end move, lengthen
  and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits
  where playing starts, Delete, arrows. A double-click opens a
  pattern item in its tab.
- What is picked is a new selection of its own, so the inspector
  shows the item or the track, with a button to delete it.
- The scheduler plans clips beside notes. Where the playhead starts
  or jumps back inside a clip, it is taken up that far into its
  sound. A clip fades out at the end of its item or of the loop.
- The editor plays one piece at a time: a pattern started stops the
  song and the other way round.
- The rules are pure functions in core/music/song, editor/song/
  songView and songEdit.

Findings:
- The suites of 4ea752f and 2a3b90b were red at e2e 54: the Pattern
  pushed Struct below the fold of the + menu. The module now scrolls
  to it, as e2e 47 does for Vector art. It also left the menu open,
  which made e2e 55 fail behind it.
- The Assets panel only dragged assets a scene can place. Patterns
  and sounds are dragged now as well, and a scene says why it does
  not take one instead of calling every asset a scene.

Tests:
- song.test.ts (new, 14) and song-edit.test.ts (new, 9): model,
  round trip, sequence of patterns and clips, mute and solo, cuts,
  clips on the scheduler, moving, sizing, trimming, splitting,
  duplicating, hits, head parts. sound-library.test.ts +5: clip
  offset, end fade, pitched clip, track bus, peaks.
- e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and
  55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset
  types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests.
- Counter-checks: no taking up of a clip, no pattern offset, no clip
  end each fail 3, 1 and 1 tests.
- Probe on stein2: the tab with four tracks, before and while
  playing, looked at in two screenshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:28:25 +02:00

966 lines
39 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 type { KeyClipboard } from '../core/commands/animationCommands';
import type { EditorClipboard } from './clipboard';
import type { ObjectPick, PickEnd } from './objectPick';
import { findKey, findTrack, hasKey, type KeyRef, sameKey } from '../core/model/animation';
import { type Command, CommandError } from '../core/commands/Command';
import { CommandStack } from '../core/commands/CommandStack';
import type { Asset, GraphAsset, SceneAsset } from '../core/model/Asset';
import { findAnnotation, type Graph } from '../core/graph';
import { findObject } from '../core/model/hierarchy';
import { allShapes } from '../core/model/vectorTree';
import { resolvedHolder } from '../core/model/instance';
import {
createEmptyProject,
findAsset,
findAssetOf,
findGraphAsset,
findObjectHolder,
findScene,
type Project,
startScene,
} from '../core/model/Project';
import { deserializeProject, serializeProject } from '../core/store/serialize';
import { requestRender } from '../ui';
import { consoleLog } from './console/log';
import type { GameScale, PlayFrom } from '../core/model/playSettings';
import type { PlaceableAssetType } from '../core/model/AssetTypes';
import type { VisibilityPart } from '../core/model/sceneVisibility';
import type { ValuePath } from '../core/properties/valuePath';
export type Selection =
| { kind: 'asset'; assetId: string }
/**
* One or more objects of a scene or of a container's template (`holderId` names which).
* `objectId` is the primary one (the last picked – the inspector shows it); `objectIds`
* holds the whole selection, primary included.
*/
| { kind: 'object'; holderId: string; objectId: string; objectIds: string[] }
/**
* One or more nodes of a behavior's graph, `nodeId` being the primary one (as with objects).
* `annotationIds` holds the groups and comments caught alongside them: a rubber band spans
* whatever lies under it, and the two sorts move and die together from then on. `edgeIds` holds
* the links picked alongside (#289); they do not move, but Delete takes them too.
*/
| {
kind: 'node';
assetId: string;
nodeId: string;
nodeIds: string[];
annotationIds: string[];
edgeIds: string[];
}
/** A frame rectangle inside a texture asset (the Texture tab). */
| { kind: 'frame'; assetId: string; frameId: string }
/**
* One or more shapes of a vector art asset (the Vector art tab, #129). `shapeId` is the primary
* one, whose points and style are edited; `shapeIds` holds all of them for combining (#187).
*/
| { kind: 'shape'; assetId: string; shapeId: string; shapeIds: string[] }
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: string[]; edgeIds: string[] }
/** One or more links of a graph and nothing else, `edgeId` being the primary one (#289). */
| { kind: 'edge'; assetId: string; edgeId: string; edgeIds: string[] }
/**
* A row of an animation's timeline (the Animation tab), and the keys picked on it or on other
* rows: `keys` is empty when the row itself was clicked. `trackId` is the row the inspector
* describes – of several keys, the one picked last.
*/
| { kind: 'track'; assetId: string; trackId: string; keys: KeyRef[] }
/**
* A track of a song (the Song tab, #334) and the items picked on it or on others: `itemIds` is
* empty when the track itself was clicked. The inspector shows the item picked last, else the track.
*/
| { kind: 'song'; assetId: string; trackId: string; itemIds: string[] }
| null;
/**
* Where a list lives. Deliberately not a `Selection`: a list is not something one selects, it is a
* value inside something already selected, and exactly three places hold one. `path` leads to a
* list nested inside the value `key` names (#239): a key per struct field, an index per list item –
* `[3, 'drops']` is the list in field `drops` of item 3.
*/
export type ListSlot = { path?: ValuePath } & (
| { kind: 'asset'; assetId: string; key: string }
| { kind: 'object'; holderId: string; objectId: string; key: string }
| { kind: 'node'; assetId: string; nodeId: string; key: string }
);
/** Whose variables a Variables tab shows (#252): a behavior or Variables asset, or a placed behavior. */
export type VariablesOwner =
{ kind: 'asset'; assetId: string } | { kind: 'object'; holderId: string; objectId: string };
/** What a Variables tab shows: every variable of an owner, or one list at any depth. */
export type ValuesTarget = { kind: 'variables'; owner: VariablesOwner } | { kind: 'list'; slot: ListSlot };
/** Builds an object selection; the last id is the primary one unless given explicitly. */
export function objectSelection(holderId: string, objectIds: readonly string[], primary?: string): Selection {
const picked = pickIds(objectIds, primary);
return picked && { kind: 'object', holderId, objectId: picked.primary, objectIds: picked.ids };
}
/** Builds a node selection; the last id is the primary one unless given explicitly. */
export function nodeSelection(
assetId: string,
nodeIds: readonly string[],
primary?: string,
annotationIds: readonly string[] = [],
edgeIds: readonly string[] = [],
): Selection {
const picked = pickIds(nodeIds, primary);
return (
picked && {
kind: 'node',
assetId,
nodeId: picked.primary,
nodeIds: picked.ids,
annotationIds: [...new Set(annotationIds)],
edgeIds: [...new Set(edgeIds)],
}
);
}
/** Builds a shape selection; the last id is the primary one unless given explicitly. */
export function shapeSelection(assetId: string, shapeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(shapeIds, primary);
return picked && { kind: 'shape', assetId, shapeId: picked.primary, shapeIds: picked.ids };
}
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
export function annotationSelection(
assetId: string,
annotationIds: readonly string[],
primary?: string,
edgeIds: readonly string[] = [],
): Selection {
const picked = pickIds(annotationIds, primary);
return (
picked && {
kind: 'annotation',
assetId,
annotationId: picked.primary,
annotationIds: picked.ids,
edgeIds: [...new Set(edgeIds)],
}
);
}
/** Builds a selection of links alone; the last id is the primary one unless given explicitly. */
export function edgeSelection(assetId: string, edgeIds: readonly string[], primary?: string): Selection {
const picked = pickIds(edgeIds, primary);
return picked && { kind: 'edge', assetId, edgeId: picked.primary, edgeIds: picked.ids };
}
/**
* What a rubber band leaves selected: nodes win the kind when it caught any, so the inspector keeps
* showing a node where it did before, then boxes, and a band over links alone still selects them.
* `primary` names the one picked last, in whichever of the three lists it is.
*/
export function graphSelection(
assetId: string,
nodeIds: readonly string[],
annotationIds: readonly string[],
edgeIds: readonly string[] = [],
primary?: string,
): Selection {
return (
nodeSelection(assetId, nodeIds, primary, annotationIds, edgeIds) ??
annotationSelection(assetId, annotationIds, primary, edgeIds) ??
edgeSelection(assetId, edgeIds, primary)
);
}
/**
* Builds a track selection. Without keys it is the row itself; with keys the row is the one of the
* key picked last, unless `trackId` says otherwise. Duplicate keys collapse to one.
*/
export function trackSelection(assetId: string, keys: readonly KeyRef[], trackId?: string): Selection {
const unique: KeyRef[] = [];
for (const key of keys) if (!hasKey(unique, key)) unique.push(key);
const last = unique[unique.length - 1];
const row = trackId ?? last?.trackId;
return row ? { kind: 'track', assetId, trackId: row, keys: unique } : null;
}
function pickIds(input: readonly string[], primary?: string): { ids: string[]; primary: string } | null {
const ids = [...new Set(input)];
if (ids.length === 0) return null;
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
}
/**
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
* The others paint: brush the chosen tile cell by cell, erase clears, rect fills the box a drag
* spans, fill the connected cells of one tile (#133). Pick paints nothing – it takes the tile of the
* cell clicked. Painting is a mode on purpose; a map one cannot move would be worse than a click.
*/
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect' | 'fill' | 'pick';
/**
* How the scene view shows the layers of the selected tilemap next to the one painted on (#138):
* all as they are, the others faded, or the painted one alone. A way of looking while painting,
* so it leaves the model – and with it the game – untouched.
*/
export type TilemapFocus = 'all' | 'dim' | 'solo';
export interface TilemapPaint {
tool: TilemapTool;
tileId: string;
layerId: string;
focus: TilemapFocus;
}
export type EditorMode = 'edit' | 'play';
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
export type EditorEvent =
/** The model changed (command, undo, preview, load). */
| 'project'
/** A different project was loaded. */
| 'load'
| 'selection'
/** A Variables tab was asked for (`openedValues`). */
| 'values'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
| 'ping'
| 'mode'
| 'save'
| 'notice'
/** The eyedropper of an object's place started, moved its candidate or ended (`objectPick`). */
| 'pick'
/** The running world advanced a frame (play mode): values may have changed without a command. */
| 'runtime';
type Listener = () => void;
/** How a notice reaches the console panel: as a line, as a red line, or not at all. */
export type NoticeLevel = 'info' | 'error' | 'quiet';
/**
* Single source of truth for the editor. Play mode swaps `project` for a deep clone (same ids)
* with its own undo stack; panels and commands keep working on it, and `exitPlay()` discards it.
*/
export class EditorState {
project: Project = createEmptyProject('', 'Untitled');
revision = 0;
commands = new CommandStack(this.project);
selection: Selection = null;
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null;
mode: EditorMode = 'edit';
/**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
* still apply.
*/
paused = false;
saveStatus: SaveStatus = 'saved';
/** Last user-facing message (errors, hints). */
notice = '';
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
keyClipboard: KeyClipboard | null = null;
/** Objects or graph pieces copied with Ctrl+C (#240). Gone with the project, whose ids it holds. */
clipboard: EditorClipboard | null = null;
/**
* What the scene view paints a tilemap with (#128): the tool, the tile and the layer it goes
* on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of
* working rather than something the project remembers.
*/
tilemapPaint: TilemapPaint = { tool: 'select', tileId: '', layerId: '', focus: 'all' };
/** The asset the last `pingAsset` pointed at. */
pingedAssetId: string | null = null;
/** Objects the scene view is dragging: a running game holds their bodies to the pointer. */
heldObjectIds: ReadonlySet<string> = new Set();
/** The eyedropper of an object's place while it runs (#276). */
objectPick: ObjectPick | null = null;
/** How the last one ended – what a listener of the `pick` event finds once it is gone. */
pickEnd: PickEnd | null = null;
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
private edit: { project: Project; commands: CommandStack } | null = null;
private readonly listeners = new Map<EditorEvent, Set<Listener>>();
on(event: EditorEvent, listener: Listener): () => void {
let set = this.listeners.get(event);
if (!set) this.listeners.set(event, (set = new Set()));
set.add(listener);
return () => set.delete(listener);
}
emit(event: EditorEvent): void {
// The one choke point every model change passes: what a listener repaints is drawn even when
// it repaints without going through `invalidate()`.
requestRender();
for (const listener of this.listeners.get(event) ?? []) listener();
}
loadProject(project: Project, revision: number): void {
this.endObjectPick('drop');
this.edit = null;
this.mode = 'edit';
this.paused = false;
this.project = project;
this.revision = revision;
this.commands = new CommandStack(project);
this.selection = null;
this.openedValues = null;
this.clipboard = null;
this.saveStatus = 'saved';
this.emit('load');
this.emit('project');
this.emit('selection');
this.emit('save');
this.emit('mode');
}
/** Returns the runtime copy; the selection survives because ids are identical. */
enterPlay(): Project {
if (this.mode === 'play') return this.project;
this.endObjectPick('drop');
const copy = deserializeProject(serializeProject(this.project), this.project.id);
this.edit = { project: this.project, commands: this.commands };
this.project = copy;
this.commands = new CommandStack(copy);
this.mode = 'play';
this.paused = false;
this.emit('mode');
this.emit('project');
return copy;
}
/** Ignored outside play mode. */
setPaused(paused: boolean): void {
if (this.mode !== 'play' || this.paused === paused) return;
this.paused = paused;
this.emit('mode');
}
exitPlay(): void {
if (this.mode !== 'play') return;
this.endObjectPick('drop');
if (this.edit) {
this.project = this.edit.project;
this.commands = this.edit.commands;
this.edit = null;
}
this.mode = 'edit';
this.paused = false;
this.emit('mode');
this.emit('project');
this.validateSelection();
// A save postponed while playing runs now.
this.emit('save');
}
get activeScene(): SceneAsset | undefined {
return findScene(this.project, this.project.activeSceneId);
}
/**
* Which scene Play builds its world from. Two answers rather than one, because the project has
* two: where the game begins, and what you are working on this minute. The switch says which
* question Play is asking; without a start scene the two are the same thing anyway.
*/
sceneToPlay(): SceneAsset | undefined {
return this.playFrom === 'active' ? this.activeScene : startScene(this.project);
}
get playFrom(): PlayFrom {
return this.project.playFrom;
}
get gameScale(): GameScale {
return this.project.gameScale;
}
/**
* Saved with the project but not an undo step (#226): Ctrl+Z undoes edits, not a look at the game.
* While playing it goes into the saved model too, so stopping does not throw it away.
*/
setGameScale(scale: GameScale): void {
if (scale === this.gameScale) return;
this.project.gameScale = scale;
if (this.edit) this.edit.project.gameScale = scale;
this.setSaveStatus('dirty');
}
/** Shows or hides one part of a type in the scene view – saved, and like the game scale no undo step. */
setSceneVisibility(type: PlaceableAssetType, part: VisibilityPart, on: boolean): void {
if (this.project.sceneVisibility[type][part] === on) return;
this.project.sceneVisibility[type][part] = on;
if (this.edit) this.edit.project.sceneVisibility[type][part] = on;
this.setSaveStatus('dirty');
this.emit('project');
}
/**
* Runs a command through the undo stack. Returns false (and sets a notice) when invalid.
* In play mode the command changes the runtime copy only (see `enterPlay`).
*/
execute(command: Command): boolean {
return this.runEdit(() => {
this.commands.execute(command);
return true;
});
}
undo(): void {
this.endObjectPick('drop');
this.runEdit(() => this.commands.undo());
}
redo(): void {
this.endObjectPick('drop');
this.runEdit(() => this.commands.redo());
}
/**
* Runs one history operation. A `CommandError` is a command rejecting the edit, so it becomes
* a notice; anything else is a real defect and keeps travelling.
*/
private runEdit(operation: () => boolean): boolean {
try {
if (!operation()) return false;
} catch (error) {
if (error instanceof CommandError) {
this.setNotice(error.message);
return false;
}
throw error;
}
this.markDirty();
return true;
}
/**
* The model was mutated directly (live preview while scrubbing). Notifies listeners
* but does not touch the undo stack or the save status – the committing command does.
*/
preview(): void {
this.emit('project');
}
/** The world ticked: panels showing live values refresh (no model event, nothing to save). */
runtimeTick(): void {
this.emit('runtime');
}
/**
* A behavior running outside play mode (see `EditorRun`) wrote into the saved model. The change
* is saved like any other, but nothing was pushed onto the undo stack – it cannot be undone.
*/
runtimeEdit(): void {
this.setSaveStatus('dirty');
this.emit('runtime');
}
private markDirty(): void {
this.validateSelection();
// Play-mode edits live in the runtime copy and are never saved.
if (this.mode === 'edit') this.saveStatus = 'dirty';
this.emit('project');
this.emit('save');
}
/**
* Asks for a Variables tab on `target` – a new one, or the one already showing it. No tab knows
* the `Editor`, so this is how the button on a list or a VARIABLES header gets there.
*/
openValues(target: ValuesTarget): void {
this.openedValues = target;
this.emit('values');
}
/**
* Selects the asset and asks for its tab. Selecting alone never switches tabs (a click in the
* Assets panel is a look at the inspector); this is the way a double-click or a fresh asset gets
* there, and like `openValues` it works without knowing the `Editor`.
*/
openAsset(assetId: string): void {
this.select({ kind: 'asset', assetId });
this.emit('open');
}
/**
* Shows where an asset is listed without selecting it – what one click on a link in the inspector
* does (#221), so the inspector stays on the object the link belongs to.
*/
pingAsset(assetId: string): void {
this.pingedAssetId = assetId;
this.emit('ping');
}
select(selection: Selection): void {
// Test hooks pass the short form `{ kind: 'object', holderId, objectId }` without `objectIds`.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'object' && !selection.objectIds)
selection = objectSelection(selection.holderId, [selection.objectId]);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (selection?.kind === 'shape' && !selection.shapeIds)
selection = shapeSelection(selection.assetId, [selection.shapeId]);
// A graph selection carries two lists, and a short form may leave out either – `{ nodeId }`
// alone, or `{ nodeIds }` without the boxes. Filling both here keeps every reader of them
// from having to ask whether they are there.
if (selection?.kind === 'node')
selection = nodeSelection(
selection.assetId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.nodeIds ?? [selection.nodeId],
selection.nodeId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [],
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.edgeIds ?? [],
);
if (selection?.kind === 'annotation')
selection = annotationSelection(
selection.assetId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.annotationIds ?? [selection.annotationId],
selection.annotationId,
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection.edgeIds ?? [],
);
if (selection?.kind === 'edge')
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection = edgeSelection(selection.assetId, selection.edgeIds ?? [selection.edgeId], selection.edgeId);
// The short form `{ trackId }` names a row without keys.
if (selection?.kind === 'track')
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
selection = trackSelection(selection.assetId, selection.keys ?? [], selection.trackId);
if (sameSelection(this.selection, selection)) return;
this.endObjectPick('drop');
this.selection = selection;
this.emit('selection');
}
/** Starts the eyedropper of an object's place (#276); one running already is dropped for it. */
startObjectPick(pick: ObjectPick): void {
const before = this.objectPick;
this.objectPick = pick;
this.pickEnd = null;
before?.ended();
this.emit('pick');
}
/** Moves the eyedropper's candidate; nothing is written until `endObjectPick('take')`. */
movePick(candidate: string | null): void {
const pick = this.objectPick;
if (!pick || pick.candidate === candidate) return;
pick.candidate = candidate;
this.emit('pick');
}
/**
* Ends the eyedropper. Only `take` writes, and only a candidate the dropdown still offers and that
* is not the target already – a pick that changes nothing leaves no undo step.
*/
endObjectPick(how: PickEnd): void {
const pick = this.objectPick;
if (!pick) return;
this.objectPick = null;
this.pickEnd = how;
pick.ended();
this.emit('pick');
if (how !== 'take') return;
const ref = pick.choices().find((c) => c.objectId === pick.candidate)?.ref;
if (ref !== undefined && ref !== pick.current) pick.take(ref);
}
toggleObject(holderId: string, objectId: string): void {
const s = this.selection;
const current = s?.kind === 'object' && s.holderId === holderId ? s.objectIds : [];
const next = current.includes(objectId)
? current.filter((id) => id !== objectId)
: [...current, objectId];
this.select(objectSelection(holderId, next, current.includes(objectId) ? undefined : objectId));
}
/** Ctrl+click on a shape: in or out of the selection; emptied, the drawing itself stays selected. */
toggleShape(assetId: string, shapeId: string): void {
const s = this.selection;
const current = s?.kind === 'shape' && s.assetId === assetId ? s.shapeIds : [];
const held = current.includes(shapeId);
const next = held ? current.filter((id) => id !== shapeId) : [...current, shapeId];
this.select(shapeSelection(assetId, next, held ? undefined : shapeId) ?? { kind: 'asset', assetId });
}
get selectedShapeIds(): string[] {
return this.selection?.kind === 'shape' ? this.selection.shapeIds : [];
}
toggleNode(assetId: string, nodeId: string): void {
const s = this.selection;
const current = s?.kind === 'node' && s.assetId === assetId ? s.nodeIds : [];
const held = current.includes(nodeId);
const next = held ? current.filter((id) => id !== nodeId) : [...current, nodeId];
// Boxes and links caught alongside stay held: the toggle speaks about this node, not about them.
const mine = s?.kind === 'node' && s.assetId === assetId;
const boxes = mine ? s.annotationIds : [];
const edges = this.graphHolds(assetId) ? this.selectedEdgeIds : [];
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
this.select(
graphSelection(assetId, next, boxes, edges, held ? undefined : nodeId) ?? { kind: 'asset', assetId },
);
}
/** Ctrl+click on a link (#289): in or out of the selection, whatever else of the graph is held. */
toggleEdge(assetId: string, edgeId: string): void {
const mine = this.graphHolds(assetId);
const current = mine ? this.selectedEdgeIds : [];
const held = current.includes(edgeId);
const next = held ? current.filter((id) => id !== edgeId) : [...current, edgeId];
const s = this.selection;
// A node or box already shown in the inspector stays the primary one.
const primary = s?.kind === 'node' ? s.nodeId : s?.kind === 'annotation' ? s.annotationId : undefined;
this.select(
graphSelection(
assetId,
mine ? this.selectedNodeIds : [],
mine ? this.selectedAnnotationIds : [],
next,
mine ? (primary ?? (held ? undefined : edgeId)) : edgeId,
) ?? { kind: 'asset', assetId },
);
}
/** Whether the selection is a node, box or link of this graph. */
private graphHolds(assetId: string): boolean {
const s = this.selection;
return (s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge') && s.assetId === assetId;
}
get selectedObjectIds(): string[] {
return this.selection?.kind === 'object' ? this.selection.objectIds : [];
}
get selectedNodeIds(): string[] {
return this.selection?.kind === 'node' ? this.selection.nodeIds : [];
}
/** The groups and comments selected, whether alongside nodes or on their own. */
get selectedAnnotationIds(): string[] {
const s = this.selection;
if (s?.kind === 'node') return s.annotationIds;
return s?.kind === 'annotation' ? s.annotationIds : [];
}
/** The links selected, whether alongside nodes and boxes or on their own. */
get selectedEdgeIds(): string[] {
const s = this.selection;
return s?.kind === 'node' || s?.kind === 'annotation' || s?.kind === 'edge' ? s.edgeIds : [];
}
/** Drops selected targets that no longer exist (after delete/undo). */
private validateSelection(): void {
const s = this.selection;
if (!s) return;
if (s.kind === 'asset' && !findAsset(this.project, s.assetId)) this.select(null);
if (s.kind === 'object') {
const holder = findObjectHolder(this.project, s.holderId);
const view = holder && resolvedHolder(this.project, holder);
const alive = view ? s.objectIds.filter((id) => findObject(view, id)) : [];
if (alive.length !== s.objectIds.length) this.select(objectSelection(s.holderId, alive, s.objectId));
}
if (s.kind === 'node') {
const asset = findGraphAsset(this.project, s.assetId);
// Fall back to the asset so the graph stays open when its node vanished (undo).
if (!asset) this.select(null);
else {
const alive = s.nodeIds.filter((id) => asset.graph.nodes.some((n) => n.id === id));
const boxes = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
const edges = liveEdges(asset.graph, s.edgeIds);
if (
alive.length !== s.nodeIds.length ||
boxes.length !== s.annotationIds.length ||
edges.length !== s.edgeIds.length
)
this.select(
graphSelection(
s.assetId,
alive,
boxes,
edges,
alive.includes(s.nodeId) ? s.nodeId : undefined,
) ?? {
kind: 'asset',
assetId: s.assetId,
},
);
}
}
if (s.kind === 'edge') {
const asset = findGraphAsset(this.project, s.assetId);
// Same fallback as a node: the graph stays open when the link it held vanished (undo).
if (!asset) this.select(null);
else {
const alive = liveEdges(asset.graph, s.edgeIds);
if (alive.length !== s.edgeIds.length)
this.select(edgeSelection(s.assetId, alive, s.edgeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'annotation') {
const asset = findGraphAsset(this.project, s.assetId);
// Same fallback as a node: the graph stays open when the box it held vanished (undo).
if (!asset) this.select(null);
else {
const alive = s.annotationIds.filter((id) => findAnnotation(asset.graph, id));
const edges = liveEdges(asset.graph, s.edgeIds);
if (alive.length !== s.annotationIds.length || edges.length !== s.edgeIds.length)
this.select(
annotationSelection(s.assetId, alive, s.annotationId, edges) ??
edgeSelection(s.assetId, edges) ?? { kind: 'asset', assetId: s.assetId },
);
}
}
if (s.kind === 'frame') {
const asset = findAssetOf(this.project, s.assetId, 'texture');
// Same fallback as a graph node: the texture stays open when its frame vanished.
if (!asset) this.select(null);
else if (!asset.frames.some((f) => f.id === s.frameId))
this.select({ kind: 'asset', assetId: s.assetId });
}
if (s.kind === 'shape') {
const asset = findAssetOf(this.project, s.assetId, 'vectorart');
// As with a frame: the drawing stays open when its shapes vanished (a combine, an undo).
if (!asset) this.select(null);
else {
const shapes = allShapes(asset);
const alive = s.shapeIds.filter((id) => shapes.some((shape) => shape.id === id));
if (alive.length !== s.shapeIds.length)
this.select(shapeSelection(s.assetId, alive, s.shapeId) ?? { kind: 'asset', assetId: s.assetId });
}
}
if (s.kind === 'track') {
const asset = findAssetOf(this.project, s.assetId, 'animation');
if (!asset) this.select(null);
else {
// A key that moved or died leaves the selection; a row that died takes its keys with it.
const alive = s.keys.filter((key) => findKey(asset, key));
const rowAlive = findTrack(asset, s.trackId) !== undefined;
if (alive.length !== s.keys.length || !rowAlive)
this.select(
trackSelection(s.assetId, alive, rowAlive ? s.trackId : undefined) ?? {
kind: 'asset',
assetId: s.assetId,
},
);
}
}
if (s.kind === 'song') {
const asset = findAssetOf(this.project, s.assetId, 'song');
if (!asset) this.select(null);
else {
// An item that died leaves the selection; a track that died hands it to the song.
const items = s.itemIds.filter((id) => asset.items.some((item) => item.id === id));
const trackAlive = asset.tracks.some((track) => track.id === s.trackId);
if (!trackAlive) this.select({ kind: 'asset', assetId: s.assetId });
else if (items.length !== s.itemIds.length) this.select({ ...s, itemIds: items });
}
}
}
/** The song the selection refers to (#334): the asset, or a track or items of it. */
get selectedSongId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset' && s?.kind !== 'song') return null;
return findAssetOf(this.project, s.assetId, 'song')?.id ?? null;
}
/** The texture asset the current selection refers to (the asset itself or one of its frames). */
get selectedTextureId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind !== 'asset' && s.kind !== 'frame') return null;
return findAssetOf(this.project, s.assetId, 'texture')?.id ?? null;
}
/** The animation the selection refers to: the asset, or a row or key of its timeline. */
get selectedAnimationId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset' && s?.kind !== 'track') return null;
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
}
/** The vector drawing the selection is about – the asset itself or one shape of it (#129). */
get selectedVectorArtId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset' && s?.kind !== 'shape') return null;
return findAssetOf(this.project, s.assetId, 'vectorart')?.id ?? null;
}
/** The tile the selection refers to; only the asset itself, it has nothing inside to pick. */
get selectedTileId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
return findAssetOf(this.project, s.assetId, 'tile')?.id ?? null;
}
/** The curve the selection refers to (#247); only the asset itself, its points are picked in the tab. */
get selectedCurveId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
return findAssetOf(this.project, s.assetId, 'curve')?.id ?? null;
}
/** The pattern the selection refers to (#333); only the asset itself, its notes are picked in the tab. */
get selectedPatternId(): string | null {
const s = this.selection;
if (s?.kind !== 'asset') return null;
return findAssetOf(this.project, s.assetId, 'pattern')?.id ?? null;
}
/** The timeline row the selection is about; undefined when it is about something else. */
get selectedTrackId(): string | undefined {
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
}
/** The keys picked in the Animation tab, none when the selection is about something else. */
get selectedKeys(): KeyRef[] {
return this.selection?.kind === 'track' ? this.selection.keys : [];
}
/** The behavior asset the current selection refers to (asset, placed object or node), if any. */
get selectedBehaviorId(): string | null {
return this.selectedGraphId('behavior');
}
/** The state machine the current selection refers to: the asset, or one of its states. */
get selectedStateMachineId(): string | null {
return this.selectedGraphId('statemachine');
}
/** The shader or shader function the current selection refers to: the asset, or one of its nodes. */
get selectedShaderId(): string | null {
return this.selectedGraphId('shader') ?? this.selectedGraphId('shaderfunction');
}
/** The particle asset the selection refers to: the asset, a placed source, or one of its nodes. */
get selectedParticlesId(): string | null {
return this.selectedGraphId('particles');
}
/** The sound FX asset the selection refers to: the asset, or one of its nodes. */
get selectedSoundFxId(): string | null {
return this.selectedGraphId('soundfx');
}
/** The instrument the selection refers to: the asset, or one of its nodes. */
get selectedInstrumentId(): string | null {
return this.selectedGraphId('instrument');
}
private selectedGraphId(type: GraphAsset['type']): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'frame') return null;
// A node names its asset; which tab that is depends on what the asset is. A group, a comment
// and a link name it the same way, so selecting one keeps its graph on screen.
const asset =
s.kind === 'node' || s.kind === 'annotation' || s.kind === 'edge'
? findAsset(this.project, s.assetId)
: this.assetBehind(s);
return asset && asset.type === type ? asset.id : null;
}
/**
* The container the current selection refers to: the asset itself, a placed container, or the
* template being edited – selecting one of its objects keeps the Container tab on it.
*/
get selectedContainerId(): string | null {
const s = this.selection;
if (!s) return null;
if (s.kind === 'object' && findAsset(this.project, s.holderId)?.type === 'container') return s.holderId;
const asset = this.assetBehind(s);
return asset && asset.type === 'container' ? asset.id : null;
}
/** The asset a selection stands for: itself, or the one a selected object was placed from. */
private assetBehind(s: NonNullable<Selection>): Asset | undefined {
if (s.kind === 'asset') return findAsset(this.project, s.assetId);
if (s.kind !== 'object') return undefined;
const holder = findObjectHolder(this.project, s.holderId);
const view = holder && resolvedHolder(this.project, holder);
const assetId = view && findObject(view, s.objectId)?.assetId;
return assetId ? findAsset(this.project, assetId) : undefined;
}
setSaveStatus(status: SaveStatus): void {
if (this.saveStatus === status) return;
this.saveStatus = status;
this.emit('save');
}
/**
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
* for the play status lines, which state a mode rather than report an event.
*/
/** Changes what the scene view paints with; the inspector marks it and the view repaints. */
setTilemapPaint(patch: Partial<TilemapPaint>): void {
this.tilemapPaint = { ...this.tilemapPaint, ...patch };
this.emit('selection');
}
setNotice(message: string, level: NoticeLevel = 'info'): void {
this.notice = message;
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
this.emit('notice');
}
}
/** Whether two selections mean the same thing (same kind, same ids, same order). */
export function sameSelection(a: Selection, b: Selection): boolean {
if (a === b) return true;
if (!a || !b || a.kind !== b.kind) return false;
if (a.kind === 'asset' && b.kind === 'asset') return a.assetId === b.assetId;
if (a.kind === 'object' && b.kind === 'object') {
return (
a.holderId === b.holderId &&
a.objectId === b.objectId &&
a.objectIds.length === b.objectIds.length &&
a.objectIds.every((id, i) => id === b.objectIds[i])
);
}
if (a.kind === 'node' && b.kind === 'node') {
return (
a.assetId === b.assetId &&
a.nodeId === b.nodeId &&
sameIds(a.nodeIds, b.nodeIds) &&
sameIds(a.annotationIds, b.annotationIds) &&
sameIds(a.edgeIds, b.edgeIds)
);
}
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
if (a.kind === 'shape' && b.kind === 'shape')
return a.assetId === b.assetId && a.shapeId === b.shapeId && sameIds(a.shapeIds, b.shapeIds);
if (a.kind === 'track' && b.kind === 'track')
return (
a.assetId === b.assetId &&
a.trackId === b.trackId &&
a.keys.length === b.keys.length &&
a.keys.every((key, i) => sameKey(key, b.keys[i]!))
);
if (a.kind === 'annotation' && b.kind === 'annotation')
return (
a.assetId === b.assetId &&
a.annotationId === b.annotationId &&
sameIds(a.annotationIds, b.annotationIds) &&
sameIds(a.edgeIds, b.edgeIds)
);
if (a.kind === 'edge' && b.kind === 'edge')
return a.assetId === b.assetId && a.edgeId === b.edgeId && sameIds(a.edgeIds, b.edgeIds);
if (a.kind === 'song' && b.kind === 'song')
return a.assetId === b.assetId && a.trackId === b.trackId && sameIds(a.itemIds, b.itemIds);
return false;
}
/** Same ids in the same order – the order carries which one was picked last. */
function sameIds(a: readonly string[], b: readonly string[]): boolean {
return a.length === b.length && a.every((id, i) => id === b[i]);
}
/** The ids that still name a link of the graph. */
function liveEdges(graph: Graph, ids: readonly string[]): string[] {
return ids.filter((id) => graph.edges.some((e) => e.id === id));
}