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 = {};
|
||||
|
||||
Reference in new issue
Block a user