Files
feuerwerk/src/viewport/tilemap.ts
T
adminandClaude Opus 5.5 50854389bb Let a tilemap layer sort its rows by Y with the objects (#228)
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>
2026-09-24 19:32:37 +02:00

192 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}