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
@@ -41,6 +41,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'animation', root: '', tracks: [] };
|
||||
case 'autotile':
|
||||
return { ...base, type: 'autotile', pictures: [] };
|
||||
// A tilemap without a layer could not be painted on, so it starts with one.
|
||||
case 'tilemap':
|
||||
return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] };
|
||||
case 'statemachine':
|
||||
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
|
||||
case 'shader':
|
||||
|
||||
@@ -14,6 +14,11 @@ export function nextAssetName(project: Project, base: string): string {
|
||||
return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base;
|
||||
}
|
||||
|
||||
/** "Layer 1", "Layer 2", … – the first numbered name none of `taken` holds. */
|
||||
export function nextNumberedName(taken: readonly string[], base: string): string {
|
||||
return firstFree(new Set(taken), (i) => `${base} ${i}`, 1);
|
||||
}
|
||||
|
||||
/** "var1", "var2", … – the first variable name not yet used by the behavior. */
|
||||
export function nextVariableName(asset: BehaviorAsset | VariablesAsset, base = 'var'): string {
|
||||
return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
/** Lookups that throw a `CommandError` when the target does not exist. */
|
||||
import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type { Asset, AutotileAsset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset';
|
||||
import type {
|
||||
Asset,
|
||||
AutotileAsset,
|
||||
BehaviorAsset,
|
||||
StateMachineAsset,
|
||||
TilemapAsset,
|
||||
TilemapLayer,
|
||||
VariablesAsset,
|
||||
} from '../model/Asset';
|
||||
import { findLayer } from '../model/tilemap';
|
||||
import { findObject, type ObjectTree } from '../model/hierarchy';
|
||||
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
|
||||
import {
|
||||
@@ -85,6 +94,18 @@ export function requireAutotile(project: Project, assetId: string): AutotileAsse
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireTilemap(project: Project, assetId: string): TilemapAsset {
|
||||
const asset = findAssetOf(project, assetId, 'tilemap');
|
||||
if (!asset) throw new CommandError(`Tilemap ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireTilemapLayer(project: Project, assetId: string, layerId: string): TilemapLayer {
|
||||
const layer = findLayer(requireTilemap(project, assetId), layerId);
|
||||
if (!layer) throw new CommandError(`Layer ${layerId} not found`);
|
||||
return layer;
|
||||
}
|
||||
|
||||
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
|
||||
const asset = findAssetOf(project, assetId, 'statemachine');
|
||||
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers:
|
||||
* the pointer decides where a stroke ends, so an undo has to take back the whole line and not the
|
||||
* last cell of it. The grid size and the cell size are ordinary properties and go through
|
||||
* `SetAssetPropertyCommand`.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { TilemapLayer } from '../model/Asset';
|
||||
import { cellKey } from '../model/tilemap';
|
||||
import type { Project } from '../model/Project';
|
||||
import { nextNumberedName } from './naming';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireTilemap, requireTilemapLayer } from './require';
|
||||
|
||||
/** One cell as a stroke wants it: `autotileId` empty erases. */
|
||||
export interface CellEdit {
|
||||
x: number;
|
||||
y: number;
|
||||
autotileId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts
|
||||
* a stroke over another autotile back exactly – not merely empty.
|
||||
*/
|
||||
export class PaintTilesCommand implements Command {
|
||||
readonly label: string;
|
||||
private previous = new Map<string, string>();
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly layerId: string,
|
||||
private readonly edits: readonly CellEdit[],
|
||||
label?: string,
|
||||
) {
|
||||
this.label = label ?? 'Paint tiles';
|
||||
}
|
||||
|
||||
/** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */
|
||||
static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean {
|
||||
return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.autotileId);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
|
||||
this.previous = new Map();
|
||||
for (const edit of this.edits) {
|
||||
const key = cellKey(edit.x, edit.y);
|
||||
if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? '');
|
||||
if (edit.autotileId) layer.cells[key] = edit.autotileId;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
|
||||
for (const [key, value] of this.previous) {
|
||||
if (value) layer.cells[key] = value;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class AddTilemapLayerCommand implements Command {
|
||||
readonly label = 'Add layer';
|
||||
readonly layerId = newId();
|
||||
|
||||
constructor(private readonly assetId: string) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTilemap(project, this.assetId);
|
||||
const name = nextNumberedName(
|
||||
asset.layers.map((layer) => layer.name),
|
||||
'Layer',
|
||||
);
|
||||
asset.layers.push({ id: this.layerId, name, cells: {} });
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireTilemap(project, this.assetId);
|
||||
asset.layers = asset.layers.filter((layer) => layer.id !== this.layerId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */
|
||||
export class RemoveTilemapLayerCommand implements Command {
|
||||
readonly label = 'Remove layer';
|
||||
private removed: TilemapLayer | null = null;
|
||||
private index = -1;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly layerId: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireTilemap(project, this.assetId);
|
||||
if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer');
|
||||
this.index = asset.layers.findIndex((layer) => layer.id === this.layerId);
|
||||
const layer = asset.layers[this.index];
|
||||
if (!layer) throw new CommandError(`Layer ${this.layerId} not found`);
|
||||
this.removed = layer;
|
||||
asset.layers.splice(this.index, 1);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (this.removed) requireTilemap(project, this.assetId).layers.splice(this.index, 0, this.removed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves a layer in the drawing order; the last layer in the list is drawn on top. */
|
||||
export class MoveTilemapLayerCommand implements Command {
|
||||
readonly label = 'Reorder layers';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly from: number,
|
||||
private readonly to: number,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.move(project, this.from, this.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.move(project, this.to, this.from);
|
||||
}
|
||||
|
||||
private move(project: Project, from: number, to: number): void {
|
||||
const asset = requireTilemap(project, this.assetId);
|
||||
const layer = asset.layers[from];
|
||||
if (!layer || to < 0 || to >= asset.layers.length) throw new CommandError('No such layer');
|
||||
asset.layers.splice(from, 1);
|
||||
asset.layers.splice(to, 0, layer);
|
||||
}
|
||||
}
|
||||
|
||||
export class RenameTilemapLayerCommand implements Command {
|
||||
readonly label = 'Rename layer';
|
||||
private previous = '';
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly layerId: string,
|
||||
private readonly name: string,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
|
||||
this.previous = layer.name;
|
||||
layer.name = this.name;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
requireTilemapLayer(project, this.assetId, this.layerId).name = this.previous;
|
||||
}
|
||||
}
|
||||
|
||||
/** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */
|
||||
export class SetTilemapLayerHiddenCommand implements Command {
|
||||
readonly label: string;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly layerId: string,
|
||||
private readonly hidden: boolean,
|
||||
) {
|
||||
this.label = hidden ? 'Hide layer' : 'Show layer';
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.apply(project, this.hidden);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
this.apply(project, !this.hidden);
|
||||
}
|
||||
|
||||
private apply(project: Project, hidden: boolean): void {
|
||||
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
|
||||
if (hidden) layer.hidden = true;
|
||||
else delete layer.hidden;
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,28 @@ export interface AutotileAsset extends AssetBase {
|
||||
pictures: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the autotile lying there, and
|
||||
* a layer nobody switched off is drawn. Which tile of that autotile a cell shows is worked out
|
||||
* from its neighbours while drawing, never stored – see `model/tilemap.ts`.
|
||||
*/
|
||||
export interface TilemapLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: boolean;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A grid painted with autotiles, placed in a scene as a sprite is. How wide the grid is and how
|
||||
* big one cell is are properties, so the inspector edits them with undo like any other row; the
|
||||
* layers are intrinsic data, as a texture's frames are (#128).
|
||||
*/
|
||||
export interface TilemapAsset extends AssetBase {
|
||||
type: 'tilemap';
|
||||
layers: TilemapLayer[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph is the motion of one particle: every output of its `Output` node is a function of the
|
||||
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
|
||||
@@ -217,6 +239,7 @@ export type Asset =
|
||||
| TextureAsset
|
||||
| AnimationAsset
|
||||
| AutotileAsset
|
||||
| TilemapAsset
|
||||
| StateMachineAsset
|
||||
| ShaderAsset
|
||||
| FontAsset
|
||||
|
||||
@@ -10,6 +10,7 @@ export const ASSET_TYPES = [
|
||||
'texture',
|
||||
'animation',
|
||||
'autotile',
|
||||
'tilemap',
|
||||
'statemachine',
|
||||
'shader',
|
||||
'font',
|
||||
@@ -31,6 +32,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
texture: 'Texture',
|
||||
animation: 'Animation',
|
||||
autotile: 'Autotile',
|
||||
tilemap: 'Tilemap',
|
||||
statemachine: 'State machine',
|
||||
shader: 'Shader',
|
||||
font: 'Font',
|
||||
@@ -43,7 +45,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
|
||||
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
|
||||
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. An autotile
|
||||
* is a tile a tilemap draws with (#128), so it is placed no more directly than a texture is.
|
||||
* is a tile a tilemap draws with, so it is placed no more directly than a texture is – what is
|
||||
* placed is the tilemap.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* A tilemap is a grid of cells, and every cell names the autotile lying there. Which of that
|
||||
* autotile's tiles a cell shows is *not* stored: it follows from the neighbours and is worked out
|
||||
* while drawing (#128), so painting one cell fixes the edges of the ones around it by itself.
|
||||
*
|
||||
* Cells are sparse – a map from `"x,y"` to an autotile id – rather than one entry per grid square.
|
||||
* A map is what painting, erasing and reading a neighbour all want, and changing the grid size then
|
||||
* moves nothing: what falls outside is simply not drawn and comes back when the grid grows again.
|
||||
*/
|
||||
import type { AutotileAsset, TilemapLayer } from './Asset';
|
||||
import {
|
||||
type AutotileKind,
|
||||
autotileKind,
|
||||
type Neighbours,
|
||||
type TileQuadrants,
|
||||
tileQuadrants,
|
||||
} from './autotile';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
|
||||
export const TILEMAP_COLUMNS_KEY = 'columns';
|
||||
export const TILEMAP_ROWS_KEY = 'rows';
|
||||
export const TILEMAP_TILE_KEY = 'tileSize';
|
||||
|
||||
/**
|
||||
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
|
||||
* its size like any other row, and the footprint the scene view picks and frames by has nothing
|
||||
* but the bag (see `properties/bounds.ts`).
|
||||
*/
|
||||
export function tilemapColumns(props: PropertyBag): number {
|
||||
return wholeAbove(props[TILEMAP_COLUMNS_KEY], 20);
|
||||
}
|
||||
|
||||
export function tilemapRows(props: PropertyBag): number {
|
||||
return wholeAbove(props[TILEMAP_ROWS_KEY], 15);
|
||||
}
|
||||
|
||||
/** Side of one cell in world units; the autotile's own pictures are scaled onto it. */
|
||||
export function tilemapTileSize(props: PropertyBag): number {
|
||||
const size = Number(props[TILEMAP_TILE_KEY]);
|
||||
return Number.isFinite(size) && size > 0 ? size : 48;
|
||||
}
|
||||
|
||||
function wholeAbove(value: unknown, fallback: number): number {
|
||||
const count = Math.floor(Number(value));
|
||||
return Number.isFinite(count) && count >= 1 ? count : fallback;
|
||||
}
|
||||
|
||||
export function tilemapSize(props: PropertyBag): { w: number; h: number } {
|
||||
const tile = tilemapTileSize(props);
|
||||
return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile };
|
||||
}
|
||||
|
||||
export function cellKey(x: number, y: number): string {
|
||||
return `${x},${y}`;
|
||||
}
|
||||
|
||||
export function parseCellKey(key: string): { x: number; y: number } | null {
|
||||
const [x, y] = key.split(',').map(Number);
|
||||
return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null;
|
||||
}
|
||||
|
||||
export function cellAt(layer: TilemapLayer, x: number, y: number): string {
|
||||
return layer.cells[cellKey(x, y)] ?? '';
|
||||
}
|
||||
|
||||
export function isLayerVisible(layer: TilemapLayer): boolean {
|
||||
return layer.hidden !== true;
|
||||
}
|
||||
|
||||
export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined {
|
||||
return asset.layers.find((layer) => layer.id === layerId);
|
||||
}
|
||||
|
||||
export function inGrid(props: PropertyBag, x: number, y: number): boolean {
|
||||
return x >= 0 && y >= 0 && x < tilemapColumns(props) && y < tilemapRows(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which neighbours a cell is joined to. Only the *same* autotile joins: a different one counts as
|
||||
* empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets
|
||||
* are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge.
|
||||
*/
|
||||
export function neighboursOf(
|
||||
props: PropertyBag,
|
||||
layer: TilemapLayer,
|
||||
x: number,
|
||||
y: number,
|
||||
autotileId: string,
|
||||
): Neighbours {
|
||||
const same = (dx: number, dy: number): boolean =>
|
||||
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === autotileId;
|
||||
return {
|
||||
n: same(0, -1),
|
||||
e: same(1, 0),
|
||||
s: same(0, 1),
|
||||
w: same(-1, 0),
|
||||
ne: same(1, -1),
|
||||
se: same(1, 1),
|
||||
sw: same(-1, 1),
|
||||
nw: same(-1, -1),
|
||||
};
|
||||
}
|
||||
|
||||
/** One cell as it is drawn: where it sits, which autotile it is and which quarters build it. */
|
||||
export interface TileDraw {
|
||||
x: number;
|
||||
y: number;
|
||||
autotileId: string;
|
||||
quadrants: TileQuadrants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every cell of one layer that is inside the grid and names an autotile that exists, in reading
|
||||
* order so the result is the same list twice. Cells naming a deleted autotile are skipped rather
|
||||
* than dropped from the model: an undo brings the autotile, and with it the drawing, back.
|
||||
*/
|
||||
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
|
||||
const kinds = new Map<string, AutotileKind>();
|
||||
const kindOf = (id: string): AutotileKind | null => {
|
||||
const known = kinds.get(id);
|
||||
if (known) return known;
|
||||
const autotile: AutotileAsset | undefined = findAssetOf(project, id, 'autotile');
|
||||
if (!autotile) return null;
|
||||
const kind = autotileKind(autotile);
|
||||
kinds.set(id, kind);
|
||||
return kind;
|
||||
};
|
||||
const tiles: TileDraw[] = [];
|
||||
for (const [key, autotileId] of Object.entries(layer.cells)) {
|
||||
const at = parseCellKey(key);
|
||||
if (!at || !autotileId || !inGrid(props, at.x, at.y)) continue;
|
||||
const kind = kindOf(autotileId);
|
||||
if (!kind) continue;
|
||||
tiles.push({
|
||||
x: at.x,
|
||||
y: at.y,
|
||||
autotileId,
|
||||
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, autotileId)),
|
||||
});
|
||||
}
|
||||
return tiles.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
}
|
||||
|
||||
/** The autotiles a tilemap paints with, in the order they first appear – its palette in use. */
|
||||
export function tilemapAutotileIds(asset: { layers: TilemapLayer[] }): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const layer of asset.layers)
|
||||
for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
|
||||
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
|
||||
*/
|
||||
export function cellAtPoint(props: PropertyBag, x: number, y: number): { x: number; y: number } {
|
||||
const tile = tilemapTileSize(props);
|
||||
const { w, h } = tilemapSize(props);
|
||||
return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) };
|
||||
}
|
||||
|
||||
/** Where the top-left corner of a cell sits in the tilemap's own frame, in world units. */
|
||||
export function cellOrigin(props: PropertyBag, x: number, y: number): { x: number; y: number } {
|
||||
const tile = tilemapTileSize(props);
|
||||
const { w, h } = tilemapSize(props);
|
||||
return { x: x * tile - w / 2, y: y * tile - h / 2 };
|
||||
}
|
||||
|
||||
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
|
||||
export function cellsInRect(
|
||||
props: PropertyBag,
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): { x: number; y: number }[] {
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
const x0 = Math.max(0, Math.min(a.x, b.x));
|
||||
const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x));
|
||||
const y0 = Math.max(0, Math.min(a.y, b.y));
|
||||
const y1 = Math.min(tilemapRows(props) - 1, Math.max(a.y, b.y));
|
||||
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) cells.push({ x, y });
|
||||
return cells;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
|
||||
* this a fast drag would paint dots instead of a line.
|
||||
*/
|
||||
export function cellsAlong(
|
||||
from: { x: number; y: number },
|
||||
to: { x: number; y: number },
|
||||
): { x: number; y: number }[] {
|
||||
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
|
||||
if (steps === 0) return [{ ...to }];
|
||||
const cells: { x: number; y: number }[] = [];
|
||||
for (let i = 1; i <= steps; i++)
|
||||
cells.push({
|
||||
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
|
||||
y: Math.round(from.y + ((to.y - from.y) * i) / steps),
|
||||
});
|
||||
return cells;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { referencesAny, trackDef } from './animation';
|
||||
import type { AnimationAsset, Asset, AutotileAsset } from './Asset';
|
||||
import type { AnimationAsset, Asset, AutotileAsset, TilemapAsset } from './Asset';
|
||||
import { overrideSites } from './instance';
|
||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
@@ -71,6 +71,15 @@ export function autotilesUsing(project: Project, ids: readonly string[]): Autoti
|
||||
);
|
||||
}
|
||||
|
||||
/** The tilemaps painted with any of these autotiles – intrinsic data, like an autotile's pictures. */
|
||||
export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
return project.assets.filter(
|
||||
(a): a is TilemapAsset =>
|
||||
a.type === 'tilemap' && a.layers.some((l) => Object.values(l.cells).some((id) => wanted.has(id))),
|
||||
);
|
||||
}
|
||||
|
||||
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
||||
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from './kinds';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { AUTOTILE_FPS_KEY, AUTOTILE_KIND_KEY, AUTOTILE_PLAYBACK_KEY } from '../model/autotile';
|
||||
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
|
||||
import { SLICE_SCALE_KEY } from '../model/texture';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { flatListOf, listItemOf } from './listItems';
|
||||
@@ -140,6 +141,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
|
||||
'collider',
|
||||
'container',
|
||||
'particles',
|
||||
'tilemap',
|
||||
]);
|
||||
|
||||
export function hasTransformProps(type: AssetType): boolean {
|
||||
@@ -244,6 +246,14 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
|
||||
],
|
||||
},
|
||||
],
|
||||
// The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say
|
||||
// how far the grid reaches and how big one cell is drawn. An autotile's own pictures are scaled
|
||||
// onto that cell, so one tilemap can mix autotiles drawn at different resolutions.
|
||||
tilemap: [
|
||||
{ key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 },
|
||||
{ key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 },
|
||||
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
|
||||
],
|
||||
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
|
||||
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
|
||||
// Every row is declared on the asset itself – see `schemaForBag`.
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
translation,
|
||||
} from '../model/matrix';
|
||||
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
|
||||
import { tilemapSize } from '../model/tilemap';
|
||||
import { cameraFrame } from './camera';
|
||||
import { colliderSize } from './collider';
|
||||
import { defaultsFor } from './PropertySchema';
|
||||
@@ -64,6 +65,10 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
|
||||
// framed is the source, which is a point with a handle around it.
|
||||
case 'particles':
|
||||
return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL };
|
||||
// The whole grid, centred on the object's position like every other footprint – so its cells
|
||||
// are the same squares for drawing, for picking and for the outline around it.
|
||||
case 'tilemap':
|
||||
return tilemapSize(props);
|
||||
default:
|
||||
return cameraFrame(props);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,15 @@ import {
|
||||
import { isParticleNodeDef } from '../particles/nodes';
|
||||
import { isShaderNodeDef } from '../shader/nodes';
|
||||
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
|
||||
import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
|
||||
import type {
|
||||
AnimationKey,
|
||||
AnimationTrack,
|
||||
Asset,
|
||||
StateMachineAsset,
|
||||
TextureFrame,
|
||||
TilemapLayer,
|
||||
} from '../model/Asset';
|
||||
import { parseCellKey } from '../model/tilemap';
|
||||
import { isAssetType } from '../model/AssetTypes';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { isSoundFile } from '../model/sound';
|
||||
@@ -175,6 +183,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
|
||||
(id): id is string => typeof id === 'string' && id !== '',
|
||||
);
|
||||
assets.push({ id, type, name, props, pictures });
|
||||
} else if (type === 'tilemap') {
|
||||
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
|
||||
} else if (type === 'animation') {
|
||||
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
|
||||
const root = typeof a.root === 'string' ? a.root : '';
|
||||
@@ -493,6 +503,26 @@ function normalizeTracks(raw: unknown): AnimationTrack[] {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
|
||||
* is dropped; one naming a deleted autotile is kept, like an animation's dangling row, so an undo
|
||||
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
|
||||
*/
|
||||
function normalizeLayers(raw: unknown): TilemapLayer[] {
|
||||
const layers: TilemapLayer[] = [];
|
||||
for (const item of Array.isArray(raw) ? raw : []) {
|
||||
const l = asRecord(item);
|
||||
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
|
||||
const cells: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(asRecord(l.cells)))
|
||||
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
|
||||
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
|
||||
if (l.hidden === true) layer.hidden = true;
|
||||
layers.push(layer);
|
||||
}
|
||||
return layers;
|
||||
}
|
||||
|
||||
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
|
||||
const r = asRecord(raw);
|
||||
const bag: PropertyBag = {};
|
||||
|
||||
@@ -8,6 +8,9 @@ export interface TabPlace<T extends string> {
|
||||
tab: T;
|
||||
x: number;
|
||||
right: number;
|
||||
/** Top edge and height, so a check can tell the tab from a label of the same text elsewhere. */
|
||||
y: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,8 +87,8 @@ export class TabStrip<T extends string> extends HStack {
|
||||
for (const tab of this.order) {
|
||||
const button = this.buttons.get(tab)!;
|
||||
if (!button.visible) continue;
|
||||
const x = button.toGlobal({ x: 0, y: 0 }).x;
|
||||
out.push({ tab, x, right: x + button.w });
|
||||
const at = button.toGlobal({ x: 0, y: 0 });
|
||||
out.push({ tab, x: at.x, right: at.x + button.w, y: at.y, h: button.h });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+57
-1
@@ -4,6 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview';
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { findLayer } from '../core/model/tilemap';
|
||||
import { effectiveScale, gameClock } from '../core/time';
|
||||
import {
|
||||
activeDrag,
|
||||
@@ -28,7 +29,8 @@ import { currentFontPicker } from './font/FontPicker';
|
||||
import { currentNodePalette } from './graph/NodePalette';
|
||||
import { currentSoundPicker } from './sound/SoundPicker';
|
||||
import { currentImagePicker } from './texture/ImagePicker';
|
||||
import type { EditorState } from './state';
|
||||
import { currentTilemapSection } from './inspector/tilemapSection';
|
||||
import type { EditorState, TilemapTool } from './state';
|
||||
|
||||
/**
|
||||
* Installs `window.__gm`, which the check scripts in tools/ use to find widgets by their
|
||||
@@ -248,6 +250,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
locate,
|
||||
locateAll,
|
||||
inspectorField: fieldOf,
|
||||
/** The inspector panel on screen – what tells whether a row of it can be clicked at all. */
|
||||
inspectorRect: () => rectOf(inspector),
|
||||
inspectorInfo: () => inspector.debugInfo(),
|
||||
textColor,
|
||||
overlayTexts,
|
||||
@@ -523,6 +527,58 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
pictureRowAt: (index: number) => editor.autotileEditor.strip.list.rowGlobal(String(index)),
|
||||
fitAt: () => centreOf(editor.autotileEditor.fitButton),
|
||||
},
|
||||
tilemap: {
|
||||
/** What the scene view painted a map with, and on which layer (#128). */
|
||||
paint: () => ({ ...state.tilemapPaint }),
|
||||
/** The cells of one layer as the model holds them: `"x,y"` to autotile id. */
|
||||
cells: (assetId: string, layerId: string) => {
|
||||
const asset = findAssetOf(state.project, assetId, 'tilemap');
|
||||
const layer = asset && findLayer(asset, layerId);
|
||||
return layer ? { ...layer.cells } : null;
|
||||
},
|
||||
layers: (assetId: string) =>
|
||||
findAssetOf(state.project, assetId, 'tilemap')?.layers.map((layer) => ({
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
hidden: layer.hidden === true,
|
||||
cells: Object.keys(layer.cells).length,
|
||||
})) ?? null,
|
||||
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
|
||||
info: (objectId: string) => viewport.tilemapInfo(objectId),
|
||||
gameInfo: (objectId: string) => gameView.tilemapInfo(objectId),
|
||||
/** Where the widgets of the inspector section sit: their texts are on screen elsewhere too. */
|
||||
toolAt: (tool: string) => {
|
||||
const button = currentTilemapSection()?.tools.get(tool as TilemapTool);
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
autotileAt: (assetId: string) => {
|
||||
const button = currentTilemapSection()?.autotiles.get(assetId);
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
addLayerAt: () => {
|
||||
const button = currentTilemapSection()?.addLayer;
|
||||
return button ? centreOf(button) : null;
|
||||
},
|
||||
/** One row of the layer list, top-down as drawn: the layer, its switch and its buttons. */
|
||||
layerRowAt: (index: number) => {
|
||||
const row = currentTilemapSection()?.layers[index];
|
||||
return row
|
||||
? {
|
||||
id: row.id,
|
||||
// `y` is the row's own line, so a check can tell whether it is inside the panel.
|
||||
y: centreOf(row.name).y,
|
||||
name: centreOf(row.name),
|
||||
visible: centreOf(row.visible),
|
||||
up: centreOf(row.up),
|
||||
down: centreOf(row.down),
|
||||
remove: centreOf(row.remove),
|
||||
}
|
||||
: null;
|
||||
},
|
||||
/** Where a cell sits on screen – for a click that paints it or a pixel that proves it. */
|
||||
cellAt: (objectId: string, x: number, y: number) => viewport.cellScreen(objectId, x, y),
|
||||
gameCellAt: (objectId: string, x: number, y: number) => gameView.cellScreen(objectId, x, y),
|
||||
},
|
||||
container: {
|
||||
current: () => containerEditor.currentAssetId,
|
||||
pickerWidth: () => containerEditor.picker.w,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -139,6 +139,19 @@ function pickIds(input: readonly string[], primary?: string): { ids: string[]; p
|
||||
return { ids, primary: primary && ids.includes(primary) ? primary : ids[ids.length - 1]! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select leaves the scene view as it is – the map is picked up and moved like any other object.
|
||||
* The other three paint: brush the chosen autotile cell by cell, erase clears, rect fills the box
|
||||
* a drag spans. Painting is a mode on purpose; a map one cannot move would be worse than a click.
|
||||
*/
|
||||
export type TilemapTool = 'select' | 'brush' | 'erase' | 'rect';
|
||||
|
||||
export interface TilemapPaint {
|
||||
tool: TilemapTool;
|
||||
autotileId: string;
|
||||
layerId: string;
|
||||
}
|
||||
|
||||
export type EditorMode = 'edit' | 'play';
|
||||
export type SaveStatus = 'saved' | 'dirty' | 'saving' | 'error' | 'conflict';
|
||||
|
||||
@@ -184,6 +197,12 @@ export class EditorState {
|
||||
notice = '';
|
||||
/** Keys copied in the Animation tab (#102). Lives for the session, across animations and projects. */
|
||||
keyClipboard: KeyClipboard | null = null;
|
||||
/**
|
||||
* What the scene view paints a tilemap with (#128): the tool, the autotile and the layer it goes
|
||||
* on. Editor state, not model state – like the picked tile of the Animation tab, it is a way of
|
||||
* working rather than something the project remembers.
|
||||
*/
|
||||
tilemapPaint: TilemapPaint = { tool: 'select', autotileId: '', layerId: '' };
|
||||
/** The real (saved) model and its undo stack while `project` is the play-mode copy. */
|
||||
private edit: { project: Project; commands: CommandStack } | null = null;
|
||||
|
||||
@@ -603,6 +622,12 @@ export class EditorState {
|
||||
* Shows `message` in the toolbar and, unless `quiet`, keeps it in the console panel. Quiet is
|
||||
* for the play status lines, which state a mode rather than report an event.
|
||||
*/
|
||||
/** Changes what the scene view paints with; the inspector marks it and the view repaints. */
|
||||
setTilemapPaint(patch: Partial<TilemapPaint>): void {
|
||||
this.tilemapPaint = { ...this.tilemapPaint, ...patch };
|
||||
this.emit('selection');
|
||||
}
|
||||
|
||||
setNotice(message: string, level: NoticeLevel = 'info'): void {
|
||||
this.notice = message;
|
||||
if (message !== '' && level !== 'quiet') consoleLog.push(level, message);
|
||||
|
||||
@@ -6,16 +6,38 @@
|
||||
import { type FederatedPointerEvent, Graphics, Text } from 'pixi.js';
|
||||
import { oneStep } from '../../core/commands/CompoundCommand';
|
||||
import { MoveSceneObjectCommand, PlaceAssetCommand } from '../../core/commands/sceneObjectCommands';
|
||||
import { type CellEdit, PaintTilesCommand } from '../../core/commands/tilemapCommands';
|
||||
import type { TilemapAsset, TilemapLayer } from '../../core/model/Asset';
|
||||
import { hasSceneIcon } from '../../core/model/AssetTypes';
|
||||
import { findObject, subtreeRoots } from '../../core/model/hierarchy';
|
||||
import { objectRef, resolvedHolder } from '../../core/model/instance';
|
||||
import { applyVector, type Extent, invert, linearOf, type Mat } from '../../core/model/matrix';
|
||||
import {
|
||||
apply as applyPoint,
|
||||
applyVector,
|
||||
type Extent,
|
||||
invert,
|
||||
linearOf,
|
||||
type Mat,
|
||||
} from '../../core/model/matrix';
|
||||
import { placementRejection } from '../../core/model/placement';
|
||||
import type { Position, Vec2 } from '../../core/model/Properties';
|
||||
import { findAsset, type ObjectHolder } from '../../core/model/Project';
|
||||
import { findAsset, findAssetOf, type ObjectHolder } from '../../core/model/Project';
|
||||
import {
|
||||
cellAtPoint,
|
||||
cellKey,
|
||||
cellOrigin,
|
||||
cellsAlong,
|
||||
cellsInRect,
|
||||
findLayer,
|
||||
inGrid,
|
||||
tilemapColumns,
|
||||
tilemapRows,
|
||||
tilemapTileSize,
|
||||
} from '../../core/model/tilemap';
|
||||
import { footprintCorners } from '../../core/properties/bounds';
|
||||
import { localMatrixOf, parentTransform, transformOf } from '../../core/properties/transform';
|
||||
import { type EditorState, objectSelection } from '../state';
|
||||
import { type EditorState, objectSelection, type TilemapTool } from '../state';
|
||||
import { paintedLayerId } from '../inspector/tilemapSection';
|
||||
import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui';
|
||||
import { collectHolderItems, type DrawItem } from '../../viewport/items';
|
||||
import { dashedLine, drawSourceIcon } from '../../viewport/symbols';
|
||||
@@ -45,6 +67,26 @@ interface DropPreview {
|
||||
rejection: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A stroke on a placed tilemap. `previous` is what the cells held before the live preview wrote
|
||||
* into them – the same trick a move drag uses: the model shows the stroke at once, and on release
|
||||
* it is put back so the command records it as one undo step.
|
||||
*/
|
||||
interface PaintStroke {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
assetId: string;
|
||||
layerId: string;
|
||||
tool: TilemapTool;
|
||||
autotileId: string;
|
||||
edits: CellEdit[];
|
||||
previous: Map<string, string>;
|
||||
from: Vec2;
|
||||
last: Vec2 | null;
|
||||
/** Where the pointer is now, in cells – what the rectangle preview is drawn from. */
|
||||
to: Vec2;
|
||||
}
|
||||
|
||||
/** A rubber-band selection in progress, in viewport-local pixels. */
|
||||
interface Marquee {
|
||||
start: Vec2;
|
||||
@@ -74,6 +116,7 @@ export class SceneViewport extends FreeViewport {
|
||||
private readonly labels = new Map<string, Text>();
|
||||
private readonly icons = new Map<string, Graphics>();
|
||||
private dragging: ObjectDrag | null = null;
|
||||
private stroke: PaintStroke | null = null;
|
||||
private marquee: Marquee | null = null;
|
||||
private dropPreview: DropPreview | null = null;
|
||||
private lastGizmos: GizmoInfo[] = [];
|
||||
@@ -162,6 +205,19 @@ export class SceneViewport extends FreeViewport {
|
||||
return;
|
||||
}
|
||||
if (this.panTo(e)) return;
|
||||
if (this.stroke) {
|
||||
const item = this.lastItems.find((i) => i.id === this.stroke!.objectId);
|
||||
if (!item) return;
|
||||
const at = this.cellUnder(item, e);
|
||||
if (this.stroke.last && at.x === this.stroke.last.x && at.y === this.stroke.last.y) return;
|
||||
const from = this.stroke.last ?? this.stroke.from;
|
||||
this.stroke.last = at;
|
||||
this.stroke.to = at;
|
||||
// A pointer jumps between events, so the cells in between are filled in as well.
|
||||
if (this.stroke.tool !== 'rect') this.paintCells(item, cellsAlong(from, at));
|
||||
this.redraw();
|
||||
return;
|
||||
}
|
||||
if (this.dragging) {
|
||||
const local = this.toLocal(e.global);
|
||||
const dx = (local.x - this.dragging.pointer.x) / this.view.zoom;
|
||||
@@ -239,6 +295,10 @@ export class SceneViewport extends FreeViewport {
|
||||
return;
|
||||
}
|
||||
this.endPan();
|
||||
if (this.stroke) {
|
||||
this.endStroke();
|
||||
return;
|
||||
}
|
||||
const marquee = this.marquee;
|
||||
this.marquee = null;
|
||||
if (marquee) {
|
||||
@@ -263,6 +323,120 @@ export class SceneViewport extends FreeViewport {
|
||||
if (moves.length > 0) this.state.execute(oneStep('Move objects', moves));
|
||||
}
|
||||
|
||||
/**
|
||||
* The tilemap a click paints on: the primary selection, placed here, with a painting tool
|
||||
* chosen. Anything else – another object, the select tool, a modifier held – leaves the view
|
||||
* doing what it always did.
|
||||
*/
|
||||
private paintTargetOf(objectId: string): { item: DrawItem; asset: TilemapAsset; layerId: string } | null {
|
||||
const paint = this.state.tilemapPaint;
|
||||
if (paint.tool === 'select') return null;
|
||||
const s = this.state.selection;
|
||||
if (s?.kind !== 'object' || s.objectId !== objectId) return null;
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!item || item.type !== 'tilemap') return null;
|
||||
const object = findObject(this.resolved() ?? { objects: [] }, objectId);
|
||||
const asset = object && findAssetOf(this.state.project, object.assetId, 'tilemap');
|
||||
if (!asset) return null;
|
||||
const layerId = paintedLayerId(this.state, asset);
|
||||
return layerId ? { item, asset, layerId } : null;
|
||||
}
|
||||
|
||||
/** The cell under a pointer position, in the tilemap's own grid. */
|
||||
private cellUnder(item: DrawItem, e: FederatedPointerEvent): Vec2 {
|
||||
const local = this.toLocal(e.global);
|
||||
const world = screenToWorld(this.currentView(), local.x, local.y);
|
||||
const inMap = applyPoint(invert(item.m), world);
|
||||
return cellAtPoint(item.props, inMap.x, inMap.y);
|
||||
}
|
||||
|
||||
private startStroke(
|
||||
target: { item: DrawItem; asset: TilemapAsset; layerId: string },
|
||||
holderId: string,
|
||||
e: FederatedPointerEvent,
|
||||
): boolean {
|
||||
const paint = this.state.tilemapPaint;
|
||||
const autotileId = paint.tool === 'erase' ? '' : paint.autotileId;
|
||||
if (paint.tool !== 'erase' && !autotileId) {
|
||||
this.state.setNotice('Choose an autotile to paint with.');
|
||||
return false;
|
||||
}
|
||||
const at = this.cellUnder(target.item, e);
|
||||
this.stroke = {
|
||||
holderId,
|
||||
objectId: target.item.id,
|
||||
assetId: target.asset.id,
|
||||
layerId: target.layerId,
|
||||
tool: paint.tool,
|
||||
autotileId,
|
||||
edits: [],
|
||||
previous: new Map(),
|
||||
from: at,
|
||||
last: null,
|
||||
to: at,
|
||||
};
|
||||
// A rectangle only shows where it would go until the button comes up; the other two draw at once.
|
||||
if (paint.tool !== 'rect') this.paintCells(target.item, [at]);
|
||||
this.redraw();
|
||||
return true;
|
||||
}
|
||||
|
||||
private strokeLayer(stroke: PaintStroke): TilemapLayer | undefined {
|
||||
const asset = findAssetOf(this.state.project, stroke.assetId, 'tilemap');
|
||||
return asset && findLayer(asset, stroke.layerId);
|
||||
}
|
||||
|
||||
/** Writes cells into the model as a preview, remembering what they held. */
|
||||
private paintCells(item: DrawItem, cells: readonly Vec2[]): void {
|
||||
const stroke = this.stroke;
|
||||
const layer = stroke && this.strokeLayer(stroke);
|
||||
if (!stroke || !layer) return;
|
||||
let changed = false;
|
||||
for (const at of cells) {
|
||||
if (!inGrid(item.props, at.x, at.y)) continue;
|
||||
const key = cellKey(at.x, at.y);
|
||||
if (!stroke.previous.has(key)) stroke.previous.set(key, layer.cells[key] ?? '');
|
||||
if ((layer.cells[key] ?? '') === stroke.autotileId) continue;
|
||||
stroke.edits.push({ x: at.x, y: at.y, autotileId: stroke.autotileId });
|
||||
if (stroke.autotileId) layer.cells[key] = stroke.autotileId;
|
||||
else delete layer.cells[key];
|
||||
changed = true;
|
||||
}
|
||||
if (changed) this.state.preview();
|
||||
}
|
||||
|
||||
/** Puts the preview back and lets the command do the edit, so one stroke is one undo step. */
|
||||
private endStroke(): void {
|
||||
const stroke = this.stroke;
|
||||
this.stroke = null;
|
||||
if (!stroke) return;
|
||||
const layer = this.strokeLayer(stroke);
|
||||
if (layer)
|
||||
for (const [key, value] of stroke.previous) {
|
||||
if (value) layer.cells[key] = value;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
const item = this.lastItems.find((i) => i.id === stroke.objectId);
|
||||
const edits =
|
||||
stroke.tool === 'rect' && item
|
||||
? cellsInRect(item.props, stroke.from, stroke.to).map((at) => ({
|
||||
...at,
|
||||
autotileId: stroke.autotileId,
|
||||
}))
|
||||
: stroke.edits;
|
||||
if (edits.length > 0)
|
||||
this.state.execute(
|
||||
new PaintTilesCommand(
|
||||
stroke.assetId,
|
||||
stroke.layerId,
|
||||
edits,
|
||||
stroke.tool === 'erase' ? 'Erase tiles' : 'Paint tiles',
|
||||
),
|
||||
);
|
||||
else this.state.preview();
|
||||
this.redraw();
|
||||
}
|
||||
|
||||
protected override onItemPointerDown(objectId: string, e: FederatedPointerEvent): void {
|
||||
if (e.button !== 0) return;
|
||||
const holder = this.resolved();
|
||||
@@ -273,6 +447,9 @@ export class SceneViewport extends FreeViewport {
|
||||
this.state.toggleObject(holder.id, objectId);
|
||||
return;
|
||||
}
|
||||
// A painting tool on the selected tilemap: the press starts a stroke instead of a move.
|
||||
const target = this.paintTargetOf(objectId);
|
||||
if (target && this.startStroke(target, holder.id, e)) return;
|
||||
// Clicking a selected object keeps the selection so the whole group can be dragged.
|
||||
if (!this.state.selectedObjectIds.includes(objectId))
|
||||
this.state.select(objectSelection(holder.id, [objectId]));
|
||||
@@ -447,6 +624,52 @@ export class SceneViewport extends FreeViewport {
|
||||
.stroke({ width: 1, color });
|
||||
}
|
||||
|
||||
/**
|
||||
* While a painting tool is chosen, the selected tilemap shows its cells – without the grid one
|
||||
* paints blind – and a rectangle drag shows the box it would fill.
|
||||
*/
|
||||
private drawPaintOverlay(items: DrawItem[], view: View): void {
|
||||
const s = this.state.selection;
|
||||
if (this.state.tilemapPaint.tool === 'select' || s?.kind !== 'object') return;
|
||||
const item = items.find((i) => i.id === s.objectId && i.type === 'tilemap');
|
||||
if (!item) return;
|
||||
const tile = tilemapTileSize(item.props);
|
||||
const columns = tilemapColumns(item.props);
|
||||
const rows = tilemapRows(item.props);
|
||||
const corner = (x: number, y: number): Vec2 => {
|
||||
const at = cellOrigin(item.props, x, y);
|
||||
return worldToScreen(
|
||||
view,
|
||||
item.m.tx + at.x * item.m.a + at.y * item.m.c,
|
||||
item.m.ty + at.x * item.m.b + at.y * item.m.d,
|
||||
);
|
||||
};
|
||||
// Below this the lines are noise, exactly as the scene grid is (see `syncGrid`).
|
||||
if (tile * view.scale >= MIN_GRID_STEP) {
|
||||
for (let x = 0; x <= columns; x++) {
|
||||
const a = corner(x, 0);
|
||||
const b = corner(x, rows);
|
||||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
|
||||
}
|
||||
for (let y = 0; y <= rows; y++) {
|
||||
const a = corner(0, y);
|
||||
const b = corner(columns, y);
|
||||
this.gizmos.moveTo(a.x, a.y).lineTo(b.x, b.y);
|
||||
}
|
||||
this.gizmos.stroke({ width: 1, color: Theme.colors.gizmo, alpha: 0.25 });
|
||||
}
|
||||
const stroke = this.stroke;
|
||||
if (!stroke || stroke.tool !== 'rect') return;
|
||||
const x0 = Math.min(stroke.from.x, stroke.to.x);
|
||||
const y0 = Math.min(stroke.from.y, stroke.to.y);
|
||||
const x1 = Math.max(stroke.from.x, stroke.to.x) + 1;
|
||||
const y1 = Math.max(stroke.from.y, stroke.to.y) + 1;
|
||||
this.gizmos
|
||||
.poly([corner(x0, y0), corner(x1, y0), corner(x1, y1), corner(x0, y1)], true)
|
||||
.fill({ color: Theme.colors.accent, alpha: 0.2 })
|
||||
.stroke({ width: 2, color: Theme.colors.accent });
|
||||
}
|
||||
|
||||
private screenCorners(corners: readonly Vec2[], view: View): Vec2[] {
|
||||
return corners.map((c) => worldToScreen(view, c.x, c.y));
|
||||
}
|
||||
@@ -476,6 +699,7 @@ export class SceneViewport extends FreeViewport {
|
||||
}
|
||||
}
|
||||
this.drawDropPreview(view);
|
||||
this.drawPaintOverlay(items, view);
|
||||
const m = this.marquee;
|
||||
if (m?.active) drawBand(this.gizmos, m.start, m.current);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,9 @@ import {
|
||||
placeNode,
|
||||
syncSpriteMesh,
|
||||
} from '../../viewport/symbols';
|
||||
import { cellCentre, TilemapField } from '../../viewport/tilemap';
|
||||
import { textureLibrary } from '../../viewport/TextureLibrary';
|
||||
import { gameClock } from '../../core/time';
|
||||
import { type View, worldBounds, worldToScreen } from '../../viewport/view';
|
||||
|
||||
export interface Frame {
|
||||
@@ -53,6 +55,8 @@ export abstract class WorldViewport extends Widget {
|
||||
private readonly sprites = new Map<string, Mesh>();
|
||||
/** The drawn particles of each source, one container – and one draw call – per source. */
|
||||
private readonly fields = new Map<string, ParticleField>();
|
||||
/** The meshes of each placed tilemap, one container per map. */
|
||||
private readonly tilemaps = new Map<string, TilemapField>();
|
||||
private visibleCount = 0;
|
||||
private animated = false;
|
||||
protected lastItems: DrawItem[] = [];
|
||||
@@ -245,6 +249,7 @@ export abstract class WorldViewport extends Widget {
|
||||
this.syncText(item, onScreen, view.scale);
|
||||
this.syncSprite(item, onScreen, size, shaders);
|
||||
this.syncParticles(item, onScreen, shaders);
|
||||
this.syncTilemap(item, onScreen);
|
||||
if (!onScreen) continue;
|
||||
this.visibleCount++;
|
||||
drawItem(node, item, size, view.scale * scaleOf(placementOf(item.type, item.m)));
|
||||
@@ -259,6 +264,8 @@ export abstract class WorldViewport extends Widget {
|
||||
this.sprites.delete(id);
|
||||
this.fields.get(id)?.destroy();
|
||||
this.fields.delete(id);
|
||||
this.tilemaps.get(id)?.destroy();
|
||||
this.tilemaps.delete(id);
|
||||
node.destroy();
|
||||
this.nodes.delete(id);
|
||||
this.disposeDecorations(id);
|
||||
@@ -284,6 +291,8 @@ export abstract class WorldViewport extends Widget {
|
||||
if (text) this.objects.addChild(text);
|
||||
const field = this.fields.get(item.id);
|
||||
if (field) this.objects.addChild(field.container);
|
||||
const tilemap = this.tilemaps.get(item.id);
|
||||
if (tilemap) this.objects.addChild(tilemap.container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,6 +332,49 @@ export abstract class WorldViewport extends Widget {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A painted map, drawn from its own meshes. It keeps redrawing the view while an animated
|
||||
* autotile is on it, the same way a Time shader does.
|
||||
*/
|
||||
private syncTilemap(item: DrawItem, onScreen: boolean): void {
|
||||
let field = this.tilemaps.get(item.id);
|
||||
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
|
||||
if (field) field.container.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!field) {
|
||||
field = new TilemapField();
|
||||
this.tilemaps.set(item.id, field);
|
||||
this.objects.addChild(field.container);
|
||||
}
|
||||
field.container.visible = true;
|
||||
field.sync(item, textureLibrary, gameClock.now);
|
||||
if (field.animated) this.animated = true;
|
||||
}
|
||||
|
||||
/** Where the centre of one cell of a placed tilemap is on screen, so a check can sample it. */
|
||||
cellScreen(objectId: string, x: number, y: number): Vec2 | null {
|
||||
const view = this.currentView();
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!view || !item || item.type !== 'tilemap') return null;
|
||||
const centre = cellCentre(item, x, y);
|
||||
const local = worldToScreen(view, centre.x, centre.y);
|
||||
const origin = this.getGlobalPosition();
|
||||
return { x: origin.x + local.x, y: origin.y + local.y };
|
||||
}
|
||||
|
||||
/** What a placed tilemap drew last redraw: how many meshes, and how many cells in them (test hook). */
|
||||
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
|
||||
const field = this.tilemaps.get(objectId);
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!field || !field.container.visible || !item?.tilemap) return null;
|
||||
return {
|
||||
drawCalls: field.drawCalls,
|
||||
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
|
||||
layers: item.tilemap.layers.length,
|
||||
};
|
||||
}
|
||||
|
||||
private syncSprite(item: DrawItem, onScreen: boolean, size: Size, shaders: ShaderSource): void {
|
||||
let sprite = this.sprites.get(item.id);
|
||||
if (item.type !== 'sprite' || !onScreen) {
|
||||
|
||||
@@ -5,6 +5,14 @@ import { resolvedHolder } from '../core/model/instance';
|
||||
import type { Mat } from '../core/model/matrix';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { ParticlesAsset } from '../core/model/Asset';
|
||||
import {
|
||||
type AutotileInfo,
|
||||
autotileFps,
|
||||
type AutotilePlayback,
|
||||
autotilePlayback,
|
||||
readAutotile,
|
||||
} from '../core/model/autotile';
|
||||
import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap';
|
||||
import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core/model/Project';
|
||||
import { fontFileOf } from '../core/model/font';
|
||||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||||
@@ -36,6 +44,8 @@ export interface DrawItem {
|
||||
font: string;
|
||||
/** The asset a particle source emits from, for the same reason again: it carries the graph. */
|
||||
particles: ParticlesAsset | null;
|
||||
/** What a placed tilemap draws: its layers already cut into tiles, with their pictures resolved. */
|
||||
tilemap: TilemapDraw | null;
|
||||
/** How often an `Emit` node has restarted this source; a change is what makes it start over. */
|
||||
emits: number;
|
||||
}
|
||||
@@ -45,6 +55,52 @@ interface DrawList {
|
||||
background: number;
|
||||
}
|
||||
|
||||
/** One autotile a tilemap paints with, as drawing needs it: the blocks and how they run. */
|
||||
export interface TilemapAutotile {
|
||||
info: AutotileInfo;
|
||||
playback: AutotilePlayback;
|
||||
fps: number;
|
||||
}
|
||||
|
||||
/** One layer, already worked out: which tile each cell shows and which autotile draws it. */
|
||||
export interface TilemapLayerDraw {
|
||||
id: string;
|
||||
tiles: TileDraw[];
|
||||
}
|
||||
|
||||
export interface TilemapDraw {
|
||||
layers: TilemapLayerDraw[];
|
||||
autotiles: Map<string, TilemapAutotile>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A placed tilemap as the viewport needs it. The cells are turned into tiles here, where the
|
||||
* project is at hand, for the same reason a sprite's picture is resolved here: drawing has no
|
||||
* access to it. A hidden layer is left out, in the editor and in the running game alike.
|
||||
*/
|
||||
function resolveTilemap(project: Project, assetId: string, props: PropertyBag): TilemapDraw | null {
|
||||
const asset = findAssetOf(project, assetId, 'tilemap');
|
||||
if (!asset) return null;
|
||||
const layers: TilemapLayerDraw[] = [];
|
||||
const autotiles = new Map<string, TilemapAutotile>();
|
||||
for (const layer of asset.layers) {
|
||||
if (!isLayerVisible(layer)) continue;
|
||||
const tiles = layerTiles(project, props, layer);
|
||||
layers.push({ id: layer.id, tiles });
|
||||
for (const tile of tiles) {
|
||||
if (autotiles.has(tile.autotileId)) continue;
|
||||
const autotile = findAssetOf(project, tile.autotileId, 'autotile');
|
||||
if (!autotile) continue;
|
||||
autotiles.set(tile.autotileId, {
|
||||
info: readAutotile(project, autotile),
|
||||
playback: autotilePlayback(autotile),
|
||||
fps: autotileFps(autotile),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { layers, autotiles };
|
||||
}
|
||||
|
||||
/** Both kinds of object that name a picture in `texture`: the sprite that wears it and the source
|
||||
* that throws it. */
|
||||
function wearsPicture(type: AssetType): boolean {
|
||||
@@ -83,6 +139,7 @@ export function collectHolderItems(
|
||||
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
|
||||
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
|
||||
particles: asset.type === 'particles' ? asset : null,
|
||||
tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null,
|
||||
emits: 0,
|
||||
});
|
||||
}
|
||||
@@ -119,6 +176,7 @@ export function collectWorldItems(world: World): DrawList {
|
||||
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
|
||||
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
|
||||
particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null,
|
||||
tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null,
|
||||
emits: e.emits ?? 0,
|
||||
}));
|
||||
return { items, background: world.backgroundColor };
|
||||
|
||||
@@ -118,8 +118,11 @@ export function drawItem(
|
||||
switch (item.type) {
|
||||
// A source draws nothing of its own – what is seen of it are its particles. It still owns the
|
||||
// square around its position, so it can be clicked and dragged like anything else.
|
||||
// A tilemap, like a sprite, draws itself elsewhere – from its own meshes – and owns the
|
||||
// rectangle of its grid: that is what is dragged, and what a painting tool paints on.
|
||||
case 'particles':
|
||||
case 'sprite':
|
||||
case 'tilemap':
|
||||
node.hitArea = new Rectangle(-size.w / 2, -size.h / 2, size.w, size.h);
|
||||
return;
|
||||
case 'camera':
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* The drawn side of a placed tilemap (#128): one mesh per autotile and layer, so a whole map is a
|
||||
* handful of draw calls instead of one sprite per cell. The geometry itself is `tilemapMesh.ts`;
|
||||
* what happens here is Pixi – which texture a mesh wears, which animation step it shows, and
|
||||
* throwing away the meshes of a layer that is gone.
|
||||
*/
|
||||
import { Container, Mesh, MeshGeometry, type Texture } from 'pixi.js';
|
||||
import { type AutotilePicture, pictureIndexAt } from '../core/model/autotile';
|
||||
import { cellOrigin, tilemapTileSize } from '../core/model/tilemap';
|
||||
import type { DrawItem, TilemapAutotile } from './items';
|
||||
import { placeNode } from './symbols';
|
||||
import type { TextureLibrary } from './TextureLibrary';
|
||||
import { type TileSheet, tileMesh, tilesByAutotile } from './tilemapMesh';
|
||||
|
||||
export class TilemapField {
|
||||
readonly container = new Container();
|
||||
private readonly meshes = new Map<string, Mesh>();
|
||||
/** True after a sync that drew an autotile with more than one picture. */
|
||||
animated = false;
|
||||
/** How many meshes the last sync drew – one draw call each (test hook). */
|
||||
drawCalls = 0;
|
||||
|
||||
constructor() {
|
||||
this.container.eventMode = 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* `now` is the global clock, so every tilemap showing the same autotile is on the same step –
|
||||
* animated water does not run out of phase between two maps.
|
||||
*/
|
||||
sync(item: DrawItem, textures: TextureLibrary, now: number): void {
|
||||
const draw = item.tilemap;
|
||||
this.animated = false;
|
||||
// The whole map sits under one node, so the object's own transform is applied once rather
|
||||
// than baked into every vertex.
|
||||
placeNode(this.container, item.m);
|
||||
const tile = tilemapTileSize(item.props);
|
||||
const origin = cellOrigin(item.props, 0, 0);
|
||||
const drawn: Mesh[] = [];
|
||||
const alive = new Set<string>();
|
||||
for (const layer of draw?.layers ?? []) {
|
||||
for (const [autotileId, tiles] of tilesByAutotile(layer.tiles)) {
|
||||
const autotile = draw?.autotiles.get(autotileId);
|
||||
const picture = autotile && pictureNow(autotile, now);
|
||||
const texture = picture ? textures.get(picture.ref) : null;
|
||||
if (!autotile || !picture || !texture) continue;
|
||||
if (autotile.info.pictures.length > 1) this.animated = true;
|
||||
const key = `${layer.id}|${autotileId}`;
|
||||
alive.add(key);
|
||||
const { positions, uvs, indices } = tileMesh(
|
||||
tiles,
|
||||
tile,
|
||||
origin,
|
||||
sheetOf(autotile, picture, texture),
|
||||
);
|
||||
drawn.push(this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture));
|
||||
}
|
||||
}
|
||||
for (const [key, mesh] of this.meshes) {
|
||||
if (alive.has(key)) continue;
|
||||
mesh.destroy();
|
||||
this.meshes.delete(key);
|
||||
}
|
||||
// Re-attached in order rather than sorted lazily: the layer order is the drawing order, and
|
||||
// the list is at most one entry per autotile and layer.
|
||||
this.container.removeChildren();
|
||||
for (const mesh of drawn) this.container.addChild(mesh);
|
||||
this.drawCalls = drawn.length;
|
||||
}
|
||||
|
||||
private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh {
|
||||
const held = this.meshes.get(key);
|
||||
if (held) {
|
||||
held.geometry.destroy();
|
||||
held.geometry = geometry;
|
||||
held.texture = texture;
|
||||
return held;
|
||||
}
|
||||
const mesh = new Mesh({ geometry, texture });
|
||||
mesh.eventMode = 'none';
|
||||
this.meshes.set(key, mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.destroy({ children: true });
|
||||
this.meshes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The block this autotile shows right now. A quarter is cut from it by uvs rather than by the
|
||||
* texture library, so what is wanted here is the picture of the step and nothing finer.
|
||||
*/
|
||||
function pictureNow(autotile: TilemapAutotile, now: number): AutotilePicture | undefined {
|
||||
const { pictures, problem } = autotile.info;
|
||||
if (problem) return undefined;
|
||||
return pictures[pictureIndexAt(pictures.length, autotile.playback, autotile.fps, now)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The library hands out the block as a texture of its own, so the quadrants count from its corner
|
||||
* whether the picture was a whole sheet or one frame cut from one.
|
||||
*/
|
||||
function sheetOf(autotile: TilemapAutotile, picture: AutotilePicture, texture: Texture): TileSheet {
|
||||
return {
|
||||
quad: autotile.info.quad,
|
||||
block: { x: 0, y: 0, w: texture.width, h: texture.height },
|
||||
textureWidth: texture.width,
|
||||
textureHeight: texture.height,
|
||||
smooth: picture.ref.smooth,
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the centre of one cell of a placed tilemap lands in world units – what a pixel check aims at. */
|
||||
export function cellCentre(item: DrawItem, x: number, y: number): { x: number; y: number } {
|
||||
const tile = tilemapTileSize(item.props);
|
||||
const origin = cellOrigin(item.props, x, y);
|
||||
const local = { x: origin.x + tile / 2, y: origin.y + tile / 2 };
|
||||
const m = item.m;
|
||||
return { x: m.tx + local.x * m.a + local.y * m.c, y: m.ty + local.x * m.b + local.y * m.d };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* The geometry a painted layer becomes: one mesh per autotile instead of one sprite per cell, so a
|
||||
* map of a thousand tiles costs as many draw calls as it has autotiles on that layer (#128). Kept a
|
||||
* plain function of numbers – no Pixi, no project – so the quarter that lands in a corner can be
|
||||
* checked without a browser.
|
||||
*/
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import type { TileDraw } from '../core/model/tilemap';
|
||||
|
||||
/** Where a tile's quarters are cut from, in the pixels of the texture the mesh wears. */
|
||||
export interface TileSheet {
|
||||
/** Side of one quarter tile in image pixels. */
|
||||
quad: number;
|
||||
/** The block inside that texture; the quadrant positions count from its top-left corner. */
|
||||
block: FrameRect;
|
||||
textureWidth: number;
|
||||
textureHeight: number;
|
||||
/**
|
||||
* Whether the texture is sampled smoothly. It is, the uvs are pulled half a texel inwards: a
|
||||
* linear sample right on the border of a quarter otherwise reaches into the one next to it and
|
||||
* draws a seam along every tile.
|
||||
*/
|
||||
smooth: boolean;
|
||||
}
|
||||
|
||||
export interface MeshArrays {
|
||||
positions: Float32Array;
|
||||
uvs: Float32Array;
|
||||
indices: Uint32Array;
|
||||
}
|
||||
|
||||
/** The corners of one quad, in reading order – the order `tileQuadrants` uses for the four quarters. */
|
||||
const CORNER_OFFSETS = [
|
||||
[0, 0],
|
||||
[1, 0],
|
||||
[0, 1],
|
||||
[1, 1],
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `origin` is where cell (0, 0) starts in the tilemap's own frame, `tile` the side of a drawn cell
|
||||
* in world units. Every cell contributes four quads – its four quarters – so a tile whose corners
|
||||
* come from four corners of the block is one thing to draw, not four.
|
||||
*/
|
||||
export function tileMesh(
|
||||
tiles: readonly TileDraw[],
|
||||
tile: number,
|
||||
origin: { x: number; y: number },
|
||||
sheet: TileSheet,
|
||||
): MeshArrays {
|
||||
const positions = new Float32Array(tiles.length * 4 * 4 * 2);
|
||||
const uvs = new Float32Array(positions.length);
|
||||
const indices = new Uint32Array(tiles.length * 4 * 6);
|
||||
const half = tile / 2;
|
||||
const insetX = sheet.smooth ? 0.5 / sheet.textureWidth : 0;
|
||||
const insetY = sheet.smooth ? 0.5 / sheet.textureHeight : 0;
|
||||
let v = 0;
|
||||
let i = 0;
|
||||
for (const drawn of tiles) {
|
||||
const left = origin.x + drawn.x * tile;
|
||||
const top = origin.y + drawn.y * tile;
|
||||
drawn.quadrants.forEach((position, corner) => {
|
||||
const x = left + (corner % 2) * half;
|
||||
const y = top + Math.floor(corner / 2) * half;
|
||||
const u0 = (sheet.block.x + position.x * sheet.quad) / sheet.textureWidth + insetX;
|
||||
const v0 = (sheet.block.y + position.y * sheet.quad) / sheet.textureHeight + insetY;
|
||||
const u1 = (sheet.block.x + (position.x + 1) * sheet.quad) / sheet.textureWidth - insetX;
|
||||
const v1 = (sheet.block.y + (position.y + 1) * sheet.quad) / sheet.textureHeight - insetY;
|
||||
const base = v / 2;
|
||||
for (const [dx, dy] of CORNER_OFFSETS) {
|
||||
positions[v] = x + dx * half;
|
||||
uvs[v] = dx ? u1 : u0;
|
||||
v++;
|
||||
positions[v] = y + dy * half;
|
||||
uvs[v] = dy ? v1 : v0;
|
||||
v++;
|
||||
}
|
||||
indices[i++] = base;
|
||||
indices[i++] = base + 1;
|
||||
indices[i++] = base + 2;
|
||||
indices[i++] = base + 2;
|
||||
indices[i++] = base + 1;
|
||||
indices[i++] = base + 3;
|
||||
});
|
||||
}
|
||||
return { positions, uvs, indices };
|
||||
}
|
||||
|
||||
/** The cells of one layer grouped by autotile – one group is one mesh, because it is one texture. */
|
||||
export function tilesByAutotile(tiles: readonly TileDraw[]): Map<string, TileDraw[]> {
|
||||
const groups = new Map<string, TileDraw[]>();
|
||||
for (const drawn of tiles) {
|
||||
const group = groups.get(drawn.autotileId);
|
||||
if (group) group.push(drawn);
|
||||
else groups.set(drawn.autotileId, [drawn]);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
Reference in new issue
Block a user