An animation asset is a list of pictures (whole textures or frames) with frames per second and Loop/Once/Ping-pong playback. A sprite wears it through its texture property, so the picker, instance overrides and the Properties node work unchanged; there is no dedicated play node. A different animation starts from its first frame, the same one set again runs on, so a graph may choose Run or Idle every frame. The clock lives on the entity, not in props, which the world writes back as overrides. The AnimationSystem ticks before behaviors, and On animation finished fires once when a Once animation reaches its end. The Animation tab plays a preview above a frame strip: reorder by dragging, Delete, duplicate, + Frames, pause/resume, and wheel zoom about the pointer with pan and Fit. Scene and Container tabs show the first frame; edit mode has no clock. Right-clicking a texture creates an animation from its frames. Button now measures its label with the bold font it draws in, which cut the Animation tab and red dialog buttons short. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
import { AddAnimationFramesCommand } from '../../core/commands/animationCommands';
|
||
import { CreateAssetCommand, SetActiveSceneCommand } from '../../core/commands/assetCommands';
|
||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||
import { nextAssetName } from '../../core/commands/naming';
|
||
import {
|
||
ASSET_TYPE_LABELS,
|
||
ASSET_TYPES,
|
||
type AssetType,
|
||
hasColorSwatch,
|
||
isPlaceableType,
|
||
} from '../../core/model/AssetTypes';
|
||
import { framesOfTexture } from '../../core/model/animation';
|
||
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 { TextureStore } from '../../core/store/TextureStore';
|
||
import { Button, ListView, Menu, type MenuItem, Panel, ScrollContainer, startDrag } from '../../ui';
|
||
import { confirmDeleteAsset } from '../actions';
|
||
import type { EditorState } from '../state';
|
||
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,
|
||
) {
|
||
super({ title: 'Assets', padding: 0, gap: 0 });
|
||
this.minWidth = 160;
|
||
|
||
const add = new Button('+', { variant: 'ghost', width: 28 });
|
||
add.onClick = () =>
|
||
new Menu(
|
||
ASSET_TYPES.map((type) => ({
|
||
label: ASSET_TYPE_LABELS[type],
|
||
onSelect: () => (type === 'texture' ? this.importTextures() : this.createAsset(type)),
|
||
})),
|
||
).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();
|
||
}
|
||
|
||
private refresh(): void {
|
||
const active = this.state.project.activeSceneId;
|
||
this.list.setItems(
|
||
this.state.project.assets.map((asset) => ({
|
||
id: asset.id,
|
||
label: asset.name,
|
||
hint: asset.type === 'scene' && asset.id === active ? 'scene ●' : asset.type,
|
||
swatch: hasColorSwatch(asset.type) ? Number(asset.props.color ?? 0) : undefined,
|
||
overridden: assetDeviates(asset),
|
||
})),
|
||
);
|
||
this.syncSelection();
|
||
this.scroll.relayoutContent();
|
||
}
|
||
|
||
private syncSelection(): void {
|
||
const s = this.state.selection;
|
||
this.list.select(s?.kind === 'asset' ? s.assetId : null, true);
|
||
}
|
||
|
||
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.select({ kind: 'asset', assetId: command.asset.id });
|
||
}
|
||
|
||
/** One undo step for the asset and its frames. */
|
||
private createAnimation(texture: TextureAsset): void {
|
||
const create = new CreateAssetCommand('animation', nextAssetName(this.state.project, texture.name));
|
||
const frames = new AddAnimationFramesCommand(create.asset.id, framesOfTexture(texture));
|
||
if (this.state.execute(oneStep(create.label, [create, frames])))
|
||
this.state.select({ kind: 'asset', assetId: create.asset.id });
|
||
}
|
||
|
||
/**
|
||
* A texture is a picture, so its menu entry offers the ones the server already holds instead of
|
||
* creating an empty asset. Uploading a new one is a button inside that dialog.
|
||
*/
|
||
private importTextures(): void {
|
||
if (!this.textures) {
|
||
this.state.setNotice('Image upload is not available.');
|
||
return;
|
||
}
|
||
openImagePicker({ state: this.state, textures: this.textures });
|
||
}
|
||
|
||
/** Double-click / Enter on a scene makes it the active one. Placing is a drag, not a click. */
|
||
private activate(id: string): void {
|
||
const asset = findAsset(this.state.project, id);
|
||
if (asset?.type === 'scene') this.state.execute(new SetActiveSceneCommand(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,
|
||
});
|
||
}
|
||
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;
|
||
}
|
||
}
|