Files
feuerwerk/src/editor/state.ts
T
adminandClaude Opus 5.5 4ea752feef
game-maker / gate (push) Successful in 3m22s
game-maker / suite (push) Failing after 13m18s
Draw notes for an instrument in a piano roll and loop them #333
Notes could only be played one at a time, by a key or a behavior. A
pattern is a stretch of notes for one instrument, drawn in a piano
roll and played in time through the scheduler of #332. Songs (#334)
will lay patterns out on tracks.

- New asset type pattern under the heading Patterns. Instruments are
  listed under Instruments now, since a heading is one per type.
- Notes are a start and a length in ticks, a note number and a
  velocity, kept to their rules on load and in every command. The
  instrument, the length in bars, the grid, the meter and the tempo
  are rows in the inspector. The loop is the asset's own data, the
  whole pattern while none is set.
- Grids from 1/1 to 1/32 and the triplets 1/4T to 1/32T, all whole
  ticks. The meter only moves the bar lines, the notes stay put.
- The tab shows the keys down the left, a ruler with bars, beats,
  loop and start along the top, the notes between them and a
  velocity lane below. Keys the instrument plays nothing for are
  grey, the names of a drum kit stand on its keys.
- A click draws a note on the grid line before the pointer, as long
  as the last one drawn or lengthened and as hard as the last
  velocity set. The body moves a note, the right end lengthens it,
  Alt leaves the grid. A drag on empty grid picks with a band, Shift
  adds, a right-click deletes.
- Keys: Space plays, Delete, Ctrl+C/V/D/A, the arrows by a semitone
  or a grid step and with Shift by an octave or a bar. A paste lands
  where playing starts. Every gesture is one undo step.
- A click on the ruler sets where playing starts, a drag the loop or
  one of its ends, its menu loops a bar or the whole pattern.
- The wheel scrolls, Shift across, on the ruler or with Alt it zooms.
  Ctrl+wheel stays with the browser, Pixi listens passively.
- Play runs the loop through the scheduler. An edit goes to it on
  the next window without a restart, a new loop or tempo counts from
  what is planned. Another pattern shown or a project opened stops it.
- The time axis and the ruler are their own pieces in editor/music
  for the song tab. The rules of the roll are pure functions in
  rollView and rollEdit.

Findings:
- Music.play could start after a stop that came while it was still
  loading its files. A run counter makes the late start give up.
  Music.edit now loads what a new note needs as well.
- A click on an end of the loop would have set the loop again. A
  press there only moves the end once it is dragged.

Tests:
- pattern.test.ts (new, 9) and piano-roll.test.ts (new, 16): model,
  grids, meter, held notes, sequence, commands, loop, round trip,
  drum names, time lines, rows, hits, bands, lane, edit rules.
- e2e 64 (new) on melektaus-atx: ALL PASSED, 20 checks. e2e 4 with
  29 asset types: ALL PASSED, 62 checks.
- Gate on melektaus-atx: CHECK EXIT 0, 247 test files, 3839 tests.
- Counter-checks: the move without its clamp, the sequence without
  the end of the pattern, the duplicate offset without whole grid
  steps each fail 1 test. Without passing edits to the scheduler the
  e2e check for a note drawn while playing fails, the rest passes.
- Probe on stein2: the tab with a drum kit, before and while playing,
  looked at in two screenshots.

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

941 lines
37 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[] }
| 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,
},
);
}
}
}
/** 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);
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));
}