Part (a) of the animation rework: model, commands, saving, runtime and nodes. - AnimationAsset holds a root (sprite, text, container or scene) and rows; a row keys one property of the root or of an object inside it, keys sit on whole frames. - The last key decides the length (its frame counts as a frame of its own). The playhead runs between frames, so numbers and colors blend every tick even at 10 fps; everything else holds. - Playback gains `once-reset`, which puts back what was there before the start. A row can be relative, adding to where its target stood. - Play / Pause / Stop animation act on an object; each instance plays on its own, the later start wins on a shared property, and a behavior writing the same property still writes last. - A sprite no longer wears an animation through `texture`. "Create animation from texture" builds a sprite plus a texture row keyed frame by frame instead. - The Animation tab shows a placeholder until the timeline editor (part b) lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
248 lines
9.6 KiB
TypeScript
248 lines
9.6 KiB
TypeScript
import {
|
||
AddAnimationTrackCommand,
|
||
SetAnimationKeyCommand,
|
||
SetAnimationRootCommand,
|
||
} from '../../core/commands/animationCommands';
|
||
import {
|
||
CreateAssetCommand,
|
||
SetActiveSceneCommand,
|
||
SetStartSceneCommand,
|
||
} from '../../core/commands/assetCommands';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { nextAssetName } from '../../core/commands/naming';
|
||
import {
|
||
ASSET_TYPE_LABELS,
|
||
type AssetType,
|
||
hasColorSwatch,
|
||
isPlaceableType,
|
||
} from '../../core/model/AssetTypes';
|
||
import type { TextureAsset } from '../../core/model/Asset';
|
||
import type { PropertyBag } from '../../core/model/Properties';
|
||
import { findAsset } from '../../core/model/Project';
|
||
import { spriteFromTexture } from '../../core/properties/aspect';
|
||
import { assetDeviates } from '../../core/properties/deviation';
|
||
import type { FontStore } from '../../core/store/FontStore';
|
||
import type { SoundStore } from '../../core/store/SoundStore';
|
||
import type { TextureStore } from '../../core/store/TextureStore';
|
||
import {
|
||
Button,
|
||
ListView,
|
||
Menu,
|
||
type MenuEntry,
|
||
type MenuItem,
|
||
Panel,
|
||
ScrollContainer,
|
||
startDrag,
|
||
} from '../../ui';
|
||
import { assetMenuGroups } from './assetMenu';
|
||
import { confirmDeleteAsset } from '../actions';
|
||
import type { EditorState } from '../state';
|
||
import { openFontPicker } from '../font/FontPicker';
|
||
import { openSoundPicker } from '../sound/SoundPicker';
|
||
import { openImagePicker } from '../texture/ImagePicker';
|
||
|
||
/**
|
||
* Lists all assets of the project; creates and deletes them. Placing is a drag into a view.
|
||
*
|
||
* A row's name turns gold when the asset carries a property the inspector marks – the same rule,
|
||
* so the list cannot promise a deviation the inspector then fails to show.
|
||
*/
|
||
export class AssetPanel extends Panel {
|
||
private readonly list = new ListView();
|
||
private readonly scroll = new ScrollContainer();
|
||
|
||
constructor(
|
||
private readonly state: EditorState,
|
||
/** Where imported images go. Without it the Texture entry has nothing to upload to. */
|
||
private readonly textures: TextureStore | null = null,
|
||
/** The same for typefaces and the Font entry. */
|
||
private readonly fonts: FontStore | null = null,
|
||
/** And for sounds and the Sound entry. */
|
||
private readonly sounds: SoundStore | null = null,
|
||
) {
|
||
super({ title: 'Assets', padding: 0, gap: 0 });
|
||
this.minWidth = 160;
|
||
|
||
const add = new Button('+', { variant: 'ghost', width: 28 });
|
||
add.onClick = () => new Menu(this.addMenuEntries()).openBelow(add);
|
||
this.header.add(add);
|
||
|
||
this.list.emptyText = 'No assets – press +';
|
||
this.list.onSelect = (id) => state.select(id ? { kind: 'asset', assetId: id } : null);
|
||
this.list.onActivate = (id) => this.activate(id);
|
||
this.list.onContextMenu = (id, x, y) => this.openContextMenu(id, x, y);
|
||
this.list.onDragOut = (id) => this.startPlacementDrag(id);
|
||
this.scroll.setContent(this.list);
|
||
this.content.add(this.scroll, 1);
|
||
|
||
state.on('project', () => this.refresh());
|
||
state.on('load', () => this.refresh());
|
||
state.on('selection', () => this.syncSelection());
|
||
this.refresh();
|
||
}
|
||
|
||
/** One entry per asset type, the groups of `assetMenuGroups` kept apart by a line. */
|
||
private addMenuEntries(): MenuEntry[] {
|
||
const entry = (type: AssetType): MenuEntry => ({
|
||
label: ASSET_TYPE_LABELS[type],
|
||
onSelect: () => this.addAsset(type),
|
||
});
|
||
return assetMenuGroups().flatMap((group, index) =>
|
||
index === 0 ? group.map(entry) : [{ separator: true }, ...group.map(entry)],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* What a scene row says about itself: `●` the one the editor shows, `▶` the one a run begins
|
||
* with. Two marks rather than one, because they are two different questions and often two
|
||
* different scenes – the level you are editing versus the title screen.
|
||
*/
|
||
private sceneHint(id: string): string {
|
||
const { activeSceneId, startSceneId } = this.state.project;
|
||
const marks = `${id === startSceneId ? '▶' : ''}${id === activeSceneId ? '●' : ''}`;
|
||
return marks ? `scene ${marks}` : 'scene';
|
||
}
|
||
|
||
private refresh(): void {
|
||
this.list.setItems(
|
||
this.state.project.assets.map((asset) => ({
|
||
id: asset.id,
|
||
label: asset.name,
|
||
hint: asset.type === 'scene' ? this.sceneHint(asset.id) : asset.type,
|
||
swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined,
|
||
overridden: assetDeviates(this.state.project, asset),
|
||
})),
|
||
);
|
||
this.syncSelection();
|
||
this.scroll.relayoutContent();
|
||
}
|
||
|
||
private syncSelection(): void {
|
||
const s = this.state.selection;
|
||
const id = s?.kind === 'asset' ? s.assetId : null;
|
||
this.list.select(id, true);
|
||
// A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable
|
||
// in the list, and with fifteen kinds of asset in a project the row is often below the fold.
|
||
if (id) {
|
||
const index = this.state.project.assets.findIndex((a) => a.id === id);
|
||
if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight);
|
||
}
|
||
}
|
||
|
||
private createAsset(type: AssetType, name = ASSET_TYPE_LABELS[type], props: PropertyBag = {}): void {
|
||
const command = new CreateAssetCommand(type, nextAssetName(this.state.project, name), props);
|
||
if (this.state.execute(command)) this.state.openAsset(command.asset.id);
|
||
}
|
||
|
||
/**
|
||
* A sprite wearing the texture and an animation over it whose one row keys the frames in order,
|
||
* one per frame – what a picture sequence is on a timeline. One undo step for all of it.
|
||
*/
|
||
private createAnimation(texture: TextureAsset): void {
|
||
const project = this.state.project;
|
||
const sprite = new CreateAssetCommand(
|
||
'sprite',
|
||
nextAssetName(project, texture.name),
|
||
spriteFromTexture(project, texture.id),
|
||
);
|
||
const create = new CreateAssetCommand('animation', nextAssetName(project, texture.name));
|
||
const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id);
|
||
const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture');
|
||
const pictures = texture.frames.length > 0 ? texture.frames.map((f) => f.id) : [texture.id];
|
||
const keys = pictures.map(
|
||
(id, frame) => new SetAnimationKeyCommand(create.asset.id, row.trackId, frame, id),
|
||
);
|
||
if (this.state.execute(oneStep(create.label, [sprite, create, root, row, ...keys])))
|
||
this.state.openAsset(create.asset.id);
|
||
}
|
||
|
||
/**
|
||
* A texture is a picture, a font and a sound are files, so their menu entries offer what the
|
||
* server already holds instead of creating an empty asset. Uploading a new one is a button
|
||
* inside that dialog.
|
||
*/
|
||
private addAsset(type: AssetType): void {
|
||
if (type === 'texture') this.importTextures();
|
||
else if (type === 'font') this.importFonts();
|
||
else if (type === 'sound') this.importSounds();
|
||
else this.createAsset(type);
|
||
}
|
||
|
||
private importTextures(): void {
|
||
if (!this.textures) {
|
||
this.state.setNotice('Image upload is not available.');
|
||
return;
|
||
}
|
||
openImagePicker({ state: this.state, textures: this.textures });
|
||
}
|
||
|
||
private importFonts(): void {
|
||
if (!this.fonts) {
|
||
this.state.setNotice('Font upload is not available.');
|
||
return;
|
||
}
|
||
openFontPicker({ state: this.state, fonts: this.fonts });
|
||
}
|
||
|
||
private importSounds(): void {
|
||
if (!this.sounds) {
|
||
this.state.setNotice('Sound upload is not available.');
|
||
return;
|
||
}
|
||
openSoundPicker({ state: this.state, sounds: this.sounds });
|
||
}
|
||
|
||
/** Double-click / Enter opens the asset's tab; a scene also becomes the active one. Placing is a drag. */
|
||
private activate(id: string): void {
|
||
const asset = findAsset(this.state.project, id);
|
||
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(id));
|
||
this.state.openAsset(id);
|
||
}
|
||
|
||
/** Where an asset's row sits on screen, so a check can pick it up by id (test hook). */
|
||
rowGlobal(assetId: string): { x: number; y: number } | null {
|
||
return this.list.rowGlobal(assetId);
|
||
}
|
||
|
||
/** Label colour of a row as `#rrggbb` – gold means the asset deviates from the defaults (test hook). */
|
||
rowColor(assetId: string): string | null {
|
||
return this.list.rowColor(assetId);
|
||
}
|
||
|
||
/** Hands the asset to the viewport under the pointer; a scene has nowhere to be placed into. */
|
||
private startPlacementDrag(assetId: string): void {
|
||
const asset = findAsset(this.state.project, assetId);
|
||
if (!asset || !isPlaceableType(asset.type)) return;
|
||
startDrag({ kind: 'asset', id: assetId }, asset.name);
|
||
}
|
||
|
||
private openContextMenu(id: string, x: number, y: number): Menu | undefined {
|
||
const asset = findAsset(this.state.project, id);
|
||
if (!asset) return undefined;
|
||
const items: MenuItem[] = [];
|
||
if (asset.type === 'scene') {
|
||
items.push({
|
||
label: 'Set as active scene',
|
||
onSelect: () => this.state.execute(new SetActiveSceneCommand(id)),
|
||
disabled: this.state.project.activeSceneId === id,
|
||
});
|
||
const isStart = this.state.project.startSceneId === id;
|
||
items.push({
|
||
label: isStart ? 'Clear start scene' : 'Set as start scene',
|
||
onSelect: () => this.state.execute(new SetStartSceneCommand(isStart ? '' : id)),
|
||
});
|
||
}
|
||
if (asset.type === 'texture') {
|
||
items.push({
|
||
label: 'Create sprite from texture',
|
||
onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, id)),
|
||
});
|
||
items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) });
|
||
}
|
||
items.push({ label: 'Delete…', danger: true, onSelect: () => confirmDeleteAsset(this.state, id) });
|
||
const menu = new Menu(items);
|
||
menu.open(x, y);
|
||
return menu;
|
||
}
|
||
}
|