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 = 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>(); 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): 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): 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)); }