diff --git a/README.md b/README.md index 8d513814..e9273e77 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/commands/tilemapCommands.ts b/src/core/commands/tilemapCommands.ts index 7f028f96..051d90e5 100644 --- a/src/core/commands/tilemapCommands.ts +++ b/src/core/commands/tilemapCommands.ts @@ -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 }[] = []; + + 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(); diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 7a1a5f39..faf09237 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -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, dx: number, dy: number): Record { + const moved: Record = {}; + 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, diff --git a/src/core/properties/PropertySchema.ts b/src/core/properties/PropertySchema.ts index a72f63ff..23082548 100644 --- a/src/core/properties/PropertySchema.ts +++ b/src/core/properties/PropertySchema.ts @@ -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 = { // 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`. diff --git a/src/editor/inspector/objectSection.ts b/src/editor/inspector/objectSection.ts index 159a211b..1637f835 100644 --- a/src/editor/inspector/objectSection.ts +++ b/src/editor/inspector/objectSection.ts @@ -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, diff --git a/src/editor/inspector/valueSpecs.ts b/src/editor/inspector/valueSpecs.ts index fb5685de..c75963c0 100644 --- a/src/editor/inspector/valueSpecs.ts +++ b/src/editor/inspector/valueSpecs.ts @@ -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 { + 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, diff --git a/tests/core/tilemap-resize.test.ts b/tests/core/tilemap-resize.test.ts new file mode 100644 index 00000000..9e0b9e6b --- /dev/null +++ b/tests/core/tilemap-resize.test.ts @@ -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']); + }); +}); diff --git a/tests/editor/tilemap-size-field.test.ts b/tests/editor/tilemap-size-field.test.ts new file mode 100644 index 00000000..55981187 --- /dev/null +++ b/tests/editor/tilemap-size-field.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index 2cbecdca..d539a3b6 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -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(`