689 lines
26 KiB
TypeScript
689 lines
26 KiB
TypeScript
import type { KeyClipboard } from '../core/commands/animationCommands';
|
||
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, SceneAsset } from '../core/model/Asset';
|
||
import { findAnnotation } from '../core/graph';
|
||
import { findObject } from '../core/model/hierarchy';
|
||
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 PlayFrom, settings, updateSettings } from './settings';
|
||
|
||
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.
|
||
*/
|
||
| { kind: 'node'; assetId: string; nodeId: string; nodeIds: string[]; annotationIds: string[] }
|
||
/** A frame rectangle inside a texture asset (the Texture tab). */
|
||
| { kind: 'frame'; assetId: string; frameId: string }
|
||
/** One or more groups or comments of a graph, `annotationId` being the primary one. */
|
||
| { kind: 'annotation'; assetId: string; annotationId: string; annotationIds: 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;
|
||
|
||
/**
|
||
* Which list the List tab edits. 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.
|
||
* `field` names a list held by a struct field one level down; a struct item of a list needs no
|
||
* such step, because the List tab renders it inline.
|
||
*/
|
||
export type ListSlot = { field?: string } & (
|
||
| { kind: 'asset'; assetId: string; key: string }
|
||
| { kind: 'object'; holderId: string; objectId: string; key: string }
|
||
| { kind: 'node'; assetId: string; nodeId: string; key: string }
|
||
);
|
||
|
||
function sameSlot(a: ListSlot | null, b: ListSlot | null): boolean {
|
||
if (!a || !b) return a === b;
|
||
if (a.field !== b.field) return false;
|
||
switch (a.kind) {
|
||
case 'asset':
|
||
return b.kind === 'asset' && b.assetId === a.assetId && b.key === a.key;
|
||
case 'object':
|
||
return b.kind === 'object' && b.holderId === a.holderId && b.objectId === a.objectId && b.key === a.key;
|
||
case 'node':
|
||
return b.kind === 'node' && b.assetId === a.assetId && b.nodeId === a.nodeId && b.key === a.key;
|
||
}
|
||
}
|
||
|
||
/** 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[] = [],
|
||
): Selection {
|
||
const picked = pickIds(nodeIds, primary);
|
||
return (
|
||
picked && {
|
||
kind: 'node',
|
||
assetId,
|
||
nodeId: picked.primary,
|
||
nodeIds: picked.ids,
|
||
annotationIds: [...new Set(annotationIds)],
|
||
}
|
||
);
|
||
}
|
||
|
||
/** Builds an annotation selection; the last id is the primary one unless given explicitly. */
|
||
export function annotationSelection(
|
||
assetId: string,
|
||
annotationIds: readonly string[],
|
||
primary?: string,
|
||
): Selection {
|
||
const picked = pickIds(annotationIds, primary);
|
||
return picked && { kind: 'annotation', assetId, annotationId: picked.primary, annotationIds: 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, and a band over boxes alone still selects them.
|
||
*/
|
||
export function graphSelection(
|
||
assetId: string,
|
||
nodeIds: readonly string[],
|
||
annotationIds: readonly string[],
|
||
): Selection {
|
||
return (
|
||
nodeSelection(assetId, nodeIds, undefined, annotationIds) ?? annotationSelection(assetId, annotationIds)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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'
|
||
/** The List tab was pointed at another list (or at none). */
|
||
| 'list'
|
||
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
|
||
| 'open'
|
||
| 'mode'
|
||
| 'save'
|
||
| 'notice'
|
||
/** 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;
|
||
listSlot: ListSlot | 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;
|
||
/**
|
||
* 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 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.edit = null;
|
||
this.mode = 'edit';
|
||
this.paused = false;
|
||
this.project = project;
|
||
this.revision = revision;
|
||
this.commands = new CommandStack(project);
|
||
this.selection = null;
|
||
this.listSlot = 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;
|
||
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;
|
||
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);
|
||
}
|
||
|
||
/** The play-mode copy while a run is on, else null – what a scene swap rebuilds its world from. */
|
||
get runtimeProject(): Project | null {
|
||
return this.mode === 'play' ? this.project : null;
|
||
}
|
||
|
||
/**
|
||
* 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 settings().playFrom;
|
||
}
|
||
|
||
setPlayFrom(playFrom: PlayFrom): void {
|
||
if (playFrom === this.playFrom) return;
|
||
updateSettings({ playFrom });
|
||
this.emit('mode');
|
||
}
|
||
|
||
/**
|
||
* 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.runEdit(() => this.commands.undo());
|
||
}
|
||
|
||
redo(): void {
|
||
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');
|
||
}
|
||
|
||
/**
|
||
* Points the List tab at a list. No tab knows the `Editor`, so this is how the button on a
|
||
* list row gets there: the state changes, the editor switches.
|
||
*/
|
||
openList(slot: ListSlot | null): void {
|
||
if (sameSlot(this.listSlot, slot)) {
|
||
if (slot) this.emit('list');
|
||
return;
|
||
}
|
||
this.listSlot = slot;
|
||
this.emit('list');
|
||
}
|
||
|
||
/**
|
||
* 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 `openList` it works without knowing the `Editor`.
|
||
*/
|
||
openAsset(assetId: string): void {
|
||
this.select({ kind: 'asset', assetId });
|
||
this.emit('open');
|
||
}
|
||
|
||
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]);
|
||
// 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 ?? [],
|
||
);
|
||
if (selection?.kind === 'annotation')
|
||
selection = annotationSelection(
|
||
selection.assetId,
|
||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||
selection.annotationIds ?? [selection.annotationId],
|
||
selection.annotationId,
|
||
);
|
||
// 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.selection = selection;
|
||
this.emit('selection');
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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 caught alongside stay held: the toggle speaks about this node, not about them.
|
||
const boxes = s?.kind === 'node' && s.assetId === assetId ? s.annotationIds : [];
|
||
// Emptied by the toggle, the behavior itself stays selected – the graph must not close.
|
||
this.select(
|
||
nodeSelection(assetId, next, held ? undefined : nodeId, boxes) ??
|
||
annotationSelection(assetId, boxes) ?? { kind: 'asset', 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 : [];
|
||
}
|
||
|
||
/** 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));
|
||
if (alive.length !== s.nodeIds.length || boxes.length !== s.annotationIds.length)
|
||
this.select(
|
||
nodeSelection(s.assetId, alive, s.nodeId, boxes) ??
|
||
annotationSelection(s.assetId, boxes) ?? { 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));
|
||
if (alive.length !== s.annotationIds.length)
|
||
this.select(
|
||
annotationSelection(s.assetId, alive, s.annotationId) ?? { 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 === '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 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 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 the current selection refers to: the asset, or one of its nodes. */
|
||
get selectedShaderId(): string | null {
|
||
return this.selectedGraphId('shader');
|
||
}
|
||
|
||
/** 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');
|
||
}
|
||
|
||
private selectedGraphId(type: 'behavior' | 'statemachine' | 'shader' | 'particles'): 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 or comment
|
||
// names it the same way, so selecting one keeps its graph on screen.
|
||
const asset =
|
||
s.kind === 'node' || s.kind === 'annotation' ? 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)
|
||
);
|
||
}
|
||
if (a.kind === 'frame' && b.kind === 'frame') return a.assetId === b.assetId && a.frameId === b.frameId;
|
||
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)
|
||
);
|
||
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]);
|
||
}
|