From 50854389bb5d43bb19f1b930826e7ea43276b63d Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 19:32:37 +0200 Subject: [PATCH] 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 --- README.md | 2 +- src/core/commands/tilemapCommands.ts | 27 ++++ src/core/model/Asset.ts | 2 + src/core/model/tile.ts | 13 ++ src/core/properties/PropertySchema.ts | 12 ++ src/core/store/serialize.ts | 1 + src/editor/debug/sceneHooks.ts | 6 + src/editor/panels/TilemapPanel.ts | 13 +- src/editor/viewport/SceneViewport.ts | 16 +- src/editor/viewport/WorldViewport.ts | 4 +- src/viewport/drawOrder.ts | 83 +++++++++- src/viewport/items.ts | 18 ++- src/viewport/pick.ts | 14 +- src/viewport/tilemap.ts | 73 +++++---- tests/viewport/sort-by-y.test.ts | 211 ++++++++++++++++++++++++++ tools/e2e/40-tilemap.mjs | 23 +++ 16 files changed, 467 insertions(+), 51 deletions(-) create mode 100644 tests/viewport/sort-by-y.test.ts diff --git a/README.md b/README.md index fbe23c53..7ee97b7a 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1377 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1378 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 5aba7877..23217e42 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -309,6 +309,33 @@ function setLayerZ(layer: TilemapLayer, z: number): void { else layer.z = z; } +/** Whether a layer is drawn row by row and sorted in by Y (#228); off is stored as nothing. */ +export class SetTilemapLayerSortByYCommand implements Command { + readonly label: string; + + constructor( + private readonly assetId: string, + private readonly layerId: string, + private readonly sortByY: boolean, + ) { + this.label = sortByY ? 'Sort layer by Y' : 'Stop sorting layer by Y'; + } + + execute(project: Project): void { + this.apply(project, this.sortByY); + } + + undo(project: Project): void { + this.apply(project, !this.sortByY); + } + + private apply(project: Project, sortByY: boolean): void { + const layer = requireTilemapLayer(project, this.assetId, this.layerId); + if (sortByY) layer.sortByY = true; + else delete layer.sortByY; + } +} + /** 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; diff --git a/src/core/model/Asset.ts b/src/core/model/Asset.ts index 5db54237..5b06fd24 100644 --- a/src/core/model/Asset.ts +++ b/src/core/model/Asset.ts @@ -226,12 +226,14 @@ export interface TileAsset extends AssetBase { * from its neighbours while drawing, never stored – see `model/tilemap.ts`. `z` is added to the `z` * of the placed tilemap (#174), as a child's is to its parent's, so every placement of the map * keeps its layers apart by the same steps; layers of equal `z` keep their list order. + * `sortByY` draws the layer row by row, sorted in by Y with every object on its `z` (#228). */ export interface TilemapLayer { id: string; name: string; hidden?: boolean; z?: number; + sortByY?: boolean; cells: Record; } diff --git a/src/core/model/tile.ts b/src/core/model/tile.ts index 48841b7c..da2451e2 100644 --- a/src/core/model/tile.ts +++ b/src/core/model/tile.ts @@ -32,6 +32,12 @@ export const TILE_FRICTION_KEY = 'friction'; export const TILE_BOUNCINESS_KEY = 'bounciness'; export const TILE_COLLISION_SCALE_MIN = 0.1; export const TILE_COLLISION_SCALE_MAX = 2; +/** + * How many rows further down a cell of this tile is sorted on a layer sorted by Y (#228): a + * treetop at 1 sorts with the trunk below it. + */ +export const TILE_SORT_OFFSET_KEY = 'sortOffset'; +export const TILE_SORT_OFFSET_LIMIT = 16; /** Only the two that make sense for a tile that is always on screen; there is no end to hold. */ const TILE_PLAYBACKS = ['loop', 'pingpong'] as const; @@ -73,6 +79,13 @@ export function tileFps(asset: TileAsset): number { return Number.isFinite(fps) && fps > 0 ? fps : 0; } +/** The sort offset in whole rows, held to the range the field offers; anything unreadable is 0. */ +export function tileSortOffset(asset: TileAsset): number { + const rows = Math.round(Number(asset.props[TILE_SORT_OFFSET_KEY] ?? 0)); + if (!Number.isFinite(rows)) return 0; + return Math.min(TILE_SORT_OFFSET_LIMIT, Math.max(-TILE_SORT_OFFSET_LIMIT, rows)); +} + /** A quarter tile inside the block, in quadrant units from its top-left corner. */ export interface QuadPos { x: number; diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index f0957368..d9330790 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -32,6 +32,8 @@ import { TILE_ONE_WAY_ANGLE_KEY, TILE_ONE_WAY_KEY, TILE_PLAYBACK_KEY, + TILE_SORT_OFFSET_KEY, + TILE_SORT_OFFSET_LIMIT, TILE_SOLID_KEY, } from '../model/tile'; import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap'; @@ -401,6 +403,16 @@ const ASSET_SCHEMAS: Record = { max: TILE_COLLISION_SCALE_MAX, step: 0.05, }, + { + key: TILE_SORT_OFFSET_KEY, + label: 'Sort offset', + kind: 'number', + default: 0, + min: -TILE_SORT_OFFSET_LIMIT, + max: TILE_SORT_OFFSET_LIMIT, + step: 1, + decimals: 0, + }, ], // 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. A tile's own pictures are scaled onto diff --git a/src/core/store/serialize.ts b/src/core/store/serialize.ts index 1387ec11..2911736f 100644 --- a/src/core/store/serialize.ts +++ b/src/core/store/serialize.ts @@ -643,6 +643,7 @@ function normalizeLayers(raw: unknown): TilemapLayer[] { const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells }; if (l.hidden === true) layer.hidden = true; if (typeof l.z === 'number' && Number.isFinite(l.z) && l.z !== 0) layer.z = l.z; + if (l.sortByY === true) layer.sortByY = true; layers.push(layer); } return layers; diff --git a/src/editor/debug/sceneHooks.ts b/src/editor/debug/sceneHooks.ts index b3387f97..6f52aafe 100644 --- a/src/editor/debug/sceneHooks.ts +++ b/src/editor/debug/sceneHooks.ts @@ -101,6 +101,7 @@ export function sceneHooks({ state, editor }: HookContext) { name: layer.name, hidden: layer.hidden === true, z: layerZ(layer), + sortByY: layer.sortByY === true, cells: Object.keys(layer.cells).length, })) ?? null, /** What the scene view drew: meshes (one draw call each), cells and visible layers. */ @@ -164,6 +165,11 @@ export function sceneHooks({ state, editor }: HookContext) { const field = widgets && target && widgets.layers.valueField(paintedLayerId(state, target.asset)); return field ? { ...centreOf(field), value: field.value } : null; }, + /** The Sort by Y box of the painted layer (#228), with whether it is ticked. */ + sortByYAt: () => { + const box = currentTilemapWidgets()?.sortByY; + return box ? { ...centreOf(box), checked: box.checked } : null; + }, /** The duplicate mark on the painted layer's row. */ duplicateLayerAt: () => { const widgets = currentTilemapWidgets(); diff --git a/src/editor/panels/TilemapPanel.ts b/src/editor/panels/TilemapPanel.ts index a85cc76a..23c1ba71 100644 --- a/src/editor/panels/TilemapPanel.ts +++ b/src/editor/panels/TilemapPanel.ts @@ -14,6 +14,7 @@ import { RemoveTilemapLayerCommand, RenameTilemapLayerCommand, SetTilemapLayerHiddenCommand, + SetTilemapLayerSortByYCommand, SetTilemapLayerZCommand, } from '../../core/commands/tilemapCommands'; import type { TilemapAsset } from '../../core/model/Asset'; @@ -23,6 +24,7 @@ import { findAssetOf } from '../../core/model/Project'; import { findLayer, isLayerVisible, layerZ } from '../../core/model/tilemap'; import { Button, + Checkbox, Dropdown, HStack, Menu, @@ -139,6 +141,8 @@ export interface TilemapPanelWidgets { layers: SortableList; /** How the other layers are shown in the scene view (#138). */ focus: Dropdown; + /** Whether the painted layer is sorted by Y (#228). */ + sortByY: Checkbox; } let built: TilemapPanelWidgets | null = null; @@ -256,6 +260,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { const { state } = host; const paint = state.tilemapPaint; const painted = paintedLayerId(state, asset); + const sortByY = new Checkbox(findLayer(asset, painted)?.sortByY === true); + sortByY.onChange = (on) => state.execute(new SetTilemapLayerSortByYCommand(asset.id, painted, on)); const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', { onClick: () => new Menu( @@ -298,6 +304,7 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { }, }), focus, + sortByY, }; built = widgets; @@ -340,6 +347,10 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void { host.body.add(widgets.layers); addNote(host, 'Drag a row to reorder, double-click a name to rename.', 'small'); addNote(host, 'The number is the layer’s Z, added to the map’s.', 'small'); + const sortRow = labelledRow('Sort by Y').row; + sortRow.add(sortByY); + host.body.add(sortRow); + addNote(host, 'Sorts the active layer row by row with every object on its Z.', 'small'); const showRow = labelledRow('Show').row; showRow.add(focus, 1); host.body.add(showRow); @@ -359,7 +370,7 @@ function tileIdsOf(state: EditorState): string[] { 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}:${layerZ(l)}`) + .map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}:${layerZ(l)}:${l.sortByY ? 1 : 0}`) .join(','); const tiles = tileIdsOf(state).join(','); return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${paint.focus}|${layers}|${tiles}`; diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index d1815c7d..ea8cff53 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -28,6 +28,7 @@ import { type EditorState, objectSelection } from '../state'; import { paintedLayerId, paintedTilemap } from '../panels/TilemapPanel'; import { activeDrag, drawBand, drawGrid, endDrag, onDragChange, Theme, textStyle } from '../../ui'; import { collectHolderItems, type DrawItem, type TilemapSolids } from '../../viewport/items'; +import { sortRowOf } from '../../viewport/drawOrder'; import { menuTargetOf, type PointerHit, pressOn, stackAt } from '../../viewport/pick'; import { openObjectMenu } from '../objectMenu'; import { type LayerFocus, layerAlpha } from '../../viewport/tilemap'; @@ -467,11 +468,16 @@ export class SceneViewport extends FreeViewport { const inMap = applyPoint(invert(item.m), world); const cell = cellAtPoint(item.props, inMap.x, inMap.y); const focus = this.tilemapFocus(item); - const layers = (item.tilemap?.layers ?? []) - .filter((layer) => layerAlpha(layer.id, focus) > 0) - .filter((layer) => layer.tiles.some((t) => t.x === cell.x && t.y === cell.y)) - .map((layer) => layer.id); - hits.push({ id: item.id, layers }); + const layers: string[] = []; + const rows = new Map(); + for (const layer of item.tilemap?.layers ?? []) { + if (layerAlpha(layer.id, focus) === 0) continue; + const tile = layer.tiles.find((t) => t.x === cell.x && t.y === cell.y); + if (!tile) continue; + layers.push(layer.id); + if (layer.sortByY) rows.set(layer.id, sortRowOf(item.tilemap!, tile)); + } + hits.push({ id: item.id, layers, rows }); } return hits; } diff --git a/src/editor/viewport/WorldViewport.ts b/src/editor/viewport/WorldViewport.ts index f7cab9cb..69d9708c 100644 --- a/src/editor/viewport/WorldViewport.ts +++ b/src/editor/viewport/WorldViewport.ts @@ -358,9 +358,9 @@ export abstract class WorldViewport extends Widget { */ private syncDrawOrder(items: DrawItem[]): void { this.objects.removeChildren(); - for (const { id, layerId } of drawSlots(items)) { + for (const { id, layerId, row } of drawSlots(items)) { if (layerId) { - const layer = this.tilemaps.get(id)?.layerContainer(layerId); + const layer = this.tilemaps.get(id)?.layerContainer(layerId, row); if (layer) this.objects.addChild(layer); continue; } diff --git a/src/viewport/drawOrder.ts b/src/viewport/drawOrder.ts index 163f3d54..5eb91fa8 100644 --- a/src/viewport/drawOrder.ts +++ b/src/viewport/drawOrder.ts @@ -1,30 +1,99 @@ import { isSymbolType } from '../core/model/AssetTypes'; import { sortByZ } from '../core/model/Properties'; -import type { DrawItem } from './items'; +import { cellOrigin, type TileDraw, tilemapTileSize } from '../core/model/tilemap'; +import { footprintExtent } from '../core/properties/bounds'; +import type { DrawItem, TilemapDraw, TilemapLayerDraw } from './items'; /** * One thing sorted into the draw order: an object, or one layer of a placed tilemap (#174) – * `layerId` is empty for the object itself. A layer is sorted on its own, so a figure can walk - * between the ground and the treetops of one map. + * between the ground and the treetops of one map. A layer sorted by Y comes as one slot per row + * (#228), `row` naming it. */ export interface DrawSlot { id: string; layerId: string; + row: number | null; z: number; } +/** What names a slot, for the viewport's containers and for picking. */ +export function slotKey(id: string, layerId: string, row: number | null): string { + return `${id}#${layerId}#${row ?? ''}`; +} + +/** The row a cell is sorted by on a layer sorted by Y: its own, moved down by its tile's offset. */ +export function sortRowOf(draw: TilemapDraw, tile: TileDraw): number { + return tile.y + (draw.tiles.get(tile.tileId)?.sortOffset ?? 0); +} + +/** The cells of a layer sorted by Y, grouped by the row they sort by, top row first. */ +export function layerRows(draw: TilemapDraw, layer: TilemapLayerDraw): Map { + const rows = new Map(); + for (const tile of layer.tiles) { + const row = sortRowOf(draw, tile); + const held = rows.get(row); + if (held) held.push(tile); + else rows.set(row, [tile]); + } + return new Map([...rows].sort(([a], [b]) => a - b)); +} + +interface Candidate extends DrawSlot { + item: DrawItem; +} + /** * Back to front: ascending `z`, ties in hierarchy order, and a map's layers right after the map in * their list order. With every layer at 0 that is exactly the order of before, where a map was one * piece. Symbols are left out, they are drawn above everything. + * + * Where a layer sorted by Y sits on a `z`, everything on that `z` is sorted by where it stands + * (#228): an object by the bottom of its box, a row of the layer by the bottom of the row. Ties + * keep the order above. A layer drawn as one piece and the map's own node stay behind them, in + * that order, since neither has a single place to stand. */ export function drawSlots(items: readonly DrawItem[]): DrawSlot[] { - const slots: DrawSlot[] = []; + const slots: Candidate[] = []; for (const item of items) { if (isSymbolType(item.type)) continue; - slots.push({ id: item.id, layerId: '', z: item.z }); - for (const layer of item.tilemap?.layers ?? []) - slots.push({ id: item.id, layerId: layer.id, z: item.z + layer.z }); + slots.push({ id: item.id, layerId: '', row: null, z: item.z, item }); + for (const layer of item.tilemap?.layers ?? []) { + const z = item.z + layer.z; + if (!layer.sortByY) slots.push({ id: item.id, layerId: layer.id, row: null, z, item }); + else + for (const row of layerRows(item.tilemap!, layer).keys()) + slots.push({ id: item.id, layerId: layer.id, row, z, item }); + } } - return sortByZ(slots); + const sorted = sortByZ(slots); + const byY = new Set(sorted.filter((slot) => slot.row !== null).map((slot) => slot.z)); + const order: Candidate[] = []; + for (let start = 0; start < sorted.length;) { + const z = sorted[start]!.z; + let end = start; + while (end < sorted.length && sorted[end]!.z === z) end++; + const group = sorted.slice(start, end); + if (!byY.has(z)) order.push(...group); + else { + const standing = group.filter(standsOnY); + order.push(...group.filter((slot) => !standsOnY(slot))); + const feet = new Map(standing.map((slot) => [slot, feetOf(slot)])); + order.push(...standing.sort((a, b) => feet.get(a)! - feet.get(b)!)); + } + start = end; + } + return order.map(({ id, layerId, row, z }) => ({ id, layerId, row, z })); +} + +/** A row of a layer sorted by Y, or an object other than a map. */ +function standsOnY(slot: Candidate): boolean { + return slot.row !== null || (slot.layerId === '' && slot.item.type !== 'tilemap'); +} + +/** Where a slot stands, in world Y: the bottom of an object's box, or of a map's row. */ +function feetOf({ item, row }: Candidate): number { + if (row === null) return footprintExtent(item.type, item.props, item.m, item.frame).bottom; + const bottom = cellOrigin(item.props, 0, row).y + tilemapTileSize(item.props); + return item.m.ty + bottom * item.m.d; } diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 63215e12..d09f4991 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -5,7 +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, TilemapAsset } from '../core/model/Asset'; -import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile'; +import { + readTile, + tileFps, + type TileInfo, + type TilePlayback, + tilePlayback, + tileSortOffset, +} from '../core/model/tile'; import { isLayerVisible, layerTiles, layerZ, type TileDraw } from '../core/model/tilemap'; import { type CellPolygon, @@ -66,15 +73,19 @@ export interface TilemapTile { info: TileInfo; playback: TilePlayback; fps: number; + /** Rows further down its cells are sorted on a layer sorted by Y (#228). */ + sortOffset: number; } /** * One layer, already worked out: which quarters each cell shows and which tile draws it, and how - * far above the map's own `z` it is sorted in among the other objects (#174). + * far above the map's own `z` it is sorted in among the other objects (#174) – as one piece, or + * row by row by Y (#228). */ export interface TilemapLayerDraw { id: string; z: number; + sortByY: boolean; tiles: TileDraw[]; } @@ -133,7 +144,7 @@ function resolveTilemap( for (const layer of asset.layers) { if (!isLayerVisible(layer)) continue; const tiles = layerTiles(project, props, layer); - layers.push({ id: layer.id, z: layerZ(layer), tiles }); + layers.push({ id: layer.id, z: layerZ(layer), sortByY: layer.sortByY === true, tiles }); for (const tile of tiles) { if (used.has(tile.tileId)) continue; const source = findAssetOf(project, tile.tileId, 'tile'); @@ -142,6 +153,7 @@ function resolveTilemap( info: readTile(project, source), playback: tilePlayback(source), fps: tileFps(source), + sortOffset: tileSortOffset(source), }); } } diff --git a/src/viewport/pick.ts b/src/viewport/pick.ts index 610ed16a..eb6eaf65 100644 --- a/src/viewport/pick.ts +++ b/src/viewport/pick.ts @@ -1,11 +1,15 @@ import { isSymbolType } from '../core/model/AssetTypes'; -import { drawSlots } from './drawOrder'; +import { drawSlots, slotKey } from './drawOrder'; import type { DrawItem } from './items'; -/** An object whose hit area is under the pointer; `layers` names a tilemap's layers with a tile there. */ +/** + * An object whose hit area is under the pointer; `layers` names a tilemap's layers with a tile + * there, and `rows` the row that tile sorts by on each of them sorted by Y (#228). + */ export interface PointerHit { id: string; layers: readonly string[] | null; + rows?: ReadonlyMap; } /** @@ -16,7 +20,7 @@ export interface PointerHit { */ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] { const rank = new Map(); - drawSlots(items).forEach((slot, i) => rank.set(`${slot.id}#${slot.layerId}`, i)); + drawSlots(items).forEach((slot, i) => rank.set(slotKey(slot.id, slot.layerId, slot.row), i)); const symbolsFrom = rank.size; const index = new Map(items.map((item, i) => [item.id, i])); const ranked: { id: string; rank: number }[] = []; @@ -25,7 +29,9 @@ export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]) if (at === undefined) continue; const ranks = isSymbolType(items[at]!.type) ? [symbolsFrom + at] - : (hit.layers ?? ['']).map((layerId) => rank.get(`${hit.id}#${layerId}`) ?? -1); + : (hit.layers ?? ['']).map( + (layerId) => rank.get(slotKey(hit.id, layerId, hit.rows?.get(layerId) ?? null)) ?? -1, + ); const best = Math.max(-1, ...ranks); if (best >= 0) ranked.push({ id: hit.id, rank: best }); } diff --git a/src/viewport/tilemap.ts b/src/viewport/tilemap.ts index e5ecb69b..d8f8a090 100644 --- a/src/viewport/tilemap.ts +++ b/src/viewport/tilemap.ts @@ -6,7 +6,8 @@ */ import { Container, Mesh, MeshGeometry, type Texture } from 'pixi.js'; import { pictureIndexAt, type TilePicture } from '../core/model/tile'; -import { cellOrigin, tilemapTileSize } from '../core/model/tilemap'; +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'; @@ -30,6 +31,7 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number { /** * 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(); @@ -49,9 +51,9 @@ export class TilemapField { for (const layer of this.layers.values()) layer.visible = shown; } - /** The container a layer draws into; null for a layer the last sync did not draw. */ - layerContainer(layerId: string): Container | null { - return this.layers.get(layerId) ?? null; + /** 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; } /** @@ -67,27 +69,37 @@ export class TilemapField { const alive = new Set(); const aliveLayers = new Set(); for (const layer of draw?.layers ?? []) { - aliveLayers.add(layer.id); - // Each layer sits under a node of its own carrying the map's transform, so that is applied - // once per layer rather than baked into every vertex. - const container = this.layerFor(item.id, layer.id); - placeNode(container, item.m); + const parts: [number | null, TileDraw[]][] = + draw && layer.sortByY ? [...layerRows(draw, layer)] : [[null, layer.tiles]]; const alpha = layerAlpha(layer.id, focus); - if (alpha === 0) continue; - for (const [tileId, tiles] of groupByTile(layer.tiles)) { - 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 = `${layer.id}|${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 [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) { @@ -106,15 +118,15 @@ export class TilemapField { this.drawCalls = drawn.length; } - private layerFor(objectId: string, layerId: string): Container { - let layer = this.layers.get(layerId); + 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}#${layerId}`; + layer.label = `${objectId}#${part}`; layer.visible = this.shown; - this.layers.set(layerId, layer); + this.layers.set(part, layer); } return layer; } @@ -140,6 +152,11 @@ export class TilemapField { } } +/** 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. diff --git a/tests/viewport/sort-by-y.test.ts b/tests/viewport/sort-by-y.test.ts new file mode 100644 index 00000000..ebeeddec --- /dev/null +++ b/tests/viewport/sort-by-y.test.ts @@ -0,0 +1,211 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import { MoveSceneObjectCommand } from '../../src/core/commands/sceneObjectCommands'; +import { + AddTilemapLayerCommand, + PaintTilesCommand, + RenameTilemapLayerCommand, + SetTilemapLayerSortByYCommand, +} from '../../src/core/commands/tilemapCommands'; +import type { TileAsset, TilemapAsset } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { TILE_SORT_OFFSET_KEY, tileSortOffset } from '../../src/core/model/tile'; +import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; +import { type DrawSlot, drawSlots } from '../../src/viewport/drawOrder'; +import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; +import { stackAt } from '../../src/viewport/pick'; +import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers'; + +/** + * A 20 × 15 map of 48-unit cells at the origin, so row `r` ends at `-360 + (r + 1) * 48`: row 5 at + * -72, row 8 at 72. "Ground" is a plain layer; "Trees" holds a trunk at (2,5), its crown at (2,4) + * and a bush at (7,8). The sprite "Box" is 100 tall and stands with its bottom at `feet`. + */ +function makeScene(feet: number, crownOffset = 1) { + const f = makeSpriteScene('Box'); + const map = new CreateAssetCommand('tilemap', 'Forest'); + f.stack.execute(map); + const mapId = map.asset.id; + const assetOf = () => findAsset(f.project, mapId) as TilemapAsset; + const ground = assetOf().layers[0]!.id; + f.stack.execute(new RenameTilemapLayerCommand(mapId, ground, 'Ground')); + const treesAdd = new AddTilemapLayerCommand(mapId); + f.stack.execute(treesAdd); + const trees = treesAdd.layerId; + f.stack.execute(new RenameTilemapLayerCommand(mapId, trees, 'Trees')); + const tile = (name: string, props = {}) => { + const created = new CreateAssetCommand('tile', name, props); + f.stack.execute(created); + return created.asset.id; + }; + const trunk = tile('Trunk'); + const crown = tile('Crown', { [TILE_SORT_OFFSET_KEY]: crownOffset }); + f.stack.execute(new PaintTilesCommand(mapId, ground, [{ x: 0, y: 0, tileId: trunk }])); + f.stack.execute( + new PaintTilesCommand(mapId, trees, [ + { x: 2, y: 5, tileId: trunk }, + { x: 2, y: 4, tileId: crown }, + { x: 7, y: 8, tileId: trunk }, + ]), + ); + const mapObject = place(f, mapId, { x: 0, y: 0, z: 0 }); + const box = place(f, f.spriteId, { x: 0, y: feet - 50, z: 0 }); + const names = new Map([ + [mapObject, 'map'], + [box, 'box'], + ]); + const layerNames = new Map([ + [ground, 'Ground'], + [trees, 'Trees'], + ]); + const sortTrees = () => f.stack.execute(new SetTilemapLayerSortByYCommand(mapId, trees, true)); + return { f, mapId, mapObject, box, ground, trees, crown, assetOf, names, layerNames, sortTrees }; +} + +type Scene = ReturnType; + +function readable(s: Scene, slots: DrawSlot[]): string[] { + return slots.map((slot) => { + if (!slot.layerId) return s.names.get(slot.id) ?? slot.id; + const name = s.layerNames.get(slot.layerId)!; + return slot.row === null ? name : `${name} ${slot.row}`; + }); +} + +function sceneOrder(s: Scene): string[] { + return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items)); +} + +describe('a tilemap layer sorted by Y (#228)', () => { + it('is off by default, and the map then draws as one piece per layer as before', () => { + const s = makeScene(-50); + expect(s.assetOf().layers.map((l) => l.sortByY)).toEqual([undefined, undefined]); + expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees', 'box']); + }); + + it('sets, undoes and redoes, and stores off as nothing', () => { + const s = makeScene(-50); + const trees = () => s.assetOf().layers[1]!; + s.sortTrees(); + expect(trees().sortByY).toBe(true); + s.f.stack.execute(new SetTilemapLayerSortByYCommand(s.mapId, s.trees, false)); + expect('sortByY' in trees()).toBe(false); + s.f.stack.undo(); + expect(trees().sortByY).toBe(true); + s.f.stack.undo(); + expect('sortByY' in trees()).toBe(false); + s.f.stack.redo(); + expect(trees().sortByY).toBe(true); + }); + + it('survives a round trip, and loading keeps only a literal true', () => { + const s = makeScene(-50); + s.sortTrees(); + const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset; + expect(back.layers.map((l) => l.sortByY)).toEqual([undefined, true]); + + const saved = serializeProject(s.f.project) as { + assets: { id: string; layers?: Record[] }[]; + }; + const layers = saved.assets.find((a) => a.id === s.mapId)!.layers!; + for (const [raw, kept] of [ + [true, true], + ['true', undefined], + [1, undefined], + [false, undefined], + ] as const) { + layers[1]!.sortByY = raw; + const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset; + expect(loaded.layers[1]!.sortByY, `stored ${String(raw)}`).toBe(kept); + } + }); + + it('puts a figure in front of a row whose bottom is above its feet, behind one below', () => { + const between = makeScene(-50); + between.sortTrees(); + expect(sceneOrder(between)).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); + + const behind = makeScene(-100); + behind.sortTrees(); + expect(sceneOrder(behind)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); + + const front = makeScene(100); + front.sortTrees(); + expect(sceneOrder(front)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']); + }); + + it('sorts a crown with the trunk its sort offset points to, and on its own row without one', () => { + const offset = makeScene(-80); + offset.sortTrees(); + // Feet at -80: below the crown's own row (ends at -120), above the trunk's (ends at -72). + expect(sceneOrder(offset)).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); + + const plain = makeScene(-80, 0); + plain.sortTrees(); + expect(sceneOrder(plain)).toEqual(['map', 'Ground', 'Trees 4', 'box', 'Trees 5', 'Trees 8']); + }); + + it('sorts objects on that z among each other by their feet, too', () => { + const s = makeScene(100); + s.sortTrees(); + const far = place(s.f, s.f.spriteId, { x: 0, y: -150, z: 0 }); + s.names.set(far, 'far'); + // `far` comes after `box` in the hierarchy but stands further up, so it is drawn first. + expect(sceneOrder(s)).toEqual(['map', 'Ground', 'far', 'Trees 5', 'Trees 8', 'box']); + }); + + it('leaves everything on another z as it was', () => { + const s = makeScene(-50); + s.sortTrees(); + s.f.stack.execute(new MoveSceneObjectCommand(s.f.sceneId, s.box, { x: 0, y: -100, z: 3 })); + expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Trees 5', 'Trees 8', 'box']); + }); + + it('holds in the running game, and follows a figure that walks past a row', () => { + const s = makeScene(-100); + s.sortTrees(); + const { world } = startWorld(s.f); + const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items)); + expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Trees 5', 'Trees 8']); + world.entities.find((e) => e.objectId === s.box)!.y = 0; + expect(gameOrder()).toEqual(['map', 'Ground', 'Trees 5', 'box', 'Trees 8']); + }); + + it('picks the figure over a row behind it and the row over a figure behind it', () => { + const cellHit = (s: Scene) => ({ + id: s.mapObject, + layers: [s.ground, s.trees], + rows: new Map([[s.trees, 5]]), + }); + const pick = (s: Scene) => + stackAt(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items, [ + { id: s.box, layers: null }, + cellHit(s), + ]).map((id) => s.names.get(id)); + + const inFront = makeScene(-50); + inFront.sortTrees(); + expect(pick(inFront)).toEqual(['box', 'map']); + + const behind = makeScene(-100); + behind.sortTrees(); + expect(pick(behind)).toEqual(['map', 'box']); + }); +}); + +describe('the sort offset of a tile (#228)', () => { + it('is 0 by default and held to whole rows between -16 and 16', () => { + const s = makeScene(0); + const crown = () => findAsset(s.f.project, s.crown) as TileAsset; + expect(tileSortOffset({ ...crown(), props: {} })).toBe(0); + for (const [raw, kept] of [ + [1, 1], + [2.4, 2], + [-3, -3], + [40, 16], + [-40, -16], + ['x', 0], + ] as const) + expect(tileSortOffset({ ...crown(), props: { [TILE_SORT_OFFSET_KEY]: raw } }), String(raw)).toBe(kept); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index f3cb46fb..3657e6fd 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -571,6 +571,29 @@ export async function tilemap(t) { JSON.stringify({ withZ, orderZ, orderBack, zBack }), ); + // 40p-iii. Sort by Y (#228) draws the painted layer row by row: its one cell (4,0) becomes the + // slot of row 0 in place of the whole layer's. Undone again for the blocks below. + const sortBox = await inPanel(t, '__gm.tilemap.sortByYAt()'); + if (sortBox) await click(sortBox.x, sortBox.y); + await settle(); + const sortedLayer = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1]; + const orderSorted = await json('__gm.drawOrder()'); + const boxSorted = await json('__gm.tilemap.sortByYAt()'); + await undo(); + await settle(); + const orderUnsorted = await json('__gm.drawOrder()'); + check( + 'Sort by Y draws the painted layer as one slot per row, and undo makes it one piece again', + sortBox?.checked === false && + sortedLayer.sortByY === true && + boxSorted?.checked === true && + orderSorted.includes(`${upperSlot}#0`) && + !orderSorted.includes(upperSlot) && + orderUnsorted.includes(upperSlot) && + !orderUnsorted.includes(`${upperSlot}#0`), + JSON.stringify({ sortBox, sortedLayer, boxSorted, orderSorted, orderUnsorted }), + ); + // 40p-ii. The duplicate mark copies the painted layer right above it, cells and all, and paints // on the copy. Undone again: the blocks below count on two layers. const copyMark = await inPanel(t, '__gm.tilemap.duplicateLayerAt()');