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:
1 parent
ffb5bbe039
commit
50854389bb
16 files changed
+467
-51
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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<string, number>();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user