A tilemap layer was one piece in the draw order, so a figure stood either in front of a whole layer of trees or behind all of it. For a top-down game the trees in front of the figure's feet have to cover it and the ones behind must not. - A new "Sort by Y" box under the layer list sets `sortByY` on the painted layer. Every change is its own undo step. - Such a layer comes as one draw slot per row. On its `z`, the rows and all objects on that `z` are sorted by where they stand: the bottom of an object's box, the bottom of a row. Ties keep the hierarchy order, and other `z` values are untouched. - A layer drawn as one piece and the map's own node stay behind the sorted ones on the same `z`, since neither has a single place to stand. Ground belongs on a lower `z` or stays unsorted. - A new tile property "Sort offset" (whole rows, -16 to 16) lets a crown sort with the trunk below it. - `TilemapField` draws a container per row for such a layer, and picking takes the row into account, so a click picks what is drawn in front. Scene view, game view and play share the same rule. Limits: on a rotated map "down" is world Y. A layer sorted by Y costs about one draw call per row and tile instead of one per tile. Tests: - ./check.sh: 160 files, 2350 tests, CHECK EXIT 0 (before 2340). - 10 new unit tests in tests/viewport/sort-by-y.test.ts. - e2e 39-43: ALL PASSED, 148 checks. New check 40p-iii ticks the box and finds the slot of row 0, undo brings back the whole layer. - Counter checks: without the Y sort 5 unit tests fail, with the offset ignored 5, with no row in picking 1. If TilemapField draws no rows, exactly 40p-iii fails in e2e. - The e2e sum in the README is 1378, counted, not yet seen in CI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
192 lines
7.6 KiB
TypeScript
192 lines
7.6 KiB
TypeScript
/**
|
||
* The drawn side of a placed tilemap (#128): one mesh per tile 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 { pictureIndexAt, type TilePicture } from '../core/model/tile';
|
||
import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap';
|
||
import { layerRows } from './drawOrder';
|
||
import type { DrawItem, TilemapTile } from './items';
|
||
import { placeNode } from './symbols';
|
||
import type { TextureLibrary } from './TextureLibrary';
|
||
import { groupByTile, type TileSheet, tileMesh } from './tilemapMesh';
|
||
|
||
/** The layer painted on and how the others are shown next to it (#138); null shows every layer. */
|
||
export interface LayerFocus {
|
||
layerId: string;
|
||
mode: 'all' | 'dim' | 'solo';
|
||
}
|
||
|
||
/** How far the others fade in `dim`: still readable as a map, plainly not what the brush reaches. */
|
||
export const DIMMED_LAYER_ALPHA = 0.3;
|
||
|
||
/** How opaque one layer is drawn; 0 means it is not drawn at all. */
|
||
export function layerAlpha(layerId: string, focus: LayerFocus | null): number {
|
||
if (!focus || focus.mode === 'all' || layerId === focus.layerId) return 1;
|
||
return focus.mode === 'dim' ? DIMMED_LAYER_ALPHA : 0;
|
||
}
|
||
|
||
/**
|
||
* One container per layer rather than one per map: each layer has a `z` of its own (#174) and is
|
||
* sorted in among the other objects on its own – where is the viewport's call, not this one's.
|
||
* A layer sorted by Y has one container per row instead (#228), keyed like its draw slot.
|
||
*/
|
||
export class TilemapField {
|
||
private readonly layers = new Map<string, Container>();
|
||
private readonly meshes = new Map<string, Mesh>();
|
||
private shown = true;
|
||
/** True after a sync that drew a tile with more than one picture. */
|
||
animated = false;
|
||
/** How many meshes the last sync drew – one draw call each (test hook). */
|
||
drawCalls = 0;
|
||
|
||
get visible(): boolean {
|
||
return this.shown;
|
||
}
|
||
|
||
set visible(shown: boolean) {
|
||
this.shown = shown;
|
||
for (const layer of this.layers.values()) layer.visible = shown;
|
||
}
|
||
|
||
/** The container a layer, or one row of it, draws into; null for one the last sync did not draw. */
|
||
layerContainer(layerId: string, row: number | null = null): Container | null {
|
||
return this.layers.get(rowKey(layerId, row)) ?? null;
|
||
}
|
||
|
||
/**
|
||
* `now` is the global clock, so every tilemap showing the same tile is on the same step –
|
||
* animated water does not run out of phase between two maps.
|
||
*/
|
||
sync(item: DrawItem, textures: TextureLibrary, now: number, focus: LayerFocus | null = null): void {
|
||
const draw = item.tilemap;
|
||
this.animated = false;
|
||
const tile = tilemapTileSize(item.props);
|
||
const origin = cellOrigin(item.props, 0, 0);
|
||
const drawn: { layer: Container; mesh: Mesh }[] = [];
|
||
const alive = new Set<string>();
|
||
const aliveLayers = new Set<string>();
|
||
for (const layer of draw?.layers ?? []) {
|
||
const parts: [number | null, TileDraw[]][] =
|
||
draw && layer.sortByY ? [...layerRows(draw, layer)] : [[null, layer.tiles]];
|
||
const alpha = layerAlpha(layer.id, focus);
|
||
for (const [row, cells] of parts) {
|
||
const part = rowKey(layer.id, row);
|
||
aliveLayers.add(part);
|
||
// Each part sits under a node of its own carrying the map's transform, so that is applied
|
||
// once per part rather than baked into every vertex.
|
||
const container = this.layerFor(item.id, part);
|
||
placeNode(container, item.m);
|
||
if (alpha === 0) continue;
|
||
for (const [tileId, tiles] of groupByTile(cells)) {
|
||
const source = draw?.tiles.get(tileId);
|
||
const picture = source && pictureNow(source, now);
|
||
const texture = picture ? textures.get(picture.ref) : null;
|
||
if (!source || !picture || !texture) continue;
|
||
if (source.info.pictures.length > 1) this.animated = true;
|
||
const key = `${part}|${tileId}`;
|
||
alive.add(key);
|
||
const { positions, uvs, indices } = tileMesh(
|
||
tiles,
|
||
tile,
|
||
origin,
|
||
sheetOf(source, picture, texture),
|
||
);
|
||
const mesh = this.meshFor(key, new MeshGeometry({ positions, uvs, indices }), texture);
|
||
// Per mesh rather than per layer: the meshes of one layer never overlap, so fading each
|
||
// one looks the same as fading the layer as a whole.
|
||
mesh.alpha = alpha;
|
||
drawn.push({ layer: container, mesh });
|
||
}
|
||
}
|
||
}
|
||
for (const [key, mesh] of this.meshes) {
|
||
if (alive.has(key)) continue;
|
||
mesh.destroy();
|
||
this.meshes.delete(key);
|
||
}
|
||
for (const [id, layer] of this.layers) {
|
||
if (aliveLayers.has(id)) continue;
|
||
layer.destroy();
|
||
this.layers.delete(id);
|
||
}
|
||
// Re-attached rather than kept: a mesh whose layer showed nothing last time has to come back.
|
||
for (const layer of this.layers.values()) layer.removeChildren();
|
||
for (const { layer, mesh } of drawn) layer.addChild(mesh);
|
||
this.drawCalls = drawn.length;
|
||
}
|
||
|
||
private layerFor(objectId: string, part: string): Container {
|
||
let layer = this.layers.get(part);
|
||
if (!layer) {
|
||
layer = new Container();
|
||
layer.eventMode = 'none';
|
||
// The draw order reports it by this, next to the object's own id.
|
||
layer.label = `${objectId}#${part}`;
|
||
layer.visible = this.shown;
|
||
this.layers.set(part, layer);
|
||
}
|
||
return layer;
|
||
}
|
||
|
||
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 {
|
||
for (const layer of this.layers.values()) layer.destroy({ children: true });
|
||
this.layers.clear();
|
||
this.meshes.clear();
|
||
}
|
||
}
|
||
|
||
/** A whole layer is keyed by its id, as before; a row of a layer sorted by Y by id and row. */
|
||
function rowKey(layerId: string, row: number | null): string {
|
||
return row === null ? layerId : `${layerId}#${row}`;
|
||
}
|
||
|
||
/**
|
||
* The block this tile 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(source: TilemapTile, now: number): TilePicture | undefined {
|
||
const { pictures, problem } = source.info;
|
||
if (problem) return undefined;
|
||
return pictures[pictureIndexAt(pictures.length, source.playback, source.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(source: TilemapTile, picture: TilePicture, texture: Texture): TileSheet {
|
||
return {
|
||
quad: source.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 };
|
||
}
|