Each tilemap layer has its own Z: an offset added to the Z of the placed tilemap, like a child's Z to its parent, default 0 keeps the previous order (layers right after their map, in list order), a negative Z sorts a layer below the map, on a tie layers keep their list order and follow the hierarchy against other objects; editor scene and game use the same order through the new pure function drawSlots, a tilemap draws into one container per layer, and the tilemap panel gains a Z field for the selected layer with one undo step per change (#174)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5385d9e152
commit
47eea2f6e4
13 files changed
+389
-38
No files matched your search
@@ -38,7 +38,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, 1075 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1076 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -245,6 +245,34 @@ export class RenameTilemapLayerCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** A layer's `z` above its tilemap's (#174); 0 is stored as nothing, the way a layer starts out. */
|
||||
export class SetTilemapLayerZCommand implements Command {
|
||||
readonly label = 'Set layer z';
|
||||
private previous: number | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly layerId: string,
|
||||
private readonly z: number,
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
if (!Number.isFinite(this.z)) throw new CommandError('A layer z is a number');
|
||||
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
|
||||
this.previous = layer.z;
|
||||
setLayerZ(layer, this.z);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
setLayerZ(requireTilemapLayer(project, this.assetId, this.layerId), this.previous ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
function setLayerZ(layer: TilemapLayer, z: number): void {
|
||||
if (z === 0) delete layer.z;
|
||||
else layer.z = z;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -213,12 +213,15 @@ export interface TileAsset extends AssetBase {
|
||||
/**
|
||||
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the tile lying there, and a
|
||||
* layer nobody switched off is drawn. Which of that tile's quarters a cell shows is worked out
|
||||
* from its neighbours while drawing, never stored – see `model/tilemap.ts`.
|
||||
* 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.
|
||||
*/
|
||||
export interface TilemapLayer {
|
||||
id: string;
|
||||
name: string;
|
||||
hidden?: boolean;
|
||||
z?: number;
|
||||
cells: Record<string, string>;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,11 @@ export function isLayerVisible(layer: TilemapLayer): boolean {
|
||||
return layer.hidden !== true;
|
||||
}
|
||||
|
||||
/** How far a layer is drawn above its tilemap's own `z` (#174); a layer never given one sits on it. */
|
||||
export function layerZ(layer: TilemapLayer): number {
|
||||
return layer.z ?? 0;
|
||||
}
|
||||
|
||||
export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined {
|
||||
return asset.layers.find((layer) => layer.id === layerId);
|
||||
}
|
||||
|
||||
@@ -546,6 +546,7 @@ function normalizeLayers(raw: unknown): TilemapLayer[] {
|
||||
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
|
||||
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;
|
||||
layers.push(layer);
|
||||
}
|
||||
return layers;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { previewObjectId } from '../core/model/animationPreview';
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findAsset, findAssetOf, findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { findLayer } from '../core/model/tilemap';
|
||||
import { findLayer, layerZ } from '../core/model/tilemap';
|
||||
import { effectiveScale, gameClock } from '../core/time';
|
||||
import { physicsStatus } from '../runtime/physics/engine';
|
||||
import {
|
||||
@@ -554,6 +554,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
id: layer.id,
|
||||
name: layer.name,
|
||||
hidden: layer.hidden === true,
|
||||
z: layerZ(layer),
|
||||
cells: Object.keys(layer.cells).length,
|
||||
})) ?? null,
|
||||
/** What the scene view drew: meshes (one draw call each), cells and visible layers. */
|
||||
@@ -598,6 +599,11 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
const field = currentTilemapWidgets()?.layerName;
|
||||
return field ? { ...centreOf(field), value: field.value } : null;
|
||||
},
|
||||
/** The Z field of the painted layer, with what it holds (#174). */
|
||||
layerZAt: () => {
|
||||
const field = currentTilemapWidgets()?.layerZ;
|
||||
return field ? { ...centreOf(field), value: field.value } : null;
|
||||
},
|
||||
focusAt: () => {
|
||||
const button = currentTilemapWidgets()?.focus;
|
||||
return button ? { ...centreOf(button), label: button.text } : null;
|
||||
|
||||
@@ -13,19 +13,21 @@ import {
|
||||
RemoveTilemapLayerCommand,
|
||||
RenameTilemapLayerCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
SetTilemapLayerZCommand,
|
||||
} from '../../core/commands/tilemapCommands';
|
||||
import type { Command } from '../../core/commands/Command';
|
||||
import type { TilemapAsset } from '../../core/model/Asset';
|
||||
import { findObject } from '../../core/model/hierarchy';
|
||||
import { resolvedHolder } from '../../core/model/instance';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { findLayer, isLayerVisible } from '../../core/model/tilemap';
|
||||
import { findLayer, isLayerVisible, layerZ } from '../../core/model/tilemap';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
HStack,
|
||||
Menu,
|
||||
NumberField,
|
||||
Panel,
|
||||
ScrollContainer,
|
||||
TextField,
|
||||
@@ -134,6 +136,8 @@ export interface TilemapPanelWidgets {
|
||||
layers: { id: string; name: Button; visible: Checkbox; up: Button; down: Button; remove: Button }[];
|
||||
/** The name of the layer painted on (#138). */
|
||||
layerName: TextField;
|
||||
/** Its `z` above the map's own (#174). */
|
||||
layerZ: NumberField;
|
||||
/** How the other layers are shown in the scene view (#138). */
|
||||
focus: Dropdown;
|
||||
addLayer: Button;
|
||||
@@ -236,7 +240,8 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
const { state } = host;
|
||||
const paint = state.tilemapPaint;
|
||||
const painted = paintedLayerId(state, asset);
|
||||
const paintedName = findLayer(asset, painted)?.name ?? '';
|
||||
const paintedLayer = findLayer(asset, painted);
|
||||
const paintedName = paintedLayer?.name ?? '';
|
||||
const layerName: TextField = new TextField(paintedName, {
|
||||
onChange: (name) => {
|
||||
// The command rejects a blank name but cannot put the old text back into the field,
|
||||
@@ -245,6 +250,12 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
else layerName.setValue(paintedName);
|
||||
},
|
||||
});
|
||||
const layerZField = new NumberField(paintedLayer ? layerZ(paintedLayer) : 0, {
|
||||
onChange: (z) => {
|
||||
if (paintedLayer && z !== layerZ(paintedLayer))
|
||||
state.execute(new SetTilemapLayerZCommand(asset.id, painted, z));
|
||||
},
|
||||
});
|
||||
const focus: Dropdown = new Dropdown(FOCUS.find((f) => f.focus === paint.focus)?.label ?? '', {
|
||||
onClick: () =>
|
||||
new Menu(
|
||||
@@ -259,6 +270,7 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
palette: null,
|
||||
layers: [],
|
||||
layerName,
|
||||
layerZ: layerZField,
|
||||
focus,
|
||||
addLayer: new Button('Add layer', {
|
||||
onClick: () => state.execute(new AddTilemapLayerCommand(asset.id)),
|
||||
@@ -326,6 +338,10 @@ function buildPanelContent(host: InspectorHost, asset: TilemapAsset): void {
|
||||
const nameRow = labelledRow('Name').row;
|
||||
nameRow.add(layerName, 1);
|
||||
host.body.add(nameRow);
|
||||
const zRow = labelledRow('Z').row;
|
||||
zRow.add(layerZField, 1);
|
||||
host.body.add(zRow);
|
||||
addNote(host, 'Added to the map’s own Z, as a child’s is.', 'small');
|
||||
const showRow = labelledRow('Show').row;
|
||||
showRow.add(focus, 1);
|
||||
host.body.add(showRow);
|
||||
@@ -346,7 +362,9 @@ 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}`).join(',');
|
||||
const layers = asset.layers
|
||||
.map((l) => `${l.id}:${l.name}:${isLayerVisible(l) ? 1 : 0}:${layerZ(l)}`)
|
||||
.join(',');
|
||||
const tiles = tileIdsOf(state).join(',');
|
||||
return `${paint.tool}|${paint.tileId}|${paintedLayerId(state, asset)}|${paint.focus}|${layers}|${tiles}`;
|
||||
}
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
} from 'pixi.js';
|
||||
import { isSymbolType } from '../../core/model/AssetTypes';
|
||||
import { type Extent, scaleOf } from '../../core/model/matrix';
|
||||
import { sortByZ, type Vec2, textOf } from '../../core/model/Properties';
|
||||
import { type Vec2, textOf } from '../../core/model/Properties';
|
||||
import { footprintExtent, footprintOf, placementOf, textAnchor } from '../../core/properties/bounds';
|
||||
import { rectsIntersect } from '../../core/properties/camera';
|
||||
import type { EditorState } from '../state';
|
||||
import { type Size, Theme, Widget } from '../../ui';
|
||||
import { fontLibrary } from '../../viewport/FontLibrary';
|
||||
import { drawSlots } from '../../viewport/drawOrder';
|
||||
import type { DrawItem } from '../../viewport/items';
|
||||
import { hashId } from '../../core/particles/nodes';
|
||||
import { ParticleField } from '../../viewport/particles';
|
||||
@@ -55,7 +56,7 @@ export abstract class WorldViewport extends Widget {
|
||||
private readonly sprites = new Map<string, Mesh>();
|
||||
/** The drawn particles of each source, one container – and one draw call – per source. */
|
||||
private readonly fields = new Map<string, ParticleField>();
|
||||
/** The meshes of each placed tilemap, one container per map. */
|
||||
/** The meshes of each placed tilemap, one container per layer (#174). */
|
||||
private readonly tilemaps = new Map<string, TilemapField>();
|
||||
private visibleCount = 0;
|
||||
private animated = false;
|
||||
@@ -274,25 +275,28 @@ export abstract class WorldViewport extends Widget {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order. Explicit rather than
|
||||
* Pixi's lazy `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
||||
* Re-attaches the object nodes in ascending `z`, ties in hierarchy order, each layer of a map
|
||||
* sorted in on its own (#174, see `drawSlots`). Explicit rather than Pixi's lazy
|
||||
* `sortableChildren` so the order is deterministic and honours hierarchy changes.
|
||||
*/
|
||||
private syncDrawOrder(items: DrawItem[]): void {
|
||||
const ordered = sortByZ(items.filter((item) => !isSymbolType(item.type)));
|
||||
this.objects.removeChildren();
|
||||
for (const item of ordered) {
|
||||
for (const { id, layerId } of drawSlots(items)) {
|
||||
if (layerId) {
|
||||
const layer = this.tilemaps.get(id)?.layerContainer(layerId);
|
||||
if (layer) this.objects.addChild(layer);
|
||||
continue;
|
||||
}
|
||||
// Picture and text are siblings because Graphics may not have children: the picture right
|
||||
// under its node, which only holds the hit area, the text right above its box.
|
||||
const sprite = this.sprites.get(item.id);
|
||||
const sprite = this.sprites.get(id);
|
||||
if (sprite) this.objects.addChild(sprite);
|
||||
const node = this.nodes.get(item.id);
|
||||
const node = this.nodes.get(id);
|
||||
if (node) this.objects.addChild(node);
|
||||
const text = this.texts.get(item.id);
|
||||
const text = this.texts.get(id);
|
||||
if (text) this.objects.addChild(text);
|
||||
const field = this.fields.get(item.id);
|
||||
const field = this.fields.get(id);
|
||||
if (field) this.objects.addChild(field.container);
|
||||
const tilemap = this.tilemaps.get(item.id);
|
||||
if (tilemap) this.objects.addChild(tilemap.container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -339,15 +343,15 @@ export abstract class WorldViewport extends Widget {
|
||||
private syncTilemap(item: DrawItem, onScreen: boolean): void {
|
||||
let field = this.tilemaps.get(item.id);
|
||||
if (item.type !== 'tilemap' || !item.tilemap || !onScreen) {
|
||||
if (field) field.container.visible = false;
|
||||
if (field) field.visible = false;
|
||||
return;
|
||||
}
|
||||
if (!field) {
|
||||
// Its layers are attached by `syncDrawOrder`, which runs after every item is synced.
|
||||
field = new TilemapField();
|
||||
this.tilemaps.set(item.id, field);
|
||||
this.objects.addChild(field.container);
|
||||
}
|
||||
field.container.visible = true;
|
||||
field.visible = true;
|
||||
field.sync(item, textureLibrary, gameClock.now, this.tilemapFocus(item));
|
||||
if (field.animated) this.animated = true;
|
||||
}
|
||||
@@ -375,7 +379,7 @@ export abstract class WorldViewport extends Widget {
|
||||
tilemapInfo(objectId: string): { drawCalls: number; tiles: number; layers: number } | null {
|
||||
const field = this.tilemaps.get(objectId);
|
||||
const item = this.lastItems.find((i) => i.id === objectId);
|
||||
if (!field || !field.container.visible || !item?.tilemap) return null;
|
||||
if (!field?.visible || !item?.tilemap) return null;
|
||||
return {
|
||||
drawCalls: field.drawCalls,
|
||||
tiles: item.tilemap.layers.reduce((sum, layer) => sum + layer.tiles.length, 0),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { isSymbolType } from '../core/model/AssetTypes';
|
||||
import { sortByZ } from '../core/model/Properties';
|
||||
import type { DrawItem } 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.
|
||||
*/
|
||||
export interface DrawSlot {
|
||||
id: string;
|
||||
layerId: string;
|
||||
z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function drawSlots(items: readonly DrawItem[]): DrawSlot[] {
|
||||
const slots: DrawSlot[] = [];
|
||||
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 });
|
||||
}
|
||||
return sortByZ(slots);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import type { Mat } from '../core/model/matrix';
|
||||
import type { PropertyBag } from '../core/model/Properties';
|
||||
import type { ParticlesAsset } from '../core/model/Asset';
|
||||
import { readTile, tileFps, type TileInfo, type TilePlayback, tilePlayback } from '../core/model/tile';
|
||||
import { isLayerVisible, layerTiles, type TileDraw } from '../core/model/tilemap';
|
||||
import { isLayerVisible, layerTiles, layerZ, type TileDraw } from '../core/model/tilemap';
|
||||
import {
|
||||
type CellPolygon,
|
||||
type CellRect,
|
||||
@@ -63,9 +63,13 @@ export interface TilemapTile {
|
||||
fps: number;
|
||||
}
|
||||
|
||||
/** One layer, already worked out: which quarters each cell shows and which tile draws it. */
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export interface TilemapLayerDraw {
|
||||
id: string;
|
||||
z: number;
|
||||
tiles: TileDraw[];
|
||||
}
|
||||
|
||||
@@ -96,7 +100,7 @@ function resolveTilemap(project: Project, assetId: string, props: PropertyBag):
|
||||
for (const layer of asset.layers) {
|
||||
if (!isLayerVisible(layer)) continue;
|
||||
const tiles = layerTiles(project, props, layer);
|
||||
layers.push({ id: layer.id, tiles });
|
||||
layers.push({ id: layer.id, z: layerZ(layer), tiles });
|
||||
for (const tile of tiles) {
|
||||
if (used.has(tile.tileId)) continue;
|
||||
const source = findAssetOf(project, tile.tileId, 'tile');
|
||||
|
||||
+49
-13
@@ -27,16 +27,31 @@ export function layerAlpha(layerId: string, focus: LayerFocus | null): number {
|
||||
return focus.mode === 'dim' ? DIMMED_LAYER_ALPHA : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* One container per layer rather than one per map: each layer has a `z` of its own (#174) and is
|
||||
* sorted in among the other objects on its own – where is the viewport's call, not this one's.
|
||||
*/
|
||||
export class TilemapField {
|
||||
readonly container = new Container();
|
||||
private readonly layers = new Map<string, Container>();
|
||||
private readonly meshes = new Map<string, Mesh>();
|
||||
private shown = true;
|
||||
/** True after a sync that drew a tile with more than one picture. */
|
||||
animated = false;
|
||||
/** How many meshes the last sync drew – one draw call each (test hook). */
|
||||
drawCalls = 0;
|
||||
|
||||
constructor() {
|
||||
this.container.eventMode = 'none';
|
||||
get visible(): boolean {
|
||||
return this.shown;
|
||||
}
|
||||
|
||||
set visible(shown: boolean) {
|
||||
this.shown = shown;
|
||||
for (const layer of this.layers.values()) layer.visible = shown;
|
||||
}
|
||||
|
||||
/** The container a layer draws into; null for a layer the last sync did not draw. */
|
||||
layerContainer(layerId: string): Container | null {
|
||||
return this.layers.get(layerId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,14 +61,17 @@ export class TilemapField {
|
||||
sync(item: DrawItem, textures: TextureLibrary, now: number, focus: LayerFocus | null = null): void {
|
||||
const draw = item.tilemap;
|
||||
this.animated = false;
|
||||
// The whole map sits under one node, so the object's own transform is applied once rather
|
||||
// than baked into every vertex.
|
||||
placeNode(this.container, item.m);
|
||||
const tile = tilemapTileSize(item.props);
|
||||
const origin = cellOrigin(item.props, 0, 0);
|
||||
const drawn: Mesh[] = [];
|
||||
const drawn: { layer: Container; mesh: Mesh }[] = [];
|
||||
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 alpha = layerAlpha(layer.id, focus);
|
||||
if (alpha === 0) continue;
|
||||
for (const [tileId, tiles] of groupByTile(layer.tiles)) {
|
||||
@@ -69,7 +87,7 @@ export class TilemapField {
|
||||
// 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(mesh);
|
||||
drawn.push({ layer: container, mesh });
|
||||
}
|
||||
}
|
||||
for (const [key, mesh] of this.meshes) {
|
||||
@@ -77,13 +95,30 @@ export class TilemapField {
|
||||
mesh.destroy();
|
||||
this.meshes.delete(key);
|
||||
}
|
||||
// Re-attached in order rather than sorted lazily: the layer order is the drawing order, and
|
||||
// the list is at most one entry per tile and layer.
|
||||
this.container.removeChildren();
|
||||
for (const mesh of drawn) this.container.addChild(mesh);
|
||||
for (const [id, layer] of this.layers) {
|
||||
if (aliveLayers.has(id)) continue;
|
||||
layer.destroy();
|
||||
this.layers.delete(id);
|
||||
}
|
||||
// Re-attached rather than kept: a mesh whose layer showed nothing last time has to come back.
|
||||
for (const layer of this.layers.values()) layer.removeChildren();
|
||||
for (const { layer, mesh } of drawn) layer.addChild(mesh);
|
||||
this.drawCalls = drawn.length;
|
||||
}
|
||||
|
||||
private layerFor(objectId: string, layerId: string): Container {
|
||||
let layer = this.layers.get(layerId);
|
||||
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.visible = this.shown;
|
||||
this.layers.set(layerId, layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
private meshFor(key: string, geometry: MeshGeometry, texture: Texture): Mesh {
|
||||
const held = this.meshes.get(key);
|
||||
if (held) {
|
||||
@@ -99,7 +134,8 @@ export class TilemapField {
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.container.destroy({ children: true });
|
||||
for (const layer of this.layers.values()) layer.destroy({ children: true });
|
||||
this.layers.clear();
|
||||
this.meshes.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
|
||||
import {
|
||||
AddTilemapLayerCommand,
|
||||
RenameTilemapLayerCommand,
|
||||
SetTilemapLayerHiddenCommand,
|
||||
SetTilemapLayerZCommand,
|
||||
} from '../../src/core/commands/tilemapCommands';
|
||||
import type { TilemapAsset } from '../../src/core/model/Asset';
|
||||
import { findAsset } from '../../src/core/model/Project';
|
||||
import { layerZ } from '../../src/core/model/tilemap';
|
||||
import { deserializeProject, serializeProject } from '../../src/core/store/serialize';
|
||||
import { drawSlots } from '../../src/viewport/drawOrder';
|
||||
import { collectHolderItems, collectWorldItems } from '../../src/viewport/items';
|
||||
import { makeSpriteScene, place, roundTrip, startWorld } from '../helpers';
|
||||
|
||||
/**
|
||||
* A scene with a tilemap of two layers, "Ground" and "Roofs", and the sprite "Box" – each placed
|
||||
* at the `z` asked for, the map first in the hierarchy.
|
||||
*/
|
||||
function makeScene(mapZ: number, boxZ: number) {
|
||||
const f = makeSpriteScene('Box');
|
||||
const map = new CreateAssetCommand('tilemap', 'Village');
|
||||
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 roofsAdd = new AddTilemapLayerCommand(mapId);
|
||||
f.stack.execute(roofsAdd);
|
||||
f.stack.execute(new RenameTilemapLayerCommand(mapId, roofsAdd.layerId, 'Roofs'));
|
||||
const mapObject = place(f, mapId, { x: 0, y: 0, z: mapZ });
|
||||
const box = place(f, f.spriteId, { x: 0, y: 0, z: boxZ });
|
||||
const names = new Map([
|
||||
[mapObject, 'map'],
|
||||
[box, 'box'],
|
||||
]);
|
||||
const layerNames = new Map([
|
||||
[ground, 'Ground'],
|
||||
[roofsAdd.layerId, 'Roofs'],
|
||||
]);
|
||||
return { f, mapId, mapObject, box, ground, roofs: roofsAdd.layerId, assetOf, names, layerNames };
|
||||
}
|
||||
|
||||
type Scene = ReturnType<typeof makeScene>;
|
||||
|
||||
/** The draw order of the scene view, back to front, as readable names. */
|
||||
function sceneOrder(s: Scene): string[] {
|
||||
return readable(s, drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items));
|
||||
}
|
||||
|
||||
function readable(s: Scene, slots: { id: string; layerId: string }[]): string[] {
|
||||
return slots.map((slot) =>
|
||||
slot.layerId ? s.layerNames.get(slot.layerId)! : (s.names.get(slot.id) ?? slot.id),
|
||||
);
|
||||
}
|
||||
|
||||
describe('the z of a tilemap layer (#174)', () => {
|
||||
it('sets, undoes and redoes, and stores 0 as nothing', () => {
|
||||
const s = makeScene(0, 0);
|
||||
const roofs = () => s.assetOf().layers[1]!;
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
expect(roofs().z).toBe(10);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, -2.5));
|
||||
expect(roofs().z).toBe(-2.5);
|
||||
s.f.stack.undo();
|
||||
expect(roofs().z).toBe(10);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 0));
|
||||
expect('z' in roofs()).toBe(false);
|
||||
expect(layerZ(roofs())).toBe(0);
|
||||
s.f.stack.undo();
|
||||
s.f.stack.undo();
|
||||
expect('z' in roofs()).toBe(false);
|
||||
s.f.stack.redo();
|
||||
expect(roofs().z).toBe(10);
|
||||
});
|
||||
|
||||
it('refuses what is not a number', () => {
|
||||
const s = makeScene(0, 0);
|
||||
for (const z of [Number.NaN, Number.POSITIVE_INFINITY])
|
||||
expect(() => s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, z))).toThrow(
|
||||
'A layer z is a number',
|
||||
);
|
||||
expect('z' in s.assetOf().layers[1]!).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a round trip, and loading keeps only a finite number other than 0', () => {
|
||||
const s = makeScene(0, 0);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const back = findAsset(roundTrip(s.f.project), s.mapId) as TilemapAsset;
|
||||
expect(back.layers.map((l) => l.z)).toEqual([undefined, 10]);
|
||||
|
||||
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 [
|
||||
[0, undefined],
|
||||
['5', undefined],
|
||||
[null, undefined],
|
||||
[true, undefined],
|
||||
[-3, -3],
|
||||
] as const) {
|
||||
layers[1]!.z = raw;
|
||||
const loaded = findAsset(deserializeProject(saved), s.mapId) as TilemapAsset;
|
||||
expect(loaded.layers[1]!.z, `stored ${String(raw)}`).toBe(kept);
|
||||
}
|
||||
});
|
||||
|
||||
it('with every layer at 0 draws as before: the layers right after their map, in list order', () => {
|
||||
expect(sceneOrder(makeScene(0, 0))).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
expect(sceneOrder(makeScene(1, 0))).toEqual(['box', 'map', 'Ground', 'Roofs']);
|
||||
});
|
||||
|
||||
it('adds to the map’s own z, so a figure walks between the ground and the roofs', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box', 'Roofs']);
|
||||
});
|
||||
|
||||
it('moves every layer along when the map itself is moved in z – it is relative, not absolute', () => {
|
||||
const s = makeScene(20, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
// Roofs at 30 and Ground at 20 are both above the box at 5, still ten apart.
|
||||
expect(sceneOrder(s)).toEqual(['box', 'map', 'Ground', 'Roofs']);
|
||||
});
|
||||
|
||||
it('sorts a layer with a negative z below its map and below what shares the map’s z', () => {
|
||||
const s = makeScene(0, 0);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, -1));
|
||||
expect(sceneOrder(s)).toEqual(['Roofs', 'map', 'Ground', 'box']);
|
||||
});
|
||||
|
||||
it('breaks a tie of z by the hierarchy, and between layers by their list order', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.ground, 5));
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 5));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
|
||||
it('leaves the symbols out, whatever their z: they are drawn above everything', () => {
|
||||
const s = makeScene(0, 5);
|
||||
const camera = new CreateAssetCommand('camera', 'Camera');
|
||||
s.f.stack.execute(camera);
|
||||
place(s.f, camera.asset.id, { x: 0, y: 0, z: 7 });
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
|
||||
it('leaves a hidden layer out of the order', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
s.f.stack.execute(new SetTilemapLayerHiddenCommand(s.mapId, s.roofs, true));
|
||||
expect(sceneOrder(s)).toEqual(['map', 'Ground', 'box']);
|
||||
});
|
||||
|
||||
it('gives two placements of one map the same steps from their own z', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const high = place(s.f, s.mapId, { x: 0, y: 0, z: 100 });
|
||||
s.names.set(high, 'high');
|
||||
const slots = drawSlots(collectHolderItems(s.f.project, s.f.sceneOf(), 0).items);
|
||||
expect(slots.map((slot) => [readable(s, [slot])[0], slot.z])).toEqual([
|
||||
['map', 0],
|
||||
['Ground', 0],
|
||||
['box', 5],
|
||||
['Roofs', 10],
|
||||
['high', 100],
|
||||
['Ground', 100],
|
||||
['Roofs', 110],
|
||||
]);
|
||||
});
|
||||
|
||||
it('holds in the running game too, and follows the map when a run changes its z', () => {
|
||||
const s = makeScene(0, 5);
|
||||
s.f.stack.execute(new SetTilemapLayerZCommand(s.mapId, s.roofs, 10));
|
||||
const { world } = startWorld(s.f);
|
||||
const gameOrder = () => readable(s, drawSlots(collectWorldItems(world).items));
|
||||
expect(gameOrder()).toEqual(['map', 'Ground', 'box', 'Roofs']);
|
||||
const map = world.entities.find((e) => e.objectId === s.mapObject)!;
|
||||
map.z = -20;
|
||||
expect(gameOrder()).toEqual(['map', 'Ground', 'Roofs', 'box']);
|
||||
});
|
||||
});
|
||||
@@ -452,6 +452,39 @@ export async function tilemap(t) {
|
||||
JSON.stringify({ layers, renamedRow, nameNow }),
|
||||
);
|
||||
|
||||
// 40p. The Z field below the name gives the painted layer a z of its own, added to the map's
|
||||
// (#174): at −5 the upper layer is sorted in below the map itself and below the lower layer.
|
||||
const zField = await inPanel(t, '__gm.tilemap.layerZAt()');
|
||||
if (zField) {
|
||||
await click(zField.x, zField.y);
|
||||
await press('a', { ctrl: true });
|
||||
await typeText('-5');
|
||||
await press('Enter');
|
||||
}
|
||||
await settle();
|
||||
await shot('40-tilemap-layer-z');
|
||||
const withZ = await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`);
|
||||
const slot = (order, id) => order.indexOf(id);
|
||||
const orderZ = await json('__gm.drawOrder()');
|
||||
const upperSlot = `${MAP_OBJECT}#${withZ[1].id}`;
|
||||
const lowerSlot = `${MAP_OBJECT}#${withZ[0].id}`;
|
||||
await undo();
|
||||
await settle();
|
||||
const orderBack = await json('__gm.drawOrder()');
|
||||
const zBack = (await json(`__gm.tilemap.layers(${JSON.stringify(made.id)})`))[1].z;
|
||||
check(
|
||||
'the Z field sorts the painted layer below the map and the other layer, and undo puts it back',
|
||||
withZ[1].z === -5 &&
|
||||
withZ[0].z === 0 &&
|
||||
slot(orderZ, upperSlot) >= 0 &&
|
||||
slot(orderZ, upperSlot) < slot(orderZ, MAP_OBJECT) &&
|
||||
slot(orderZ, upperSlot) < slot(orderZ, lowerSlot) &&
|
||||
zBack === 0 &&
|
||||
slot(orderBack, MAP_OBJECT) < slot(orderBack, lowerSlot) &&
|
||||
slot(orderBack, lowerSlot) < slot(orderBack, upperSlot),
|
||||
JSON.stringify({ withZ, orderZ, orderBack, zBack }),
|
||||
);
|
||||
|
||||
// Pixels again, so the animation is cut down to one step for the reading, as in 40e.
|
||||
await mutate("__gm.state.project.assets.find((a) => a.id === 'at-grass').pictures = ['at-floor-1']");
|
||||
await settle();
|
||||
|
||||
Reference in new issue
Block a user