/** * 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(); private readonly meshes = new Map(); 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(); const aliveLayers = new Set(); 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 }; }