Autotiles are a new asset type with a tab that shows every tile of the floor (2x3) or wall (2x2) block and an animated sample pattern (#118)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c17b2b5426
commit
1a9f07c4ac
24 files changed
+1797
-18
No files matched your search
+11
-1
@@ -30,6 +30,7 @@ import { GraphEditor } from './graph/GraphEditor';
|
||||
import { Inspector } from './inspector/Inspector';
|
||||
import { ListEditor } from './list/ListEditor';
|
||||
import { AnimationEditor } from './animation/AnimationEditor';
|
||||
import { AutotileEditor } from './autotile/AutotileEditor';
|
||||
import { AssetPanel } from './panels/AssetPanel';
|
||||
import { HierarchyPanel } from './panels/HierarchyPanel';
|
||||
import { Toolbar } from './panels/Toolbar';
|
||||
@@ -55,6 +56,7 @@ export type CentreTab =
|
||||
| 'game'
|
||||
| 'texture'
|
||||
| 'animation'
|
||||
| 'autotile'
|
||||
| 'container'
|
||||
| 'list'
|
||||
| 'plugins';
|
||||
@@ -72,6 +74,7 @@ const TAB_ORDER = [
|
||||
'particles',
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'container',
|
||||
'list',
|
||||
'plugins',
|
||||
@@ -86,6 +89,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
|
||||
particles: 'Particles',
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
container: 'Container',
|
||||
list: 'List',
|
||||
plugins: 'Plugins',
|
||||
@@ -100,6 +104,7 @@ const TAB_OF_ASSET: Partial<Record<AssetType, CentreTab>> = {
|
||||
particles: 'particles',
|
||||
texture: 'texture',
|
||||
animation: 'animation',
|
||||
autotile: 'autotile',
|
||||
container: 'container',
|
||||
};
|
||||
|
||||
@@ -127,6 +132,7 @@ export class Editor implements PlayHost {
|
||||
readonly particlesEditor: ParticlesEditor;
|
||||
readonly textureEditor: TextureEditor;
|
||||
readonly animationEditor: AnimationEditor;
|
||||
readonly autotileEditor: AutotileEditor;
|
||||
readonly containerEditor: ContainerEditor;
|
||||
readonly listEditor: ListEditor;
|
||||
/** Plugins tab: the switch per file in `data/plugins/`. */
|
||||
@@ -177,6 +183,7 @@ export class Editor implements PlayHost {
|
||||
this.particlesEditor = new ParticlesEditor(state);
|
||||
this.textureEditor = new TextureEditor(state);
|
||||
this.animationEditor = new AnimationEditor(state);
|
||||
this.autotileEditor = new AutotileEditor(state);
|
||||
this.containerEditor = new ContainerEditor(state);
|
||||
this.listEditor = new ListEditor(state);
|
||||
this.hierarchy = new HierarchyPanel(state);
|
||||
@@ -235,6 +242,7 @@ export class Editor implements PlayHost {
|
||||
particles: this.particlesEditor,
|
||||
texture: this.textureEditor,
|
||||
animation: this.animationEditor,
|
||||
autotile: this.autotileEditor,
|
||||
container: this.containerEditor,
|
||||
list: this.listEditor,
|
||||
plugins: this.pluginsPane,
|
||||
@@ -408,6 +416,7 @@ export class Editor implements PlayHost {
|
||||
this.gameView.animate();
|
||||
this.console.refresh();
|
||||
if (this.tab === 'animation') this.animationEditor.tick(step);
|
||||
if (this.tab === 'autotile') this.autotileEditor.tick(step);
|
||||
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
|
||||
// to do it, and its states move just the same.
|
||||
if (this.tab === 'statemachine' && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
|
||||
@@ -426,7 +435,8 @@ export class Editor implements PlayHost {
|
||||
(this.viewport.visible && this.viewport.animates) ||
|
||||
(this.gameView.visible && this.gameView.animates) ||
|
||||
this.tab === 'particles' ||
|
||||
(this.tab === 'animation' && this.animationEditor.isPlaying)
|
||||
(this.tab === 'animation' && this.animationEditor.isPlaying) ||
|
||||
(this.tab === 'autotile' && this.autotileEditor.isPlaying)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -16,7 +16,7 @@ import { findAnnotation } from '../core/graph';
|
||||
import { findAsset, findGraphAsset, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import type { SceneObject } from '../core/model/SceneObject';
|
||||
import { textureRefIds } from '../core/model/texture';
|
||||
import { animationsUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { animationsUsing, autotilesUsing, findPropertyUsages, findUsages } from '../core/model/usages';
|
||||
import { Dialog } from '../ui';
|
||||
import { type EditorState, objectSelection } from './state';
|
||||
|
||||
@@ -97,11 +97,12 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
const ids = asset.type === 'texture' ? textureRefIds(asset) : [assetId];
|
||||
const references = findPropertyUsages(state.project, ids).length;
|
||||
const animations = asset.type === 'texture' ? animationsUsing(state.project, ids).length : 0;
|
||||
const autotiles = asset.type === 'texture' ? autotilesUsing(state.project, ids).length : 0;
|
||||
const detail =
|
||||
asset.type === 'scene'
|
||||
? `The scene and its ${asset.objects.length} object(s) are removed.`
|
||||
: references > 0 || animations > 0
|
||||
? wornDetail(references, animations, asset.type)
|
||||
: references > 0 || animations > 0 || autotiles > 0
|
||||
? wornDetail(references, animations, autotiles, asset.type)
|
||||
: usages === 0
|
||||
? 'This cannot be undone with Ctrl+Z after reload.'
|
||||
: asset.type === 'container'
|
||||
@@ -113,11 +114,15 @@ export function confirmDeleteAsset(state: EditorState, assetId: string): void {
|
||||
]).openCentered();
|
||||
}
|
||||
|
||||
function wornDetail(sprites: number, animations: number, type: AssetType): string {
|
||||
function wornDetail(sprites: number, animations: number, autotiles: number, type: AssetType): string {
|
||||
if (type === 'font') return `${sprites} text(s) reference it and fall back to the editor's own typeface.`;
|
||||
const who = [
|
||||
sprites > 0 ? `${sprites} sprite(s)` : '',
|
||||
animations > 0 ? `${animations} animation(s)` : '',
|
||||
autotiles > 0 ? `${autotiles} autotile(s)` : '',
|
||||
].filter(Boolean);
|
||||
return `${who.join(' and ')} reference it and show the plain colour in its place.`;
|
||||
// "a, b and c" rather than "a and b and c", which three kinds of user made reachable.
|
||||
const list =
|
||||
who.length > 2 ? `${who.slice(0, -1).join(', ')} and ${who[who.length - 1]}` : who.join(' and ');
|
||||
return `${list} reference it and show the plain colour in its place.`;
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
import {
|
||||
Container,
|
||||
type FederatedPointerEvent,
|
||||
type FederatedWheelEvent,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Text,
|
||||
} from 'pixi.js';
|
||||
import type { AutotileAsset } from '../../core/model/Asset';
|
||||
import {
|
||||
autotileCombinations,
|
||||
autotileFps,
|
||||
autotilePlayback,
|
||||
type AutotileInfo,
|
||||
type AutotilePicture,
|
||||
neighboursAt,
|
||||
patternCell,
|
||||
pictureIndexAt,
|
||||
quadrantRef,
|
||||
type QuadPos,
|
||||
readAutotile,
|
||||
SAMPLE_PATTERN,
|
||||
tileQuadrants,
|
||||
type TileQuadrants,
|
||||
} from '../../core/model/autotile';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { type Size, Theme, textStyle, Widget, zoomFactor } from '../../ui';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { type Box, frameBoxes } from '../framing';
|
||||
import { type GraphView, zoomedAbout } from '../graph/graphView';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
/** Tiles per row in the combination grid; 47 of them then read as six rows of eight. */
|
||||
const GRID_COLUMNS = 8;
|
||||
/** Gaps and caption room, in tile widths – so the board keeps its shape at any tile size. */
|
||||
const GAP = 0.34;
|
||||
const CAPTION = 0.3;
|
||||
const SECTION_GAP = 1.2;
|
||||
const FIT_MARGIN = 16;
|
||||
/** Wider than the graph's: a 48 px tile is small, and its quarters are what one wants to look at. */
|
||||
const ZOOM_LIMITS = { min: 0.1, max: 6 };
|
||||
|
||||
/** One tile of the board: where it sits and which quarters it is drawn from. */
|
||||
interface BoardTile {
|
||||
x: number;
|
||||
y: number;
|
||||
quadrants: TileQuadrants;
|
||||
sprites: [Sprite, Sprite, Sprite, Sprite];
|
||||
/** Set on the tiles of the sample map: which cell of it this is. */
|
||||
cell?: { row: number; column: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Autotile tab's picture (#118): every tile the block can produce, captioned with the
|
||||
* neighbours that ask for it, and below them a sample map composed from the same quarters – the
|
||||
* one place that shows whether the edges really continue across tile borders. A drag pans, the
|
||||
* wheel zooms, nothing is edited here.
|
||||
*/
|
||||
export class AutotileCanvas extends Widget {
|
||||
private readonly bg = new Graphics();
|
||||
private readonly content = new Container();
|
||||
private readonly clip = new Graphics();
|
||||
private readonly hint = new Text({ text: '', style: textStyle('muted') });
|
||||
private readonly tiles: BoardTile[] = [];
|
||||
private readonly labels: Text[] = [];
|
||||
private view: GraphView = { pan: { x: 0, y: 0 }, zoom: 1 };
|
||||
private panning: { start: { x: number; y: number }; pan: { x: number; y: number } } | null = null;
|
||||
private assetId = '';
|
||||
/** Seconds the preview has been running – the only thing that moves without an edit. */
|
||||
private time = 0;
|
||||
/** What the sprites were built for, so an unchanged board is not rebuilt every frame. */
|
||||
private built = '';
|
||||
private shownPicture = -1;
|
||||
private board = { w: 0, h: 0 };
|
||||
private info: AutotileInfo | null = null;
|
||||
private needsFit = true;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super();
|
||||
this.content.eventMode = 'none';
|
||||
this.hint.eventMode = 'none';
|
||||
this.addChild(this.bg, this.content, this.hint, this.clip);
|
||||
this.content.mask = this.clip;
|
||||
|
||||
this.on('pointerdown', (e: FederatedPointerEvent) => {
|
||||
this.panning = { start: { x: e.global.x, y: e.global.y }, pan: { ...this.view.pan } };
|
||||
});
|
||||
this.on('globalpointermove', (e: FederatedPointerEvent) => {
|
||||
if (!this.panning) return;
|
||||
const { start, pan } = this.panning;
|
||||
this.view.pan = { x: pan.x + e.global.x - start.x, y: pan.y + e.global.y - start.y };
|
||||
this.applyView();
|
||||
});
|
||||
this.on('pointerup', () => (this.panning = null));
|
||||
this.on('pointerupoutside', () => (this.panning = null));
|
||||
this.on('wheel', (e: FederatedWheelEvent) => {
|
||||
this.view = zoomedAbout(this.view, this.toLocal(e.global), zoomFactor(e.deltaY), ZOOM_LIMITS);
|
||||
this.needsFit = false;
|
||||
this.applyView();
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
state.on('project', () => this.redraw());
|
||||
state.on('load', () => this.show(''));
|
||||
// A picture arrives asynchronously; the board is empty until it does.
|
||||
textureLibrary.onChange(() => this.redraw());
|
||||
}
|
||||
|
||||
override measure(): Size {
|
||||
return { w: Math.max(this.minWidth, 200), h: Math.max(this.minHeight, 200) };
|
||||
}
|
||||
|
||||
get currentAssetId(): string {
|
||||
return this.assetId;
|
||||
}
|
||||
|
||||
/** Switching resets time and view: the next autotile is a new subject. */
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) return;
|
||||
this.assetId = assetId;
|
||||
this.time = 0;
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
fit(): void {
|
||||
this.needsFit = true;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Advances the animation; only called while the tab is visible (see `Editor.tick`). */
|
||||
tick(dt: number): void {
|
||||
if (!this.animates) return;
|
||||
this.time += dt;
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
/** Whether anything moves by itself – one picture is a still image, however fast it is set to run. */
|
||||
get animates(): boolean {
|
||||
const info = this.info;
|
||||
return Boolean(info && info.problem === '' && info.pictures.length > 1 && this.fps() > 0);
|
||||
}
|
||||
|
||||
/** What the board shows – the check scripts read this. */
|
||||
debugInfo(): {
|
||||
assetId: string;
|
||||
kind: string;
|
||||
tiles: number;
|
||||
combinations: number;
|
||||
picture: number;
|
||||
pictures: number;
|
||||
tile: number;
|
||||
problem: string;
|
||||
zoom: number;
|
||||
pan: { x: number; y: number };
|
||||
rect: { x: number; y: number; w: number; h: number };
|
||||
} {
|
||||
const info = this.info;
|
||||
const origin = this.getGlobalPosition();
|
||||
return {
|
||||
assetId: this.assetId,
|
||||
kind: info?.kind ?? '',
|
||||
tiles: this.tiles.length,
|
||||
combinations: info ? autotileCombinations(info.kind).length : 0,
|
||||
picture: this.shownPicture,
|
||||
pictures: info?.pictures.length ?? 0,
|
||||
tile: info?.tile ?? 0,
|
||||
problem: info?.problem ?? '',
|
||||
zoom: this.view.zoom,
|
||||
pan: { ...this.view.pan },
|
||||
rect: { x: origin.x, y: origin.y, w: this.w, h: this.h },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One tile of the board as drawn: which quarters it uses and where the middle of each of them
|
||||
* sits on screen. A check reads a pixel there – the only way to see that the right quarter of
|
||||
* the block ended up in the right corner of the tile.
|
||||
*/
|
||||
tileDrawn(index: number): {
|
||||
quadrants: { x: number; y: number; at: { x: number; y: number } }[];
|
||||
} | null {
|
||||
const tile = this.tiles[index];
|
||||
if (!tile) return null;
|
||||
const half = (this.tiles[0] ? (this.info?.tile ?? 0) / 2 : 0) * this.view.zoom;
|
||||
return {
|
||||
quadrants: tile.quadrants.map((position, corner) => {
|
||||
const sprite = tile.sprites[corner]!;
|
||||
const at = sprite.getGlobalPosition();
|
||||
return { x: position.x, y: position.y, at: { x: at.x + half / 2, y: at.y + half / 2 } };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** The board tile a cell of the sample map became, so a check can name one without counting. */
|
||||
sampleTile(row: number, column: number): number {
|
||||
return this.tiles.findIndex((tile) => tile.cell?.row === row && tile.cell.column === column);
|
||||
}
|
||||
|
||||
private asset(): AutotileAsset | undefined {
|
||||
return this.assetId ? findAssetOf(this.state.project, this.assetId, 'autotile') : undefined;
|
||||
}
|
||||
|
||||
private fps(): number {
|
||||
const asset = this.asset();
|
||||
return asset ? autotileFps(asset) : 0;
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
if (this.needsFit) this.redraw();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
this.bg.clear().rect(0, 0, this.w, this.h).fill(Theme.colors.viewport);
|
||||
this.clip.clear().rect(0, 0, this.w, this.h).fill(0xffffff);
|
||||
const asset = this.asset();
|
||||
this.info = asset ? readAutotile(this.state.project, asset) : null;
|
||||
this.build(asset);
|
||||
this.paint(asset);
|
||||
this.syncHint(asset);
|
||||
if (this.needsFit && this.w > 0 && this.h > 0 && this.tiles.length > 0) {
|
||||
const box: Box = { id: 'board', x: 0, y: 0, w: this.board.w, h: this.board.h };
|
||||
const framing = frameBoxes([box], { w: this.w, h: this.h }, FIT_MARGIN, ZOOM_LIMITS);
|
||||
this.view = { pan: { x: framing.panX, y: framing.panY }, zoom: framing.zoom };
|
||||
this.needsFit = false;
|
||||
}
|
||||
this.applyView();
|
||||
}
|
||||
|
||||
/** Rebuilds the sprites when the board's shape changed – the kind, the tile size or the asset. */
|
||||
private build(asset: AutotileAsset | undefined): void {
|
||||
const info = this.info;
|
||||
const signature = asset && info && info.problem === '' ? `${asset.id}:${info.kind}:${info.tile}` : '';
|
||||
if (signature === this.built) return;
|
||||
this.built = signature;
|
||||
this.shownPicture = -1;
|
||||
for (const tile of this.tiles.splice(0)) for (const sprite of tile.sprites) sprite.destroy();
|
||||
for (const label of this.labels.splice(0)) label.destroy();
|
||||
this.content.removeChildren();
|
||||
if (!info || signature === '') {
|
||||
this.board = { w: 0, h: 0 };
|
||||
return;
|
||||
}
|
||||
this.layoutBoard(info);
|
||||
}
|
||||
|
||||
/** The combination grid, then the sample map below it – all in image pixels of the tile. */
|
||||
private layoutBoard(info: AutotileInfo): void {
|
||||
const tile = info.tile;
|
||||
const step = tile * (1 + GAP);
|
||||
const row = tile * (1 + GAP + CAPTION);
|
||||
const combinations = autotileCombinations(info.kind);
|
||||
combinations.forEach((combination, index) => {
|
||||
const column = index % GRID_COLUMNS;
|
||||
const line = Math.floor(index / GRID_COLUMNS);
|
||||
const x = column * step;
|
||||
const y = line * row;
|
||||
this.addTile(x, y, combination.quadrants);
|
||||
this.addLabel(combination.label, x, y + tile + tile * 0.06, tile);
|
||||
});
|
||||
const gridHeight = Math.ceil(combinations.length / GRID_COLUMNS) * row;
|
||||
const sampleTop = gridHeight + tile * SECTION_GAP;
|
||||
for (let line = 0; line < SAMPLE_PATTERN.length; line++)
|
||||
for (let column = 0; column < (SAMPLE_PATTERN[line]?.length ?? 0); column++) {
|
||||
if (!patternCell(SAMPLE_PATTERN, line, column)) continue;
|
||||
const around = neighboursAt(SAMPLE_PATTERN, line, column);
|
||||
this.addTile(column * tile, sampleTop + line * tile, tileQuadrants(info.kind, around), {
|
||||
row: line,
|
||||
column,
|
||||
});
|
||||
}
|
||||
this.board = {
|
||||
w: Math.max(GRID_COLUMNS * step - tile * GAP, (SAMPLE_PATTERN[0]?.length ?? 0) * tile),
|
||||
h: sampleTop + SAMPLE_PATTERN.length * tile,
|
||||
};
|
||||
}
|
||||
|
||||
private addTile(
|
||||
x: number,
|
||||
y: number,
|
||||
quadrants: TileQuadrants,
|
||||
cell?: { row: number; column: number },
|
||||
): void {
|
||||
const sprites = [new Sprite(), new Sprite(), new Sprite(), new Sprite()] as [
|
||||
Sprite,
|
||||
Sprite,
|
||||
Sprite,
|
||||
Sprite,
|
||||
];
|
||||
this.content.addChild(...sprites);
|
||||
this.tiles.push(cell ? { x, y, quadrants, sprites, cell } : { x, y, quadrants, sprites });
|
||||
}
|
||||
|
||||
private addLabel(text: string, x: number, y: number, tile: number): void {
|
||||
const label = new Text({ text, style: textStyle('viewportLabel') });
|
||||
// Scaled with the tile so a caption never overruns the tile it belongs to.
|
||||
const scale = Math.min(1, tile / Math.max(label.width, 1));
|
||||
label.scale.set(scale);
|
||||
label.position.set(x, y);
|
||||
this.content.addChild(label);
|
||||
this.labels.push(label);
|
||||
}
|
||||
|
||||
/** Puts the current picture's quarters into the sprites; a picture still loading leaves them blank. */
|
||||
private paint(asset: AutotileAsset | undefined): void {
|
||||
const info = this.info;
|
||||
if (!asset || !info || info.problem !== '' || this.tiles.length === 0) return;
|
||||
const index = pictureIndexAt(
|
||||
info.pictures.length,
|
||||
autotilePlayback(asset),
|
||||
autotileFps(asset),
|
||||
this.time,
|
||||
);
|
||||
const picture = info.pictures[index];
|
||||
if (!picture) return;
|
||||
const half = info.tile / 2;
|
||||
for (const tile of this.tiles) {
|
||||
tile.quadrants.forEach((position, corner) => {
|
||||
const sprite = tile.sprites[corner]!;
|
||||
const texture = this.quadrant(picture, info, position);
|
||||
sprite.visible = texture !== null;
|
||||
if (texture) sprite.texture = texture;
|
||||
sprite.position.set(tile.x + (corner % 2) * half, tile.y + Math.floor(corner / 2) * half);
|
||||
sprite.width = half;
|
||||
sprite.height = half;
|
||||
});
|
||||
}
|
||||
this.shownPicture = index;
|
||||
}
|
||||
|
||||
private quadrant(picture: AutotilePicture, info: AutotileInfo, position: QuadPos) {
|
||||
return textureLibrary.get(quadrantRef(picture, info.quad, position));
|
||||
}
|
||||
|
||||
private syncHint(asset: AutotileAsset | undefined): void {
|
||||
const text = !asset
|
||||
? 'No autotile selected – create one in the Assets panel.'
|
||||
: (this.info?.problem ?? '');
|
||||
this.hint.text = text;
|
||||
this.hint.visible = text !== '';
|
||||
this.hint.position.set(Theme.padding * 2, Theme.padding * 2);
|
||||
}
|
||||
|
||||
private applyView(): void {
|
||||
this.content.position.set(Math.round(this.view.pan.x), Math.round(this.view.pan.y));
|
||||
this.content.scale.set(this.view.zoom);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import type { Asset, AutotileAsset } from '../../core/model/Asset';
|
||||
import {
|
||||
AUTOTILE_KIND_KEY,
|
||||
AUTOTILE_KINDS,
|
||||
autotileCombinations,
|
||||
autotileFps,
|
||||
autotileKind,
|
||||
readAutotile,
|
||||
} from '../../core/model/autotile';
|
||||
import { propDef } from '../../core/properties/PropertySchema';
|
||||
import { Dropdown, formatNumber, HStack, Menu, type MenuItem, type Widget } from '../../ui';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import type { EditorState } from '../state';
|
||||
import { AutotileCanvas } from './AutotileCanvas';
|
||||
import { PictureStrip } from './PictureStrip';
|
||||
|
||||
/** Room kept for the "Floor · 48 px · 3 pictures · 47 tiles" readout. */
|
||||
const INFO_WIDTH = 250;
|
||||
const KIND_WIDTH = 130;
|
||||
|
||||
/**
|
||||
* The Autotile tab (#118): the header picks the autotile and the kind of block, the strip on the
|
||||
* right holds the pictures one animation step each, and the board shows every tile the block can
|
||||
* produce plus a sample map composed from them. The rate and the playback are properties of the
|
||||
* asset, edited in the inspector like an animation's.
|
||||
*/
|
||||
export class AutotileEditor extends AssetTabEditor<AutotileAsset, AutotileCanvas> {
|
||||
/** Public for the check scripts. */
|
||||
readonly kindPicker: Dropdown;
|
||||
readonly strip: PictureStrip;
|
||||
|
||||
constructor(state: EditorState) {
|
||||
super(state, { noun: 'Autotile', infoWidth: INFO_WIDTH });
|
||||
this.kindPicker = new Dropdown('—', { width: KIND_WIDTH, onClick: () => this.openKindPicker() });
|
||||
this.strip = new PictureStrip(state);
|
||||
this.mount(new AutotileCanvas(state), [this.kindPicker]);
|
||||
}
|
||||
|
||||
/** The board left, the picture list right – the list keeps its width, the board takes the rest. */
|
||||
protected override body(canvas: AutotileCanvas): Widget {
|
||||
const row = new HStack();
|
||||
row.add(canvas, 1);
|
||||
row.add(this.strip);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Only called while the tab is visible. */
|
||||
tick(dt: number): void {
|
||||
this.canvas.tick(dt);
|
||||
}
|
||||
|
||||
/** Whether the board moves by itself – an animated autotile has no event to wake it. */
|
||||
get isPlaying(): boolean {
|
||||
return this.canvas.animates;
|
||||
}
|
||||
|
||||
protected override selectedId(): string {
|
||||
return this.state.selectedAutotileId ?? '';
|
||||
}
|
||||
|
||||
protected override isMine(asset: Asset): asset is AutotileAsset {
|
||||
return asset.type === 'autotile';
|
||||
}
|
||||
|
||||
protected override infoText(asset: AutotileAsset): string {
|
||||
const info = readAutotile(this.state.project, asset);
|
||||
const kind = kindLabel(this.state, asset);
|
||||
const pictures = asset.pictures.length;
|
||||
const steps = `${pictures} ${pictures === 1 ? 'picture' : 'pictures'}`;
|
||||
if (info.problem !== '') return `${kind} · ${steps} · not usable yet`;
|
||||
const tiles = autotileCombinations(info.kind).length;
|
||||
const fps = pictures > 1 ? ` · ${formatNumber(autotileFps(asset), 2)} fps` : '';
|
||||
return `${kind} · ${info.tile} px · ${steps}${fps} · ${tiles} tiles`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: AutotileAsset | null): void {
|
||||
this.strip.show(asset?.id ?? '');
|
||||
this.kindPicker.setText(asset ? kindLabel(this.state, asset) : '—');
|
||||
this.kindPicker.setEnabled(asset !== null);
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
}
|
||||
|
||||
private openKindPicker(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const current = autotileKind(asset);
|
||||
const items: MenuItem[] = AUTOTILE_KINDS.map((kind) => ({
|
||||
label: `${kind === current ? '● ' : ''}${optionLabel(this.state, asset, kind)}`,
|
||||
onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, AUTOTILE_KIND_KEY, kind)),
|
||||
}));
|
||||
new Menu(items).openBelow(this.kindPicker);
|
||||
}
|
||||
}
|
||||
|
||||
/** The label the schema gives the kind, so the picker and the readout cannot drift apart. */
|
||||
function optionLabel(state: EditorState, asset: AutotileAsset, kind: string): string {
|
||||
const def = propDef(state.project, asset, AUTOTILE_KIND_KEY);
|
||||
const option = def?.kind === 'enum' ? def.options.find((o) => o.value === kind) : undefined;
|
||||
return option?.label ?? kind;
|
||||
}
|
||||
|
||||
function kindLabel(state: EditorState, asset: AutotileAsset): string {
|
||||
return optionLabel(state, asset, autotileKind(asset));
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Graphics } from 'pixi.js';
|
||||
import {
|
||||
AddAutotilePictureCommand,
|
||||
MoveAutotilePictureCommand,
|
||||
RemoveAutotilePictureCommand,
|
||||
} from '../../core/commands/autotileCommands';
|
||||
import type { AutotileAsset } from '../../core/model/Asset';
|
||||
import { pictureChoices, textureRefLabel } from '../../core/properties/textureRef';
|
||||
import {
|
||||
Button,
|
||||
type DropSpot,
|
||||
HStack,
|
||||
Label,
|
||||
ListView,
|
||||
Menu,
|
||||
type MenuItem,
|
||||
ScrollContainer,
|
||||
Theme,
|
||||
VStack,
|
||||
} from '../../ui';
|
||||
import type { EditorState } from '../state';
|
||||
|
||||
const WIDTH = 210;
|
||||
|
||||
/**
|
||||
* Where a dragged row lands, counted in the list it has *left* – which is what the move command
|
||||
* takes. A drop on the middle of a row takes that row's place: the list is flat, so there is no
|
||||
* "inside" for it to mean, and without this a drag downwards onto the next row would cancel itself
|
||||
* out. The gaps above and below a row are insert points, and one below the dragged row shifts up
|
||||
* by one once the row is out. `-1` is "nothing to do".
|
||||
*/
|
||||
export function dropIndex(from: number, spot: DropSpot, length: number): number {
|
||||
const last = length - 1;
|
||||
if (from < 0 || from > last) return -1;
|
||||
if (spot.at === 'end') return last;
|
||||
const target = Number(spot.targetId);
|
||||
if (!Number.isInteger(target) || target < 0 || target > last) return -1;
|
||||
if (spot.at === 'onto') return target;
|
||||
const insert = target + (spot.at === 'after' ? 1 : 0);
|
||||
return Math.min(last, insert > from ? insert - 1 : insert);
|
||||
}
|
||||
|
||||
/**
|
||||
* The picture list beside the board (#118): one row per animation step, in the order they run.
|
||||
* Rows are addressed by their place rather than by the picture they name – the same block may be
|
||||
* used twice in one animation, and then two rows carry the same id.
|
||||
*/
|
||||
export class PictureStrip extends VStack {
|
||||
/** Public for the check scripts. */
|
||||
readonly addButton: Button;
|
||||
readonly removeButton: Button;
|
||||
readonly list = new ListView();
|
||||
private readonly title = new Label('Pictures', { style: 'muted' });
|
||||
private readonly scroll = new ScrollContainer();
|
||||
private readonly border = new Graphics();
|
||||
private assetId = '';
|
||||
private selected = -1;
|
||||
|
||||
constructor(private readonly state: EditorState) {
|
||||
super({ gap: Theme.gap, padding: Theme.padding });
|
||||
this.minWidth = WIDTH;
|
||||
this.addButton = new Button('+ Picture', { onClick: () => this.openPicturePicker() });
|
||||
this.removeButton = new Button('Remove', { onClick: () => this.removeSelected() });
|
||||
const header = new HStack({ gap: Theme.gap, align: 'center' });
|
||||
header.add(this.title);
|
||||
this.add(header);
|
||||
this.list.emptyText = 'No pictures yet';
|
||||
this.list.onSelect = (id) => this.select(id === null ? -1 : Number(id));
|
||||
this.list.onDrop = (draggedId, spot) => this.drop(Number(draggedId), spot);
|
||||
this.list.onContextMenu = (id, x, y) => this.openRowMenu(Number(id), x, y);
|
||||
this.scroll.setContent(this.list);
|
||||
this.add(this.scroll, 1);
|
||||
const buttons = new HStack({ gap: Theme.gap });
|
||||
buttons.add(this.addButton, 1);
|
||||
buttons.add(this.removeButton, 1);
|
||||
this.add(buttons);
|
||||
this.addChildAt(this.border, 0);
|
||||
|
||||
state.on('project', () => this.refresh());
|
||||
state.on('load', () => this.show(''));
|
||||
}
|
||||
|
||||
show(assetId: string): void {
|
||||
if (this.assetId === assetId) {
|
||||
this.refresh();
|
||||
return;
|
||||
}
|
||||
this.assetId = assetId;
|
||||
this.selected = -1;
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
/** The rows as shown and which one is picked – the check scripts read this. */
|
||||
debugInfo(): { pictures: string[]; selected: number } {
|
||||
return { pictures: this.asset()?.pictures.map((id) => this.labelOf(id)) ?? [], selected: this.selected };
|
||||
}
|
||||
|
||||
private asset(): AutotileAsset | undefined {
|
||||
const asset = this.state.project.assets.find((a) => a.id === this.assetId);
|
||||
return asset?.type === 'autotile' ? asset : undefined;
|
||||
}
|
||||
|
||||
private labelOf(id: string): string {
|
||||
return textureRefLabel(this.state.project, id);
|
||||
}
|
||||
|
||||
private refresh(): void {
|
||||
const asset = this.asset();
|
||||
const pictures = asset?.pictures ?? [];
|
||||
if (this.selected >= pictures.length) this.selected = pictures.length - 1;
|
||||
this.list.setItems(
|
||||
pictures.map((id, index) => ({
|
||||
id: String(index),
|
||||
label: this.labelOf(id),
|
||||
hint: String(index + 1),
|
||||
})),
|
||||
);
|
||||
this.list.select(this.selected >= 0 ? String(this.selected) : null, true);
|
||||
this.scroll.relayoutContent();
|
||||
this.addButton.setEnabled(asset !== undefined);
|
||||
this.removeButton.setEnabled(this.selected >= 0);
|
||||
this.title.setText(pictures.length === 1 ? '1 picture' : `${pictures.length} pictures`);
|
||||
}
|
||||
|
||||
private select(index: number): void {
|
||||
this.selected = index;
|
||||
this.removeButton.setEnabled(index >= 0);
|
||||
}
|
||||
|
||||
private removeSelected(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset || this.selected < 0) return;
|
||||
this.state.execute(new RemoveAutotilePictureCommand(asset.id, this.selected));
|
||||
}
|
||||
|
||||
/** Every texture and every frame; an autotile block is one of them, whole or cut out. */
|
||||
private openPicturePicker(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const items: MenuItem[] = pictureChoices(this.state.project).map((choice) => ({
|
||||
label: choice.label,
|
||||
onSelect: () => this.state.execute(new AddAutotilePictureCommand(asset.id, choice.id)),
|
||||
}));
|
||||
if (items.length === 0) items.push({ label: 'No textures yet', onSelect: () => undefined });
|
||||
new Menu(items, { searchable: true }).openBelow(this.addButton);
|
||||
}
|
||||
|
||||
private openRowMenu(index: number, x: number, y: number): Menu | undefined {
|
||||
const asset = this.asset();
|
||||
if (!asset) return undefined;
|
||||
this.select(index);
|
||||
this.list.select(String(index), true);
|
||||
const menu = new Menu([
|
||||
{
|
||||
label: 'Remove',
|
||||
danger: true,
|
||||
onSelect: () => this.state.execute(new RemoveAutotilePictureCommand(asset.id, index)),
|
||||
},
|
||||
]);
|
||||
menu.open(x, y);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/** Dragging a row to another place is what reorders the animation. */
|
||||
private drop(from: number, spot: DropSpot): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const to = dropIndex(from, spot, asset.pictures.length);
|
||||
if (to === from || to < 0) return;
|
||||
if (this.state.execute(new MoveAutotilePictureCommand(asset.id, from, to))) this.select(to);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
super.redraw();
|
||||
this.border.clear().rect(0, 0, 1, this.h).fill(Theme.colors.border);
|
||||
}
|
||||
}
|
||||
@@ -507,6 +507,22 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
fitAt: () => centreOf(editor.animationEditor.fitButton),
|
||||
},
|
||||
},
|
||||
autotile: {
|
||||
current: () => editor.autotileEditor.currentAssetId,
|
||||
info: () => editor.autotileEditor.shownInfo,
|
||||
/** What the board drew: tiles, the picture it is showing and what stands in the way. */
|
||||
board: () => editor.autotileEditor.canvas.debugInfo(),
|
||||
pictures: () => editor.autotileEditor.strip.debugInfo(),
|
||||
/** A tile of the board: its quarters and where each of them is drawn. */
|
||||
tile: (index: number) => editor.autotileEditor.canvas.tileDrawn(index),
|
||||
/** Which board tile a cell of the sample map became – `-1` when that cell is empty. */
|
||||
sampleTile: (row: number, column: number) => editor.autotileEditor.canvas.sampleTile(row, column),
|
||||
kindPickerAt: () => centreOf(editor.autotileEditor.kindPicker),
|
||||
addPictureAt: () => centreOf(editor.autotileEditor.strip.addButton),
|
||||
removePictureAt: () => centreOf(editor.autotileEditor.strip.removeButton),
|
||||
pictureRowAt: (index: number) => editor.autotileEditor.strip.list.rowGlobal(String(index)),
|
||||
fitAt: () => centreOf(editor.autotileEditor.fitButton),
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
pickerWidth: () => containerEditor.picker.w,
|
||||
|
||||
@@ -23,9 +23,18 @@ export interface GraphView {
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
/** The view after zooming by `factor` about a screen point, which keeps the graph point under it. */
|
||||
export function zoomedAbout(view: GraphView, at: Point, factor: number): GraphView {
|
||||
const zoom = clamp(view.zoom * factor, ZOOM_LIMITS.min, ZOOM_LIMITS.max);
|
||||
/**
|
||||
* The view after zooming by `factor` about a screen point, which keeps the graph point under it.
|
||||
* `limits` are the graph's own unless a surface with other content says otherwise – the autotile
|
||||
* board shows 48 px tiles, which one wants to come closer to than a node graph.
|
||||
*/
|
||||
export function zoomedAbout(
|
||||
view: GraphView,
|
||||
at: Point,
|
||||
factor: number,
|
||||
limits: { min: number; max: number } = ZOOM_LIMITS,
|
||||
): GraphView {
|
||||
const zoom = clamp(view.zoom * factor, limits.min, limits.max);
|
||||
const graphX = (at.x - view.pan.x) / view.zoom;
|
||||
const graphY = (at.y - view.pan.y) / view.zoom;
|
||||
return { pan: { x: at.x - graphX * zoom, y: at.y - graphY * zoom }, zoom };
|
||||
|
||||
@@ -523,6 +523,13 @@ export class EditorState {
|
||||
return findAssetOf(this.project, s.assetId, 'animation')?.id ?? null;
|
||||
}
|
||||
|
||||
/** The autotile the selection refers to; only the asset itself, it has nothing inside to pick. */
|
||||
get selectedAutotileId(): string | null {
|
||||
const s = this.selection;
|
||||
if (s?.kind !== 'asset') return null;
|
||||
return findAssetOf(this.project, s.assetId, 'autotile')?.id ?? null;
|
||||
}
|
||||
|
||||
/** The timeline row the selection is about; undefined when it is about something else. */
|
||||
get selectedTrackId(): string | undefined {
|
||||
return this.selection?.kind === 'track' ? this.selection.trackId : undefined;
|
||||
|
||||
Reference in new issue
Block a user