Files
feuerwerk/src/editor/panels/AssetPanel.ts
T
adminandClaude Opus 5 193378e6a9 An animation is a timeline of rows over one asset, played on an object rather than worn as a picture (#97)
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>
2026-09-16 20:02:25 +02:00

248 lines
9.6 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 {
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;
}
}