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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-24 19:32:37 +02:00
1 parent ffb5bbe039
commit 50854389bb
16 files changed
+467 -51

No files matched your search

+27
View File
@@ -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;
+2
View File
@@ -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<string, string>;
}
+13
View File
@@ -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;
+12
View File
@@ -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<AssetType, PropertySchema> = {
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
+1
View File
@@ -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;