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; } }