Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0607221423
commit
33a7b1b63e
30 files changed
+2060
-20
No files matched your search
@@ -105,7 +105,7 @@ export class Inspector extends Panel {
|
||||
if (s.kind === 'frame') return frameSignature(this.state.project, s.assetId, s.frameId);
|
||||
if (s.kind === 'annotation') return annotationSignature(this.state.project, s.assetId, s.annotationId);
|
||||
if (s.kind === 'track') return trackSignature(this.state.project, s.assetId, s.trackId, s.keys);
|
||||
return objectSignature(this.state.project, s.holderId, s.objectIds, s.objectId);
|
||||
return objectSignature(this.state, s.holderId, s.objectIds, s.objectId);
|
||||
}
|
||||
|
||||
/** Pushes current model values into the widgets (after undo, drag in viewport, …). */
|
||||
|
||||
@@ -44,9 +44,10 @@ import { aspectActions, sizeCommand } from './aspectAction';
|
||||
import { defSignature } from './assetSection';
|
||||
import { actionIf, createFieldRow, type FieldRow, labelledRow, type RowActions } from './fieldFactory';
|
||||
import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './host';
|
||||
import type { ListSlot } from '../state';
|
||||
import type { EditorState, ListSlot } from '../state';
|
||||
import { parentRow } from './parentRow';
|
||||
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
|
||||
import { buildTilemapSection, tilemapSignature } from './tilemapSection';
|
||||
import { overrideValue } from './valueSpecs';
|
||||
import { buildVariable } from './variableRows';
|
||||
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
|
||||
@@ -62,13 +63,14 @@ export function objectRows(project: Project, asset: Asset, object: SceneObject):
|
||||
: schemaForObject(project, asset, object).filter((def) => !isCommonProp(def.key));
|
||||
}
|
||||
|
||||
/** Structure-defining facts of an object selection: parent, template link and schema. */
|
||||
/** Structure-defining facts of an object selection: parent, template link, schema and painting. */
|
||||
export function objectSignature(
|
||||
project: Project,
|
||||
state: EditorState,
|
||||
holderId: string,
|
||||
objectIds: readonly string[],
|
||||
objectId: string,
|
||||
): string {
|
||||
const project = state.project;
|
||||
if (objectIds.length > 1) return `multi|${objectIds.join(',')}`;
|
||||
const holder = findObjectHolder(project, holderId);
|
||||
const view = holder && resolvedHolder(project, holder);
|
||||
@@ -81,7 +83,8 @@ export function objectSignature(
|
||||
? 'instance'
|
||||
: '';
|
||||
const rows = schemaForObject(project, asset, object);
|
||||
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',');
|
||||
const painting = asset.type === 'tilemap' ? `|${tilemapSignature(state, asset)}` : '';
|
||||
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',') + painting;
|
||||
}
|
||||
|
||||
/** Several objects: what they are, and the actions that apply to all of them at once. */
|
||||
@@ -204,6 +207,7 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
|
||||
markObjectRows(host, object, asset, target);
|
||||
|
||||
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
|
||||
if (asset.type === 'tilemap') buildTilemapSection(host, asset);
|
||||
}
|
||||
|
||||
function assetLink(host: InspectorHost, label: string, asset: Asset): void {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The painting side of a placed tilemap (#128): which layer is painted on, with which autotile and
|
||||
* with which tool. It hangs below the ordinary rows of the object, the way a container's content
|
||||
* section does – the grid size and the cell size are plain properties and are shown above.
|
||||
*
|
||||
* Nothing here belongs to the model except the layers themselves: tool, autotile and layer are
|
||||
* editor state (`state.tilemapPaint`), so opening a project does not decide what one paints with.
|
||||
*/
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
MoveTilemapLayerCommand,
|
||||
RemoveTilemapLayerCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
} from '../../core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../core/model/Asset';
|
||||
import { isLayerVisible } from '../../core/model/tilemap';
|
||||
import { Button, Checkbox, HStack, Theme } from '../../ui';
|
||||
import type { EditorState, TilemapTool } from '../state';
|
||||
import { addGap, addNote, addTitle, type InspectorHost } from './host';
|
||||
|
||||
const TOOLS: { tool: TilemapTool; label: string; hint: string }[] = [
|
||||
{ tool: 'select', label: 'Select', hint: 'Drag the map to move it, as any other object.' },
|
||||
{ tool: 'brush', label: 'Brush', hint: 'Drag over the map to paint a line of cells.' },
|
||||
{ tool: 'erase', label: 'Erase', hint: 'Drag over the map to clear cells.' },
|
||||
{ tool: 'rect', label: 'Rect', hint: 'Drag to fill the box you span.' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The layer that is painted on. The one chosen in the inspector when it still exists, otherwise
|
||||
* the topmost – so a fresh selection can be painted on without choosing anything first.
|
||||
*/
|
||||
export function paintedLayerId(state: EditorState, asset: TilemapAsset): string {
|
||||
const chosen = state.tilemapPaint.layerId;
|
||||
if (asset.layers.some((layer) => layer.id === chosen)) return chosen;
|
||||
return asset.layers[asset.layers.length - 1]?.id ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The widgets of the section as they were last built. Every one of them carries an icon or a name
|
||||
* that is on screen elsewhere too – the autotile's row in the asset panel, the layer's name – so a
|
||||
* check cannot find them by their text; this is what it clicks instead.
|
||||
*/
|
||||
export interface TilemapSectionWidgets {
|
||||
tools: Map<TilemapTool, Button>;
|
||||
autotiles: Map<string, Button>;
|
||||
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
|
||||
addLayer: Button;
|
||||
}
|
||||
|
||||
let built: TilemapSectionWidgets | null = null;
|
||||
|
||||
export function currentTilemapSection(): TilemapSectionWidgets | null {
|
||||
return built;
|
||||
}
|
||||
|
||||
export function buildTilemapSection(host: InspectorHost, asset: TilemapAsset): void {
|
||||
const { state } = host;
|
||||
const paint = state.tilemapPaint;
|
||||
const widgets: TilemapSectionWidgets = {
|
||||
tools: new Map(),
|
||||
autotiles: new Map(),
|
||||
layers: [],
|
||||
addLayer: new Button('Add layer', {
|
||||
onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)),
|
||||
}),
|
||||
};
|
||||
built = widgets;
|
||||
|
||||
addTitle(host, 'PAINTING');
|
||||
const tools = new HStack({ gap: Theme.gap });
|
||||
for (const { tool, label } of TOOLS) {
|
||||
const button = new Button(label, { onClick: () => state.setTilemapPaint({ tool }) });
|
||||
button.toggled = paint.tool === tool;
|
||||
widgets.tools.set(tool, button);
|
||||
tools.add(button, 1);
|
||||
}
|
||||
host.body.add(tools);
|
||||
addNote(host, TOOLS.find((t) => t.tool === paint.tool)?.hint ?? '', 'small');
|
||||
addNote(host, 'Shift-drag still pans the view.', 'small');
|
||||
|
||||
addTitle(host, 'AUTOTILES');
|
||||
const autotiles = state.project.assets.filter((a) => a.type === 'autotile');
|
||||
if (autotiles.length === 0) addNote(host, 'The project has no autotile yet.');
|
||||
for (const autotile of autotiles) {
|
||||
const button = new Button(autotile.name, {
|
||||
align: 'left',
|
||||
// Choosing a tile is how one starts painting, so it reaches for the brush by itself.
|
||||
onClick: () =>
|
||||
state.setTilemapPaint({
|
||||
autotileId: autotile.id,
|
||||
tool: paint.tool === 'select' || paint.tool === 'erase' ? 'brush' : paint.tool,
|
||||
}),
|
||||
});
|
||||
button.toggled = paint.autotileId === autotile.id;
|
||||
widgets.autotiles.set(autotile.id, button);
|
||||
host.body.add(button);
|
||||
}
|
||||
if (autotiles.length > 0 && !autotiles.some((a) => a.id === paint.autotileId))
|
||||
addNote(host, 'Choose an autotile to paint with.', 'small');
|
||||
|
||||
addTitle(host, 'LAYERS');
|
||||
addNote(host, 'The last layer is drawn on top.', 'small');
|
||||
const painted = paintedLayerId(state, asset);
|
||||
// Bottom-up on screen, as in every layer list: the topmost row is the one drawn last.
|
||||
for (let index = asset.layers.length - 1; index >= 0; index--) {
|
||||
const layer = asset.layers[index]!;
|
||||
const row = new HStack({ gap: Theme.gap });
|
||||
const visible = new Checkbox(isLayerVisible(layer));
|
||||
visible.onChange = (checked) =>
|
||||
state.execute(new SetTilemapLayerHiddenCommand(asset.id, layer.id, !checked));
|
||||
row.add(visible);
|
||||
const choose = new Button(layer.name, {
|
||||
align: 'left',
|
||||
onClick: () => state.setTilemapPaint({ layerId: layer.id }),
|
||||
});
|
||||
choose.toggled = layer.id === painted;
|
||||
row.add(choose, 1);
|
||||
const up = new Button('↑', {
|
||||
variant: 'ghost',
|
||||
width: 24,
|
||||
onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index + 1)),
|
||||
});
|
||||
const down = new Button('↓', {
|
||||
variant: 'ghost',
|
||||
width: 24,
|
||||
onClick: () => state.execute(new MoveTilemapLayerCommand(asset.id, index, index - 1)),
|
||||
});
|
||||
const remove = new Button('✕', {
|
||||
variant: 'ghost',
|
||||
width: 24,
|
||||
onClick: () => state.execute(new RemoveTilemapLayerCommand(asset.id, layer.id)),
|
||||
});
|
||||
row.add(up);
|
||||
row.add(down);
|
||||
row.add(remove);
|
||||
widgets.layers.push({ id: layer.id, name: choose, visible, up, down, remove });
|
||||
host.body.add(row);
|
||||
}
|
||||
addGap(host);
|
||||
host.body.add(widgets.addLayer);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the section is built from, for the inspector's rebuild check: the layers and what is
|
||||
* painted with. The cells themselves are deliberately absent – a stroke must not rebuild the
|
||||
* panel under the pointer that is drawing it.
|
||||
*/
|
||||
export function tilemapSignature(state: EditorState, asset: TilemapAsset): string {
|
||||
const paint = state.tilemapPaint;
|
||||
const layers = asset.layers.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}`).join(',');
|
||||
return `${paint.tool}|${paint.autotileId}|${paintedLayerId(state, asset)}|${layers}`;
|
||||
}
|
||||
Reference in new issue
Block a user