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, isPlaceableType } from '../../core/model/AssetTypes'; import type { PixelArtAsset, TextureAsset } from '../../core/model/Asset'; import { generatesPictures } from '../../core/model/generated'; import { PALETTE_KEY, pixelArtWornId } from '../../core/model/pixelart'; 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 { schemaForAsset } from '../../core/properties/PropertySchema'; import { resolveAssetProps } from '../../core/properties/resolve'; import type { FontStore } from '../../core/store/FontStore'; import type { SoundStore } from '../../core/store/SoundStore'; import type { TextureStore } from '../../core/store/TextureStore'; import { Button, type ListItem, ListView, Menu, type MenuEntry, Panel, ScrollContainer, startDrag, UI_ICON_SVG, } from '../../ui'; import { ASSET_GROUP_LABELS, groupAssets } from './assetGroups'; import { ASSET_ICON_SVG } from './assetIcons'; import { assetMenuGroups } from './assetMenu'; import { TOOLBAR_ICON_SVG } from './toolbarIcons'; import { activateAsset, confirmDeleteAsset } from '../actions'; import { openReferenceItems } from '../objectMenu'; import type { EditorState } from '../state'; import { openFontPicker } from '../font/FontPicker'; import { openSoundPicker } from '../sound/SoundPicker'; import { openImagePicker } from '../texture/ImagePicker'; /** Row ids of the group headings are this plus the type; an asset id never starts with it. */ const HEADING = 'group:'; const headingId = (type: AssetType): string => HEADING + type; /** * Lists all assets of the project, grouped by type under a folding heading (#176); 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(); /** Types whose group is unfolded, for the session; every group starts folded. */ private readonly unfolded = new Set(); /** Ids of the rows as shown, headings included. */ private rows: string[] = []; 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 + or right-click'; 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.onListMenu = (x, y) => new Menu(this.addMenuEntries()).open(x, y); this.list.onDragOut = (id) => this.startPlacementDrag(id); this.list.onToggle = (id, expand) => this.setFolded(id.slice(HEADING.length) as AssetType, !expand); this.scroll.setContent(this.list); this.content.add(this.scroll, 1); state.on('project', () => this.refresh()); // A different project brings different assets – start folded again. state.on('load', () => { this.unfolded.clear(); this.refresh(); }); state.on('selection', () => this.syncSelection()); state.on('ping', () => this.ping()); this.refresh(); } /** Unfolds and scrolls to the pinged asset and lets its row light up; the selection stays. */ private ping(): void { const id = this.state.pingedAssetId; if (!id || !findAsset(this.state.project, id)) return; this.reveal(id); this.list.ping(id); } /** The row lighting up after a ping and how brightly, or null (test hook). */ pingState(): { id: string; alpha: number } | null { return this.list.pingState(); } /** 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], icon: ASSET_ICON_SVG[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: a dot for the one the editor shows, play for 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 sceneMarks(id: string): string[] { const { activeSceneId, startSceneId } = this.state.project; return [ ...(id === startSceneId ? [TOOLBAR_ICON_SVG.play] : []), ...(id === activeSceneId ? [UI_ICON_SVG.current] : []), ]; } private refresh(): void { const project = this.state.project; const items: ListItem[] = []; for (const { type, assets } of groupAssets(project.assets)) { const expanded = this.unfolded.has(type); items.push({ id: headingId(type), label: ASSET_GROUP_LABELS[type], hint: String(assets.length), icon: ASSET_ICON_SVG[type], heading: true, expanded, }); if (!expanded) continue; for (const asset of assets) items.push({ id: asset.id, label: asset.name, marks: asset.type === 'scene' ? this.sceneMarks(asset.id) : undefined, depth: 1, overridden: assetDeviates(project, asset), }); } this.rows = items.map((item) => item.id); this.list.setItems(items); // Measured first: scrolling to the selection needs the new height of the list. this.scroll.relayoutContent(); this.syncSelection(); } private setFolded(type: AssetType, fold: boolean): void { if (fold) { // A selected asset unfolds its group again – folding lets go of it first. const s = this.state.selection; if (s?.kind === 'asset' && findAsset(this.state.project, s.assetId)?.type === type) this.state.select(null); this.unfolded.delete(type); } else this.unfolded.add(type); this.refresh(); } private syncSelection(): void { const s = this.state.selection; const id = s?.kind === 'asset' ? s.assetId : null; // A selection made anywhere else – a graph node, a picker, a fresh asset – has to be reachable // in the list: its group unfolds, and with many assets the row is often below the fold. const type = id ? findAsset(this.state.project, id)?.type : undefined; if (type && !this.unfolded.has(type)) { this.setFolded(type, false); return; } this.list.select(id, true); if (id) this.scrollTo(id); } private scrollTo(id: string): void { const index = this.rows.indexOf(id); if (index >= 0) this.scroll.ensureVisible(index * this.list.rowHeight, this.list.rowHeight); } /** The groups as shown, in order (test hook). */ groups(): { type: AssetType; label: string; expanded: boolean; ids: string[] }[] { return groupAssets(this.state.project.assets).map(({ type, assets }) => ({ type, label: ASSET_GROUP_LABELS[type], expanded: this.unfolded.has(type), ids: assets.map((a) => a.id), })); } 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(picture: TextureAsset | PixelArtAsset): void { const project = this.state.project; const pictures = picture.frames.length > 0 ? picture.frames.map((f) => f.id) : [picture.id]; const sprite = new CreateAssetCommand( 'sprite', nextAssetName(project, picture.name), spriteFromTexture(project, pictures[0] ?? picture.id), ); const create = new CreateAssetCommand('animation', nextAssetName(project, picture.name)); const root = new SetAnimationRootCommand(create.asset.id, sprite.asset.id); const row = new AddAnimationTrackCommand(create.asset.id, '', 'texture'); 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 if (type === 'pixelart') this.createPixelArt(); else this.createAsset(type); } /** * Pixel art is painted in a palette the project shares (#130, answer 2): a new one takes the first * palette there is, and where there is none a palette is created along with it, in one undo step. */ private createPixelArt(): void { const project = this.state.project; const existing = groupAssets(project.assets).find((group) => group.type === 'palette')?.assets[0]; const palette = existing ? null : new CreateAssetCommand('palette', nextAssetName(project, ASSET_TYPE_LABELS.palette)); const art = new CreateAssetCommand('pixelart', nextAssetName(project, ASSET_TYPE_LABELS.pixelart), { [PALETTE_KEY]: existing?.id ?? palette?.asset.id ?? '', }); const commands = palette ? [palette, art] : [art]; if (this.state.execute(oneStep(art.label, commands))) this.state.openAsset(art.asset.id); } 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 { activateAsset(this.state, id); } /** * Where an asset's row sits on screen, so a check can pick it up by id (test hook); with `reveal` * its group is unfolded first, otherwise a folded group's row is `null`. */ rowGlobal(assetId: string, reveal = false): { x: number; y: number } | null { if (reveal) this.reveal(assetId); return this.list.rowGlobal(assetId); } /** Unfolds the asset's group and scrolls to its row – before a ping, and as a user would before clicking it (test hooks). */ private reveal(assetId: string): void { const type = findAsset(this.state.project, assetId)?.type; if (type && !this.unfolded.has(type)) this.setFolded(type, false); this.scrollTo(assetId); } /** Centre of a group heading's icon (test hook). */ groupIcon(type: AssetType): { x: number; y: number } | null { return this.list.iconGlobal(headingId(type)); } /** Label colour of a row as `#rrggbb` – gold means the asset deviates from the defaults (test hook). */ rowColor(assetId: string): string | null { this.reveal(assetId); 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 project = this.state.project; const items = openReferenceItems( this.state, schemaForAsset(project, asset), resolveAssetProps(project, asset), ); 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)), }); } // A drawn picture (#185) is worn like a texture, so it makes a sprite the same way. Only the // animation needs frames, which a drawn picture has none of. if (asset.type === 'texture' || generatesPictures(asset.type)) { // A sequence of pixel art frames makes a sprite of its first frame rather than of the strip. const worn = asset.type === 'pixelart' ? pixelArtWornId(asset) : id; items.push({ label: asset.type === 'texture' ? 'Create sprite from texture' : 'Create sprite from picture', onSelect: () => this.createAsset('sprite', asset.name, spriteFromTexture(this.state.project, worn)), }); if (asset.type === 'texture') items.push({ label: 'Create animation from texture', onSelect: () => this.createAnimation(asset) }); if (asset.type === 'pixelart' && asset.frames.length > 1) items.push({ label: 'Create animation from frames', 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; } }