A placed tilemap grows and shrinks at every edge: the new GRID EDGES block in the inspector adds or removes one column or row at Left, Top, Right or Bottom as one undo step, shifting the cells of all layers and moving the placement through its own matrix so the content stays in place on screen; removed painted cells are kept outside the grid and return when the edge grows again, and the Columns and Rows fields of a placement keep the content in place too (#136)
game-maker / gate (push) Successful in 1m43s
game-maker / suite (push) Successful in 6m8s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 13:58:03 +02:00
1 parent 0b32d3cbf3
commit 4728f4fab5
9 files changed
+588 -11

No files matched your search

+1 -1
View File
@@ -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, 1038 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1040 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
+95 -3
View File
@@ -2,15 +2,34 @@
* Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers:
* the pointer decides where a stroke ends, so an undo has to take back the whole line and not the
* last cell of it. The grid size and the cell size are ordinary properties and go through
* `SetAssetPropertyCommand`.
* `SetAssetPropertyCommand`; on a placed tilemap the grid size goes through
* `ResizeTilemapEdgeCommand`, which keeps the painted cells where they are in the scene (#136).
*/
import { newId } from '../ids';
import type { TilemapLayer } from '../model/Asset';
import { cellKey } from '../model/tilemap';
import type { Asset, TilemapLayer } from '../model/Asset';
import type { ObjectRef } from '../model/instance';
import { apply } from '../model/matrix';
import type { Position } from '../model/Properties';
import type { SceneObject } from '../model/SceneObject';
import {
cellKey,
edgeCompensation,
edgeSizeKey,
shiftedCells,
TILEMAP_COLUMNS_KEY,
TILEMAP_MAX_CELLS,
type TilemapEdge,
tilemapColumns,
tilemapRows,
tilemapTileSize,
} from '../model/tilemap';
import type { Project } from '../model/Project';
import { resolveSceneObjectProps } from '../properties/resolve';
import { localMatrixOf, transformOfObject } from '../properties/transform';
import { nextNumberedName } from './naming';
import { type Command, CommandError } from './Command';
import { requireTilemap, requireTilemapLayer } from './require';
import { ObjectEditCommand } from './sceneObjectCommands';
/** One cell as a stroke wants it: `tileId` empty erases. */
export interface CellEdit {
@@ -61,6 +80,79 @@ export class PaintTilesCommand implements Command {
}
}
/**
* Where a placed tilemap's origin goes when `edge` gains `count` cells, starting from `from`, so the
* painted cells stay where they are in the scene (#136). The shift is worked out in the tilemap's
* own frame and carried through its turn, size and mirror, so a rotated map holds still as well.
*/
export function heldPosition(
asset: Asset,
object: SceneObject,
from: Position,
edge: TilemapEdge,
count: number,
tileSize: number,
): Position {
const at = apply(
localMatrixOf(from, transformOfObject(asset, object)),
edgeCompensation(edge, count, tileSize),
);
return { x: at.x, y: at.y, z: from.z };
}
/** The placed tilemap's current columns or rows, whichever `key` names. */
export function placedGridSize(project: Project, asset: Asset, object: SceneObject, key: string): number {
const props = resolveSceneObjectProps(project, asset, object);
return key === TILEMAP_COLUMNS_KEY ? tilemapColumns(props) : tilemapRows(props);
}
/**
* Adds (`count` > 0) or removes columns or rows at one edge of a placed tilemap (#136), as one undo
* step. The size is the placement's own, as when Columns and Rows are edited in the inspector. At the
* left and top edge the cells of every layer move along, because cell `0,0` is always the top-left
* corner – and cells live on the asset, so they move in every other placement too. Removing throws
* nothing away: what falls off lies outside the grid and comes back with the edge.
*/
export class ResizeTilemapEdgeCommand extends ObjectEditCommand {
readonly label: string;
private shifted: { layer: TilemapLayer; cells: Record<string, string> }[] = [];
constructor(
holderId: string,
objectId: string,
private readonly edge: TilemapEdge,
private readonly count: number,
) {
super(holderId, objectId);
const what = edgeSizeKey(edge) === TILEMAP_COLUMNS_KEY ? 'column' : 'row';
this.label = `${count >= 0 ? 'Add' : 'Remove'} ${what}${Math.abs(count) === 1 ? '' : 's'} (${edge})`;
}
protected override apply(ref: ObjectRef, project: Project): void {
const asset = requireTilemap(project, ref.object.assetId);
const key = edgeSizeKey(this.edge);
const size = placedGridSize(project, asset, ref.object, key) + this.count;
if (size < 1) throw new CommandError('A tilemap keeps at least one column and one row');
if (size > TILEMAP_MAX_CELLS)
throw new CommandError(`A tilemap has at most ${TILEMAP_MAX_CELLS} columns and rows`);
const tile = tilemapTileSize(resolveSceneObjectProps(project, asset, ref.object));
ref.setPosition(heldPosition(asset, ref.object, ref.object.position, this.edge, this.count, tile));
ref.setOverride(key, size);
this.shifted = [];
if (this.edge !== 'left' && this.edge !== 'top') return;
const dx = this.edge === 'left' ? this.count : 0;
const dy = this.edge === 'top' ? this.count : 0;
for (const layer of asset.layers) {
this.shifted.push({ layer, cells: layer.cells });
layer.cells = shiftedCells(layer.cells, dx, dy);
}
}
protected override revert(): void {
for (const { layer, cells } of this.shifted) layer.cells = cells;
}
}
export class AddTilemapLayerCommand implements Command {
readonly label = 'Add layer';
readonly layerId = newId();
+50
View File
@@ -17,6 +17,8 @@ import { findAssetOf, type Project } from './Project';
export const TILEMAP_COLUMNS_KEY = 'columns';
export const TILEMAP_ROWS_KEY = 'rows';
export const TILEMAP_TILE_KEY = 'tileSize';
/** The most columns or rows a grid may have – the schema's limit, and the edge buttons' too. */
export const TILEMAP_MAX_CELLS = 1000;
/**
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
@@ -163,6 +165,54 @@ export function cellOrigin(props: PropertyBag, x: number, y: number): { x: numbe
return { x: x * tile - w / 2, y: y * tile - h / 2 };
}
/** A side of the grid that grows or shrinks (#136). */
export type TilemapEdge = 'left' | 'top' | 'right' | 'bottom';
export const TILEMAP_EDGES: readonly TilemapEdge[] = ['left', 'top', 'right', 'bottom'];
/** Left and right change the columns, top and bottom the rows. */
export function edgeSizeKey(edge: TilemapEdge): string {
return edge === 'left' || edge === 'right' ? TILEMAP_COLUMNS_KEY : TILEMAP_ROWS_KEY;
}
/**
* One layer's cells moved by whole cells. Growing at the left or top edge is this: cell `0,0` is
* always the top-left corner, so what was painted moves one column or row further in. Nothing is
* dropped – a cell pushed below zero lies outside the grid and comes back when the edge grows again.
*/
export function shiftedCells(cells: Record<string, string>, dx: number, dy: number): Record<string, string> {
const moved: Record<string, string> = {};
for (const [key, tileId] of Object.entries(cells)) {
const at = parseCellKey(key);
if (at) moved[cellKey(at.x + dx, at.y + dy)] = tileId;
}
return moved;
}
/**
* How far the origin moves, in the tilemap's own frame, for the painted cells to stay where they are
* when `edge` gains `count` cells (negative removes them). The grid is centred on the origin, so
* every added column pushes the content half a cell away from that edge; the origin follows by as
* much the other way.
*/
export function edgeCompensation(
edge: TilemapEdge,
count: number,
tileSize: number,
): { x: number; y: number } {
const half = (count * tileSize) / 2;
switch (edge) {
case 'left':
return { x: -half, y: 0 };
case 'right':
return { x: half, y: 0 };
case 'top':
return { x: 0, y: -half };
case 'bottom':
return { x: 0, y: half };
}
}
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
export function cellsInRect(
props: PropertyBag,
+19 -3
View File
@@ -21,7 +21,7 @@ import {
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { TILE_FPS_KEY, TILE_KIND_KEY, TILE_PLAYBACK_KEY, TILE_SOLID_KEY } from '../model/tile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { TILEMAP_COLUMNS_KEY, TILEMAP_MAX_CELLS, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { SLICE_SCALE_KEY } from '../model/texture';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
@@ -254,8 +254,24 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
// how far the grid reaches and how big one cell is drawn. A tile's own pictures are scaled onto
// that cell, so one tilemap can mix tiles drawn at different resolutions.
tilemap: [
{ key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 },
{ key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 },
{
key: TILEMAP_COLUMNS_KEY,
label: 'Columns',
kind: 'number',
default: 20,
min: 1,
max: TILEMAP_MAX_CELLS,
step: 1,
},
{
key: TILEMAP_ROWS_KEY,
label: 'Rows',
kind: 'number',
default: 15,
min: 1,
max: TILEMAP_MAX_CELLS,
step: 1,
},
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
+39 -4
View File
@@ -6,6 +6,9 @@ import {
RevertPositionToTemplateCommand,
} from '../../core/commands/applyCommands';
import { UnpackContainerCommand } from '../../core/commands/containerCommands';
import { ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { TILEMAP_COLUMNS_KEY, TILEMAP_EDGES, TILEMAP_ROWS_KEY } from '../../core/model/tilemap';
import { findUsages } from '../../core/model/usages';
import {
ClearOverrideCommand,
MoveSceneObjectCommand,
@@ -38,7 +41,7 @@ import {
deviatesFromTemplate,
resolveSceneObjectProps,
} from '../../core/properties/resolve';
import { Button } from '../../ui';
import { Button, Theme } from '../../ui';
import { createContainer, removeObjects } from '../actions';
import { aspectActions, sizeCommand } from './aspectAction';
import { defSignature } from './assetSection';
@@ -47,7 +50,7 @@ import { addGap, addNameField, addNote, addTitle, type InspectorHost } from './h
import type { EditorState, ListSlot } from '../state';
import { parentRow } from './parentRow';
import { listOpener, previewingHandlers, structFields } from './previewHandlers';
import { overrideValue } from './valueSpecs';
import { overrideValue, tilemapSizeValue } from './valueSpecs';
import { buildVariable } from './variableRows';
import { currentProps, currentRef, deviations, positionMoved, targetOf, viewOf } from './objectQueries';
import { componentRow, OFFSET_AXES, offsetKey, POSITION_AXES } from './vectorRows';
@@ -82,7 +85,9 @@ export function objectSignature(
? 'instance'
: '';
const rows = schemaForObject(project, asset, object);
return `${object.parentId}|${kind}|` + rows.map(defSignature).join(',');
// A tilemap warns when it is placed more than once, so the number of placements shapes it too.
const placed = asset.type === 'tilemap' ? `|placed:${findUsages(project, asset.id).length}` : '';
return `${object.parentId}|${kind}${placed}|` + rows.map(defSignature).join(',');
}
/** Several objects: what they are, and the actions that apply to all of them at once. */
@@ -204,9 +209,36 @@ export function buildObjectSection(host: InspectorHost, holderId: string, object
for (const def of own) propertyRow(host, holderId, objectId, asset, def, values, asset.type === 'behavior');
markObjectRows(host, object, asset, target);
if (asset.type === 'tilemap') gridEdgesSection(host, holderId, object);
if (asset.type === 'container') contentSection(host, holderId, object, asset.objects.length);
}
/**
* Grows and shrinks the grid at any edge, one column or row per click, while the painted cells stay
* where they are in the scene (#136). The cells belong to the asset, so they move in every other
* placement too – which only this one's position is corrected for, hence the note.
*/
function gridEdgesSection(host: InspectorHost, holderId: string, object: SceneObject): void {
addTitle(host, 'GRID EDGES');
for (const edge of TILEMAP_EDGES) {
const { row } = labelledRow(edge[0]!.toUpperCase() + edge.slice(1));
for (const count of [-1, 1])
row.add(
new Button(count < 0 ? '−' : '+', {
variant: 'ghost',
minWidth: Theme.controlHeight,
onClick: () => host.state.execute(new ResizeTilemapEdgeCommand(holderId, object.id, edge, count)),
}),
);
host.body.add(row);
}
const others = findUsages(host.state.project, object.assetId).length - 1;
if (others > 0) {
addNote(host, `Placed ${others} more time${others === 1 ? '' : 's'}: at the left and top`, 'warning');
addNote(host, 'edge the cells move there as well.', 'warning');
} else addNote(host, 'The painted cells stay where they are in the scene.', 'small');
}
function assetLink(host: InspectorHost, label: string, asset: Asset): void {
const { row } = labelledRow(label);
row.add(
@@ -284,7 +316,10 @@ function propertyRow(
variable = false,
): FieldRow {
const bases = () => deviations(host.state, holderId, objectId, def.key);
const overrideSpec = overrideValue(host.state, holderId, objectId, def.key);
const overrideSpec =
owner.type === 'tilemap' && (def.key === TILEMAP_COLUMNS_KEY || def.key === TILEMAP_ROWS_KEY)
? tilemapSizeValue(host.state, holderId, objectId, def.key)
: overrideValue(host.state, holderId, objectId, def.key);
const fieldSlot = (field: string): ListSlot => ({
kind: 'object',
holderId,
+56
View File
@@ -1,10 +1,15 @@
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import { SetOverrideCommand } from '../../core/commands/sceneObjectCommands';
import { heldPosition, placedGridSize, ResizeTilemapEdgeCommand } from '../../core/commands/tilemapCommands';
import { findNode, type Graph, type GraphNode } from '../../core/graph/Graph';
import type { Asset } from '../../core/model/Asset';
import type { ObjectRef } from '../../core/model/instance';
import type { Position } from '../../core/model/Properties';
import { findAsset, findGraphAsset, type Project } from '../../core/model/Project';
import { TILEMAP_COLUMNS_KEY, type TilemapEdge, tilemapTileSize } from '../../core/model/tilemap';
import { coerceValue, schemaFor } from '../../core/properties/PropertySchema';
import { resolveSceneObjectProps } from '../../core/properties/resolve';
import type { EditorState } from '../state';
import { currentProps, currentRef } from './objectQueries';
import type { PreviewedValue } from './previewHandlers';
@@ -36,6 +41,57 @@ export function overrideValue(
};
}
/**
* Columns or Rows of a placed tilemap (#136): the grid grows and shrinks at the right or bottom edge
* while the painted cells stay where they are in the scene, so the origin moves along. The preview
* works from where the edit started rather than step by step – dragging the value back and forth
* would otherwise leave rounding drift behind, and restoring it before the commit would not be exact.
*/
export function tilemapSizeValue(
state: EditorState,
holderId: string,
objectId: string,
key: string,
): PreviewedValue<ObjectRef> {
const plain = overrideValue(state, holderId, objectId, key);
const edge: TilemapEdge = key === TILEMAP_COLUMNS_KEY ? 'right' : 'bottom';
let start: { position: Position; size: number } | undefined;
// Writes the value, then puts the origin where the size now asks for, counted from the start.
const holding = (ref: ObjectRef, write: () => void): void => {
const asset = findAsset(state.project, ref.object.assetId);
if (!asset) return write();
start ??= {
position: { ...ref.object.position },
size: placedGridSize(state.project, asset, ref.object, key),
};
write();
const props = resolveSceneObjectProps(state.project, asset, ref.object);
const size = placedGridSize(state.project, asset, ref.object, key);
ref.setPosition(
heldPosition(asset, ref.object, start.position, edge, size - start.size, tilemapTileSize(props)),
);
};
return {
...plain,
write: (ref, value) => holding(ref, () => ref.setOverride(key, value)),
remove: (ref) => holding(ref, () => ref.setOverride(key, undefined)),
commit: (value) => {
start = undefined;
const ref = currentRef(state, holderId, objectId);
const asset = ref && findAsset(state.project, ref.object.assetId);
const def = asset && schemaFor('tilemap').find((d) => d.key === key);
if (!ref || !asset || !def) return plain.commit(value);
const next = Number(coerceValue(def, value));
return new ResizeTilemapEdgeCommand(
holderId,
objectId,
edge,
next - placedGridSize(state.project, asset, ref.object, key),
);
},
};
}
export function nodeParamValue(
state: EditorState,
assetId: string,
+193
View File
@@ -0,0 +1,193 @@
/**
* Growing and shrinking a placed tilemap at any edge (#136): at the left and top edge the cells of
* every layer move along, and the placement's origin moves so that what was painted stays where it
* is in the scene – turned, scaled and mirrored maps included. One click is one undo step, and
* removing an edge throws no cell away.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands';
import { SetOverrideCommand, SetParentCommand } from '../../src/core/commands/sceneObjectCommands';
import {
AddTilemapLayerCommand,
PaintTilesCommand,
ResizeTilemapEdgeCommand,
} from '../../src/core/commands/tilemapCommands';
import type { TilemapAsset } from '../../src/core/model/Asset';
import { findObject } from '../../src/core/model/hierarchy';
import { apply } from '../../src/core/model/matrix';
import { findAsset } from '../../src/core/model/Project';
import {
cellOrigin,
layerTiles,
shiftedCells,
TILEMAP_COLUMNS_KEY,
TILEMAP_MAX_CELLS,
TILEMAP_ROWS_KEY,
type TilemapEdge,
} from '../../src/core/model/tilemap';
import { resolveSceneObjectProps } from '../../src/core/properties/resolve';
import { worldTransform } from '../../src/core/properties/transform';
import { makeSpriteScene, place } from '../helpers';
/** A 4 × 3 map of 10 px cells with two layers, placed at (100, 50). */
function setup() {
const f = makeSpriteScene();
const grass = new CreateAssetCommand('tile', 'Grass');
const map = new CreateAssetCommand('tilemap', 'Ground');
f.stack.execute(grass);
f.stack.execute(map);
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 4));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 3));
f.stack.execute(new SetAssetPropertyCommand(map.asset.id, 'tileSize', 10));
const layer2 = new AddTilemapLayerCommand(map.asset.id);
f.stack.execute(layer2);
const mapObject = place(f, map.asset.id, { x: 100, y: 50 });
const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset;
const paint = (layerId: string, ...at: [number, number][]) =>
f.stack.execute(
new PaintTilesCommand(
map.asset.id,
layerId,
at.map(([x, y]) => ({ x, y, tileId: grass.asset.id })),
),
);
const [first, second] = assetOf().layers.map((layer) => layer.id) as [string, string];
paint(first, [0, 0], [3, 2]);
paint(second, [1, 1]);
const object = (id = mapObject) => findObject(f.viewOf(), id)!;
const props = (id = mapObject) => resolveSceneObjectProps(f.project, assetOf(), object(id));
/** Where a cell's top-left corner is in the world. */
const corner = (x: number, y: number, id = mapObject) =>
apply(worldTransform(f.project, f.viewOf(), object(id)).m, cellOrigin(props(id), x, y));
const resize = (edge: TilemapEdge, count: number, id = mapObject) =>
f.stack.execute(new ResizeTilemapEdgeCommand(f.sceneId, id, edge, count));
const snapshot = () => structuredClone({ asset: assetOf(), object: object() });
return {
...f,
mapId: map.asset.id,
mapObject,
grassId: grass.asset.id,
assetOf,
object,
props,
corner,
resize,
snapshot,
};
}
/** The cells as a layer holds them, per layer. */
function cellKeys(asset: TilemapAsset): string[][] {
return asset.layers.map((layer) => Object.keys(layer.cells).sort());
}
function expectNear(a: { x: number; y: number }, b: { x: number; y: number }): void {
expect(a.x).toBeCloseTo(b.x, 9);
expect(a.y).toBeCloseTo(b.y, 9);
}
describe('shifted cells', () => {
it('moves every cell by whole cells, below zero included', () => {
expect(shiftedCells({ '0,0': 'a', '3,1': 'b' }, -1, 2)).toEqual({ '-1,2': 'a', '2,3': 'b' });
expect(shiftedCells({}, 5, 5)).toEqual({});
});
});
describe('resizing a placed tilemap at an edge', () => {
it('adds a column at the left: every layer moves along, and the cells stay put in the scene', () => {
const s = setup();
const before = [s.corner(0, 0), s.corner(3, 2), s.corner(1, 1)];
s.resize('left', 2);
expect(cellKeys(s.assetOf())).toEqual([['2,0', '5,2'], ['3,1']]);
expect(s.object().overrides[TILEMAP_COLUMNS_KEY]).toBe(6);
const after = [s.corner(2, 0), s.corner(5, 2), s.corner(3, 1)];
after.forEach((at, i) => expectNear(at, before[i]!));
expect(s.object().position).toEqual({ x: 90, y: 50, z: 0 });
});
it('adds rows at the top the same way', () => {
const s = setup();
const before = s.corner(1, 1);
s.resize('top', 1);
expect(cellKeys(s.assetOf())).toEqual([['0,1', '3,3'], ['1,2']]);
expect(s.object().overrides[TILEMAP_ROWS_KEY]).toBe(4);
expectNear(s.corner(1, 2), before);
});
it('adds at the right and bottom without moving a cell, and still holds the content', () => {
const s = setup();
const before = s.corner(3, 2);
s.resize('right', 3);
s.resize('bottom', 2);
expect(cellKeys(s.assetOf())).toEqual([['0,0', '3,2'], ['1,1']]);
expect(s.props()).toMatchObject({ [TILEMAP_COLUMNS_KEY]: 7, [TILEMAP_ROWS_KEY]: 5 });
expectNear(s.corner(3, 2), before);
});
it('holds still on a turned, scaled and mirrored map inside a turned parent', () => {
const s = setup();
const parent = place(s, s.spriteId, { x: -30, y: 20 });
s.stack.execute(new SetOverrideCommand(s.sceneId, parent, 'rotation', 20));
s.stack.execute(new SetParentCommand(s.sceneId, s.mapObject, parent));
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'rotation', 37));
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'scale', 2.5));
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'mirrorX', true));
// The painted cell (1,1), followed through all four edges.
const at = { x: 1, y: 1 };
for (const [edge, count] of [
['left', 3],
['top', -1],
['right', -2],
['bottom', 4],
] as const) {
const before = s.corner(at.x, at.y);
s.resize(edge, count);
if (edge === 'left') at.x += count;
if (edge === 'top') at.y += count;
expectNear(s.corner(at.x, at.y), before);
}
expect(s.assetOf().layers[1]!.cells).toEqual({ [`${at.x},${at.y}`]: s.grassId });
});
it('removing a painted edge keeps its cells outside the grid, and adding it back returns them', () => {
const s = setup();
const start = s.snapshot();
s.resize('left', -1);
expect(cellKeys(s.assetOf())).toEqual([['-1,0', '2,2'], ['0,1']]);
const drawn = layerTiles(s.project, s.props(), s.assetOf().layers[0]!).map((t) => `${t.x},${t.y}`);
expect(drawn).toEqual(['2,2']);
s.resize('left', 1);
expect(cellKeys(s.assetOf())).toEqual(start.asset.layers.map((l) => Object.keys(l.cells).sort()));
expect(s.object().position).toEqual(start.object.position);
});
it('is one undo step that restores cells, size and position exactly, and redoes the same', () => {
const s = setup();
s.stack.execute(new SetOverrideCommand(s.sceneId, s.mapObject, 'rotation', 33));
const start = s.snapshot();
s.resize('left', 2);
const done = s.snapshot();
expect(s.stack.undo()).toBe(true);
expect(s.snapshot()).toEqual(start);
expect(s.stack.redo()).toBe(true);
expect(s.snapshot()).toEqual(done);
});
it('refuses to go below one column or above the limit, and changes nothing then', () => {
const s = setup();
const start = s.snapshot();
expect(() => s.resize('left', -4)).toThrow(/at least one/);
expect(() => s.resize('bottom', TILEMAP_MAX_CELLS)).toThrow(/at most/);
expect(s.snapshot()).toEqual(start);
});
it('moves the cells in another placement too, whose position it leaves alone', () => {
const s = setup();
const other = place(s, s.mapId, { x: -200, y: 0 });
const before = s.object(other).position;
s.resize('left', 1);
expect(s.object(other).position).toEqual(before);
expect(s.object(other).overrides[TILEMAP_COLUMNS_KEY]).toBeUndefined();
expect(cellKeys(s.assetOf())[0]).toEqual(['1,0', '4,2']);
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Columns and Rows of a placed tilemap in the inspector (#136): the grid grows at the right or bottom
* edge while the painted cells stay where they are, already while the value is previewed. The
* preview counts from where the edit started, so dragging back and forth leaves no drift, and the
* commit is one undo step.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { PlaceAssetCommand, SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands';
import { findObject } from '../../src/core/model/hierarchy';
import { resolvedHolder } from '../../src/core/model/instance';
import { createEmptyProject, findScene } from '../../src/core/model/Project';
import { TILEMAP_COLUMNS_KEY } from '../../src/core/model/tilemap';
import { previewingHandlers } from '../../src/editor/inspector/previewHandlers';
import { tilemapSizeValue } from '../../src/editor/inspector/valueSpecs';
import { EditorState } from '../../src/editor/state';
function setup() {
const state = new EditorState();
state.loadProject(createEmptyProject('p', 'P'), 1);
const scene = new CreateAssetCommand('scene', 'Main');
const map = new CreateAssetCommand('tilemap', 'Ground');
state.execute(scene);
state.execute(map);
const placed = new PlaceAssetCommand(scene.asset.id, map.asset.id, { x: 10, y: 20 });
state.execute(placed);
state.execute(new SetOverrideCommand(scene.asset.id, placed.objectId, 'rotation', 90));
const object = () =>
findObject(resolvedHolder(state.project, findScene(state.project, scene.asset.id)!), placed.objectId)!;
const field = previewingHandlers(
{ state },
tilemapSizeValue(state, scene.asset.id, placed.objectId, TILEMAP_COLUMNS_KEY),
);
return { state, object, field };
}
describe('the Columns field of a placed tilemap', () => {
it('moves the origin along while previewing, from where the edit started', () => {
const { object, field } = setup();
// 20 columns of 48 px by default; turned by 90°, the map's own x axis points down the screen.
field.onInput(23);
expect(object().position.x).toBeCloseTo(10, 9);
expect(object().position.y).toBeCloseTo(20 + 72, 9);
field.onInput(17);
field.onInput(21);
expect(object().position.y).toBeCloseTo(20 + 24, 9);
});
it('commits one undo step that holds the content, and undo returns exactly to the start', () => {
const { state, object, field } = setup();
const start = structuredClone(object());
for (const value of [31, 5, 26]) field.onInput(value);
field.onChange(26);
expect(object().overrides[TILEMAP_COLUMNS_KEY]).toBe(26);
expect(object().position.y).toBeCloseTo(20 + 144, 9);
state.undo();
expect(object()).toEqual(start);
});
it('takes a value typed in without a preview as well', () => {
const { object, field } = setup();
field.onChange(19);
expect(object().overrides[TILEMAP_COLUMNS_KEY]).toBe(19);
expect(object().position.y).toBeCloseTo(20 - 24, 9);
});
});
+69
View File
@@ -40,6 +40,27 @@ async function clickInPanel(t, hook) {
return Boolean(point);
}
/**
* The − or + button of an edge row in the inspector's GRID EDGES block (#136), scrolled into view
* first. Both glyphs are on screen many times over, so the button is the one on the label's line.
*/
async function edgeButton(t, label, glyph) {
for (let i = 0; i < 15; i++) {
const rect = await t.json('__gm.inspectorRect()');
const within = (p) => p.x > rect.x && p.x < rect.x + rect.w;
const row = (await t.json(`__gm.locateAll(${JSON.stringify(label)})`)).find(within);
if (row && row.y > rect.y + 8 && row.y < rect.y + rect.h - 8) {
const buttons = (await t.json(`__gm.locateAll(${JSON.stringify(glyph)})`)).filter(
(p) => within(p) && Math.abs(p.y - row.y) < 6 && p.x > row.x,
);
return buttons[0] ?? null;
}
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, row && row.y < rect.y ? -200 : 200);
await t.settle();
}
return null;
}
/** Whether a box lies inside another, give or take a pixel of rounding. */
function inside(inner, outer) {
return (
@@ -561,6 +582,54 @@ export async function tilemap(t) {
await undo();
await settle();
// 40o. The grid grows at the left edge (#136): the cells of both layers move one column in, the
// origin moves half a cell to the left, and on screen the painted cells stay where they were.
const allCells = () =>
json(
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(made.id)}).layers.map((l) => Object.keys(l.cells).sort().join(' '))`,
);
const cellsBefore = await allCells();
const objectBefore = await t.object(MAP_OBJECT);
const screenBefore = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 1, 1)`);
const addLeft = await edgeButton(t, 'Left', '+');
if (addLeft) await click(addLeft.x, addLeft.y);
await settle();
await shot('40-grid-edges');
const cellsGrown = await allCells();
const objectGrown = await t.object(MAP_OBJECT);
const screenGrown = await json(`__gm.tilemap.cellAt(${JSON.stringify(MAP_OBJECT)}, 2, 1)`);
const drawnGrown = await json(`__gm.tilemap.info(${JSON.stringify(MAP_OBJECT)})`);
const movedIn = (keys) =>
keys
.split(' ')
.filter(Boolean)
.map((key) => key.split(',').map(Number))
.map(([x, y]) => `${x + 1},${y}`)
.sort()
.join(' ');
check(
'the + of the left edge moves every layer one column in and the map stays where it is on screen',
addLeft !== null &&
cellsGrown.length === 2 &&
cellsGrown.every((keys, i) => keys === movedIn(cellsBefore[i])) &&
objectGrown.overrides.columns === COLUMNS + 1 &&
Math.abs(objectGrown.position.x - (objectBefore.position.x - 24)) < 1e-6 &&
Math.abs(screenGrown.x - screenBefore.x) < 0.5 &&
Math.abs(screenGrown.y - screenBefore.y) < 0.5 &&
drawnGrown.tiles === 10,
JSON.stringify({ addLeft, cellsBefore, cellsGrown, objectGrown, screenBefore, screenGrown, drawnGrown }),
);
await undo();
await settle();
const objectUndone = await t.object(MAP_OBJECT);
check(
'and one undo takes back the cells, the column and the position',
JSON.stringify(await allCells()) === JSON.stringify(cellsBefore) &&
objectUndone.overrides.columns === undefined &&
JSON.stringify(objectUndone.position) === JSON.stringify(objectBefore.position),
JSON.stringify({ cells: await allCells(), objectUndone }),
);
// 40j. The running game draws the same map: it is scene content, not editor decoration. The map
// is moved in front of the camera for this, because an object off screen is culled there.
await mutate(`