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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<number, TileDraw[]> {
|
||||
const rows = new Map<number, TileDraw[]>();
|
||||
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;
|
||||
}
|
||||
+15
-3
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+10
-4
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,7 +20,7 @@ export interface PointerHit {
|
||||
*/
|
||||
export function stackAt(items: readonly DrawItem[], hits: readonly PointerHit[]): string[] {
|
||||
const rank = new Map<string, number>();
|
||||
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 });
|
||||
}
|
||||
|
||||
+45
-28
@@ -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<string, Container>();
|
||||
@@ -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<string>();
|
||||
const aliveLayers = new Set<string>();
|
||||
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.
|
||||
|
||||
@@ -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<typeof makeScene>;
|
||||
|
||||
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<string, unknown>[] }[];
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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()');
|
||||
|
||||
Reference in new issue
Block a user