Files
feuerwerk/src/editor/state.ts
T

689 lines
26 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 { 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]);
}