diff --git a/README.md b/README.md index 5d911656..312b7800 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1351 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1356 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/core/graph/NodeContext.ts b/src/core/graph/NodeContext.ts index fd82f653..4cbd311c 100644 --- a/src/core/graph/NodeContext.ts +++ b/src/core/graph/NodeContext.ts @@ -179,6 +179,18 @@ export interface NodeContext { ): RayOutcome[]; /** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */ ignoreOneWay(target: EntityHandle, seconds: number): void; + /** Whether `target` is a placed tilemap – what the tile nodes act on (#135). */ + isTilemap(target: EntityHandle): boolean; + /** + * The tile id in a cell of a placed tilemap, `''` for an empty one; null when `target` is no + * tilemap, has no layer of that name (`''` = its first) or the cell lies outside its grid. + */ + tileAt(target: EntityHandle, layer: string, cell: { x: number; y: number }): string | null; + /** + * Puts a tile into that cell, `''` empties it; false where `tileAt` answers null or the id is no + * tile. Only this placement changes, and only for the run (see `model/liveTilemap.ts`). + */ + setTile(target: EntityHandle, layer: string, cell: { x: number; y: number }, tileId: string): boolean; /** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */ physics?: PhysicsControl; log(message: string): void; diff --git a/src/core/graph/nodes/action/tileNodes.ts b/src/core/graph/nodes/action/tileNodes.ts new file mode 100644 index 00000000..f72a702b --- /dev/null +++ b/src/core/graph/nodes/action/tileNodes.ts @@ -0,0 +1,125 @@ +/** + * A placed tilemap read and changed by a behavior (#135). The cells a node sets belong to that one + * placement and to the run: they are drawn and block at once, and after Stop the map is the one + * that was painted (see `model/liveTilemap.ts`). + */ +import { cellToWorld, worldToCell } from '../../../model/liveTilemap'; +import { worldMatrixOf } from '../../NodeContext'; +import type { NodeDef, PortDef } from '../../NodeRegistry'; +import { asEntity, asNumber, asString, type Value } from '../../values'; +import { bool, exec, int, num, obj } from '../shared'; + +/** The layer by name, picked from the layers of every map in the project; none = the first. */ +const layerPort = (): PortDef => ({ + key: 'layer', + label: 'layer', + type: 'string', + field: { key: 'layer', label: 'Layer', kind: 'tileLayer', default: '' }, +}); + +/** A tile asset; unconnected, it shows the tile picker, and none chosen empties the cell. */ +const tilePort = (key = 'tile', label = key): PortDef => ({ + key, + label, + type: 'string', + field: { key, label: 'Tile', kind: 'asset', default: '', assetTypes: ['tile'] }, +}); + +const CELL_INPUTS: readonly PortDef[] = [int('cellX', 0, 'cell x'), int('cellY', 0, 'cell y')]; + +/** A cell counts in whole columns and rows, so a fraction arriving over an edge is floored. */ +const cellOf = (x: Value | undefined, y: Value | undefined) => ({ + x: Math.floor(asNumber(x)), + y: Math.floor(asNumber(y)), +}); + +export const TileAt: NodeDef = { + type: 'TileAt', + label: 'Tile at', + category: 'data', + description: + 'The tile in a cell of a placed tilemap, read the moment it is asked for, so a "Set tile" earlier in ' + + 'the same flow is already seen. The layer is picked by name ("First layer" is the first of the map). ' + + '"empty" is true for a cell without a tile, and for one outside the grid or on a layer the map does ' + + 'not have.', + inputs: [obj('tilemap'), layerPort(), ...CELL_INPUTS], + outputs: [tilePort(), bool('empty')], + params: [], + read: (ctx, _params, input) => { + const target = asEntity(input('tilemap')); + const tile = target + ? ctx.tileAt(target, asString(input('layer')), cellOf(input('cellX'), input('cellY'))) + : null; + return { tile: tile ?? '', empty: !tile }; + }, +}; + +export const SetTile: NodeDef = { + type: 'SetTile', + label: 'Set tile', + category: 'action', + description: + 'Puts a tile into a cell of a placed tilemap, or empties the cell when no tile is given. The autotile ' + + 'edges of the cells around it follow, and so do its collision shapes. Only this placement of the map ' + + 'changes, and only while the game runs. "done" is false when nothing could be set: no tilemap, no ' + + 'such layer, a cell outside the grid, or something that is not a tile.', + inputs: [exec(), obj('tilemap'), layerPort(), ...CELL_INPUTS, tilePort()], + outputs: [exec(), bool('done')], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.tilemap); + const cell = cellOf(inputs.cellX, inputs.cellY); + const done = target ? ctx.setTile(target, asString(inputs.layer), cell, asString(inputs.tile)) : false; + return { outputs: { done }, next: 'exec' }; + }, +}; + +export const WorldToCell: NodeDef = { + type: 'WorldToCell', + label: 'World to cell', + category: 'data', + description: + 'The cell of a placed tilemap a world point falls into – moved, turned, scaled or mirrored maps ' + + 'included. Cell 0,0 is the top-left corner. "inside" says whether that cell is on the grid.', + inputs: [obj('tilemap'), num('x'), num('y')], + outputs: [num('cellX', 'cell x'), num('cellY', 'cell y'), bool('inside')], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.tilemap); + if (!target || !ctx.isTilemap(target)) return { outputs: { cellX: 0, cellY: 0, inside: false } }; + const cell = worldToCell(target.props, worldMatrixOf(target), { + x: asNumber(inputs.x), + y: asNumber(inputs.y), + }); + return { outputs: { cellX: cell.x, cellY: cell.y, inside: cell.inside } }; + }, +}; + +export const CellToWorld: NodeDef = { + type: 'CellToWorld', + label: 'Cell to world', + category: 'data', + description: + 'The middle of a cell of a placed tilemap in world units – where to put something that stands on ' + + 'that cell. Cells off the grid are answered too, continuing its rows and columns.', + inputs: [obj('tilemap'), ...CELL_INPUTS], + outputs: [num('x'), num('y')], + params: [], + run: (ctx, inputs) => { + const target = asEntity(inputs.tilemap); + if (!target || !ctx.isTilemap(target)) return { outputs: { x: 0, y: 0 } }; + const at = cellToWorld(target.props, worldMatrixOf(target), cellOf(inputs.cellX, inputs.cellY)); + return { outputs: { x: at.x, y: at.y } }; + }, +}; + +export const TileLiteral: NodeDef = { + type: 'Tile', + label: 'Tile', + category: 'data', + description: 'A constant tile, for a "Set tile" to put down or to compare what a "Tile at" reads with.', + inputs: [], + outputs: [tilePort('value')], + params: [{ key: 'value', label: 'Value', kind: 'asset', default: '', assetTypes: ['tile'] }], + run: (_ctx, _inputs, params) => ({ outputs: { value: asString(params.value ?? '') } }), +}; diff --git a/src/core/graph/nodes/index.ts b/src/core/graph/nodes/index.ts index 22968ca6..0e2c7fb0 100644 --- a/src/core/graph/nodes/index.ts +++ b/src/core/graph/nodes/index.ts @@ -23,6 +23,7 @@ import { SetPosition } from './action/SetPosition'; import { Spawn } from './action/Spawn'; import { StopGame } from './action/StopGame'; import { StopSound } from './action/StopSound'; +import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes'; import { Abs } from './data/Abs'; import { Add } from './data/Add'; import { And } from './data/And'; @@ -227,6 +228,11 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [ Raycast, RaycastAll, IgnoreOneWay, + TileAt, + SetTile, + WorldToCell, + CellToWorld, + TileLiteral, Spawn, Destroy, Emit, diff --git a/src/core/model/liveTilemap.ts b/src/core/model/liveTilemap.ts new file mode 100644 index 00000000..d774f385 --- /dev/null +++ b/src/core/model/liveTilemap.ts @@ -0,0 +1,103 @@ +/** + * A placed tilemap as a running game sees it (#135). A behavior may read and change its cells, but + * the cells belong to the asset, and two placements of one map must not paint each other. So a run + * keeps what it changed per placement, as edits laid over the asset's cells, and never writes them + * back: after Stop the map is the one that was painted. + */ +import type { TilemapAsset, TilemapLayer } from './Asset'; +import { apply, determinant, invert, type Mat } from './matrix'; +import type { PropertyBag, Vec2 } from './Properties'; +import { type Cell, cellAt, cellAtPoint, cellKey, cellOrigin, inGrid, tilemapTileSize } from './tilemap'; + +/** + * What a run changed on one placement: per layer id, the cells it set, `''` for one it emptied. + * Replaced rather than changed in place, so whoever worked something out from the old one can tell. + */ +export type TileEdits = Readonly>>>; + +/** How a node's empty layer choice reads: the first layer of whatever map it is given. */ +export const FIRST_TILE_LAYER = 'First layer'; + +/** + * The layer a node names. By name, not by index, since the index moves when the layers are + * reordered; no name is the first layer, which is all a map of one layer needs. Two layers of one + * name are the upper one in the list, the same one the panel shows first. + */ +export function layerNamed(asset: TilemapAsset, name: string): TilemapLayer | undefined { + return name === '' ? asset.layers[0] : asset.layers.find((layer) => layer.name === name); +} + +/** The asset's layers with a run's edits laid over them – what is drawn and what blocks. */ +export function liveLayers(asset: TilemapAsset, edits: TileEdits | undefined): TilemapLayer[] { + if (!edits) return asset.layers; + return asset.layers.map((layer) => { + const changed = edits[layer.id]; + if (!changed) return layer; + const cells: Record = { ...layer.cells }; + for (const [key, tileId] of Object.entries(changed)) { + if (tileId) cells[key] = tileId; + else delete cells[key]; + } + return { ...layer, cells }; + }); +} + +/** The asset as a run sees it: the same one when nothing was changed. */ +export function liveTilemap(asset: TilemapAsset, edits: TileEdits | undefined): TilemapAsset { + return edits ? { ...asset, layers: liveLayers(asset, edits) } : asset; +} + +/** + * The tile in a cell right now, `''` for an empty one. Null when there is nothing to ask: no layer + * of that name, or a cell outside the grid. + */ +export function liveTileAt( + asset: TilemapAsset, + edits: TileEdits | undefined, + props: PropertyBag, + layerName: string, + cell: Cell, +): string | null { + const layer = layerNamed(asset, layerName); + if (!layer || !inGrid(props, cell.x, cell.y)) return null; + const edited = edits?.[layer.id]?.[cellKey(cell.x, cell.y)]; + return edited ?? cellAt(layer, cell.x, cell.y); +} + +/** + * The edits with one more cell set, `''` emptying it; null when the cell cannot be set (see + * `liveTileAt`). Setting what is already there gives the same edits back, so nothing is rebuilt. + */ +export function withTile( + asset: TilemapAsset, + edits: TileEdits | undefined, + props: PropertyBag, + layerName: string, + cell: Cell, + tileId: string, +): TileEdits | null { + const now = liveTileAt(asset, edits, props, layerName, cell); + if (now === null) return null; + if (now === tileId) return edits ?? {}; + const layer = layerNamed(asset, layerName)!; + return { ...edits, [layer.id]: { ...edits?.[layer.id], [cellKey(cell.x, cell.y)]: tileId } }; +} + +/** + * The cell a world point falls into, and whether that cell is on the grid. The map may be moved, + * turned, scaled and mirrored, so the point is taken into the map's own frame first; a map scaled + * to nothing has no cells at all. + */ +export function worldToCell(props: PropertyBag, m: Mat, point: Vec2): Cell & { inside: boolean } { + if (Math.abs(determinant(m)) < 1e-12) return { x: 0, y: 0, inside: false }; + const local = apply(invert(m), point); + const cell = cellAtPoint(props, local.x, local.y); + return { ...cell, inside: inGrid(props, cell.x, cell.y) }; +} + +/** The middle of a cell in world units – where something standing on that cell is put. */ +export function cellToWorld(props: PropertyBag, m: Mat, cell: Cell): Vec2 { + const half = tilemapTileSize(props) / 2; + const corner = cellOrigin(props, cell.x, cell.y); + return apply(m, { x: corner.x + half, y: corner.y + half }); +} diff --git a/src/core/model/tilemap.ts b/src/core/model/tilemap.ts index 1186136a..f24300e8 100644 --- a/src/core/model/tilemap.ts +++ b/src/core/model/tilemap.ts @@ -151,6 +151,17 @@ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapL return tiles.sort((a, b) => a.y - b.y || a.x - b.x); } +/** + * The layer names of every tilemap in the project, each once, in asset and layer order. A node + * picks a layer by name before it knows which map it will be given (#135), so it is offered them all. + */ +export function tilemapLayerNames(project: Project): string[] { + const names = project.assets.flatMap((asset) => + asset.type === 'tilemap' ? asset.layers.map((layer) => layer.name) : [], + ); + return [...new Set(names)]; +} + /** The tiles a tilemap paints with, in the order they first appear – its palette in use. */ export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] { const ids: string[] = []; diff --git a/src/core/properties/kinds.ts b/src/core/properties/kinds.ts index dc2a85c7..87fa8860 100644 --- a/src/core/properties/kinds.ts +++ b/src/core/properties/kinds.ts @@ -155,6 +155,8 @@ export const KINDS = [ { kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf }, /** A set of collision layers as the 16 bits of a mask (#152). */ { kind: 'layers', label: 'Layers', travels: 'number', empty: 0, coerce: numberOf }, + /** A tilemap layer by name, `''` = the first (#135); the names come from the project's tilemaps. */ + { kind: 'tileLayer', label: 'Tilemap layer', travels: 'string', empty: '', coerce: text }, ] as const satisfies readonly KindRow[]; type Row = (typeof KINDS)[number]; diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index 33142374..7cee0d22 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -69,7 +69,9 @@ export class EditorRun { this.commit(write); // A shared value belongs to no object, so a frame that only moved one writes nothing here and // the panels would never hear of it. Asking who is looking keeps that to the frames it helps. - if (!write.values && !write.structure && this.showsVariables()) this.state.runtimeTick(); + // A set tile writes nothing either, and the maps on screen have to show it (#135). + const tiles = world.takeTileChange(); + if (!write.values && !write.structure && (tiles || this.showsVariables())) this.state.runtimeTick(); } /** Which state each instance of a machine holds in this run; empty when no editor run is on. */ diff --git a/src/editor/graph/NodeView.ts b/src/editor/graph/NodeView.ts index b8918f0b..ba2562de 100644 --- a/src/editor/graph/NodeView.ts +++ b/src/editor/graph/NodeView.ts @@ -16,6 +16,7 @@ import { type Value, } from '../../core/graph'; import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers'; +import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import type { Project } from '../../core/model/Project'; import { isList, type PropertyValue } from '../../core/model/Properties'; import type { PropertyDef } from '../../core/properties/PropertySchema'; @@ -483,6 +484,7 @@ function formatField(def: PropertyDef, value: Value, project: Project | null): s const layers = project?.collisionLayers ?? []; if (def.kind === 'layer') return layerName(layers, layerIndexOf(value)); if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers); + if (def.kind === 'tileLayer') return asString(value) || FIRST_TILE_LAYER; if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value); return referenceLabel(def, project, asString(value)) ?? formatValue(value); } diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index 8decf1c1..e9a7caa3 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -15,6 +15,8 @@ import { assetRefLabel, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; +import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; +import { tilemapLayerNames } from '../../core/model/tilemap'; import { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; import { emptyFieldValue, @@ -506,6 +508,18 @@ export function createField( }, }; } + case 'tileLayer': { + const labelOf = (v: PropertyValue) => textOf(v) || FIRST_TILE_LAYER; + const button = new Dropdown(labelOf(value)); + button.onClick = () => { + const names = ['', ...tilemapLayerNames(ctx.project())]; + new Menu( + names.map((name) => ({ label: name || FIRST_TILE_LAYER, onSelect: () => handlers.onChange(name) })), + { searchable: true }, + ).openBelow(button); + }; + return { widget: button, grow: true, setValue: (v) => button.setText(labelOf(v)) }; + } case 'key': { const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key)); return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) }; diff --git a/src/editor/viewport/GameViewport.ts b/src/editor/viewport/GameViewport.ts index bf33dc7c..7de2affa 100644 --- a/src/editor/viewport/GameViewport.ts +++ b/src/editor/viewport/GameViewport.ts @@ -80,7 +80,7 @@ export class GameViewport extends WorldViewport { } private modelFrame(): Frame { - return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR); + return collectHolderItems(this.state.project, this.state.activeScene, LETTERBOX_COLOR, this.editorWorld); } /** `camera`: object id of the camera the view is through, empty when none is switched on. */ diff --git a/src/editor/viewport/SceneViewport.ts b/src/editor/viewport/SceneViewport.ts index a12dc976..75eda446 100644 --- a/src/editor/viewport/SceneViewport.ts +++ b/src/editor/viewport/SceneViewport.ts @@ -175,7 +175,7 @@ export class SceneViewport extends FreeViewport { } protected override collectFrame(): Frame { - return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport); + return collectHolderItems(this.state.project, this.holder(), Theme.colors.viewport, this.editorWorld); } protected override get baseColor(): number { diff --git a/src/runtime/Entity.ts b/src/runtime/Entity.ts index 721d7373..d314266a 100644 --- a/src/runtime/Entity.ts +++ b/src/runtime/Entity.ts @@ -2,6 +2,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext'; import type { AnimationPlayer } from '../core/model/animation'; import type { AssetType } from '../core/model/AssetTypes'; import type { FrameOutline } from '../core/model/frameOutline'; +import type { TileEdits } from '../core/model/liveTilemap'; import type { PropertyBag } from '../core/model/Properties'; /** A live instance of a scene object; its resolved props are mutable runtime state the world mirrors back. */ @@ -32,4 +33,6 @@ export interface Entity { * call, so a changed `frame` prop or a redrawn outline counts at once. Absent outside a world. */ wornFrame?: () => FrameOutline | null; + /** For a placed tilemap: the cells a behavior set during the run (#135), never written back. */ + tileEdits?: TileEdits; } diff --git a/src/runtime/World.ts b/src/runtime/World.ts index d023b51e..6a749961 100644 --- a/src/runtime/World.ts +++ b/src/runtime/World.ts @@ -1,12 +1,14 @@ import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph'; import { newId } from '../core/ids'; -import type { Asset } from '../core/model/Asset'; +import type { Asset, TilemapAsset } from '../core/model/Asset'; import type { PropertyBag, Vec2 } from '../core/model/Properties'; import { isPlaceableType } from '../core/model/AssetTypes'; import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance'; import { treeOrder } from '../core/model/hierarchy'; +import { liveTileAt, withTile } from '../core/model/liveTilemap'; +import type { Cell } from '../core/model/tilemap'; import { descendantsBelow } from '../core/model/walk'; -import { findAsset, findScene, type Project } from '../core/model/Project'; +import { findAsset, findAssetOf, findScene, type Project } from '../core/model/Project'; import { createSceneObject, type SceneObject } from '../core/model/SceneObject'; import { footprintContains } from '../core/properties/bounds'; import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider'; @@ -89,6 +91,7 @@ export class World { private elapsed = 0; /** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */ private structureChanged = false; + private tilesChanged = false; private constructor( readonly project: Project, @@ -160,6 +163,42 @@ export class World { return false; } + /** The asset of a placed tilemap; undefined for anything else. */ + private tilemapAssetOf(entity: Entity): TilemapAsset | undefined { + return entity.type === 'tilemap' ? findAssetOf(this.project, entity.assetId, 'tilemap') : undefined; + } + + isTilemap(entity: Entity): boolean { + return this.tilemapAssetOf(entity) !== undefined; + } + + /** The tile in a cell of a placed tilemap as the run left it (see `model/liveTilemap.ts`). */ + tileAt(entity: Entity, layer: string, cell: Cell): string | null { + const asset = this.tilemapAssetOf(entity); + return asset ? liveTileAt(asset, entity.tileEdits, entity.props, layer, cell) : null; + } + + /** Sets a cell of this one placement for the rest of the run; `''` empties it. */ + setTile(entity: Entity, layer: string, cell: Cell, tileId: string): boolean { + const asset = this.tilemapAssetOf(entity); + if (!asset || (tileId !== '' && !findAssetOf(this.project, tileId, 'tile'))) return false; + const edits = withTile(asset, entity.tileEdits, entity.props, layer, cell, tileId); + if (!edits) return false; + if (edits !== entity.tileEdits) this.tilesChanged = true; + entity.tileEdits = edits; + return true; + } + + /** + * Whether a tile was set since the last ask. The editor run needs it: a changed cell reaches no + * model, so nothing else would tell the views to draw the map again. + */ + takeTileChange(): boolean { + const changed = this.tilesChanged; + this.tilesChanged = false; + return changed; + } + /** Sets a scene property for the rest of the run; the scene in the model keeps its own. */ setSceneProp(key: string, value: PropertyBag[string]): void { this.sceneProps[key] = value; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index a88ef1bd..6359ce9f 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -707,6 +707,18 @@ export class BehaviorSystem implements System { const found = resolve(target); if (found) world.ignoreOneWay(found, seconds); }, + isTilemap: (target) => { + const found = resolve(target); + return found ? world.isTilemap(found) : false; + }, + tileAt: (target, layer, cell) => { + const found = resolve(target); + return found ? world.tileAt(found, layer, cell) : null; + }, + setTile: (target, layer, cell, tileId) => { + const found = resolve(target); + return found ? world.setTile(found, layer, cell, tileId) : false; + }, physics: this.collisions instanceof PhysicsSystem ? physicsControl(world, this.collisions, resolve) diff --git a/src/runtime/systems/solids.ts b/src/runtime/systems/solids.ts index 88168935..6c11d740 100644 --- a/src/runtime/systems/solids.ts +++ b/src/runtime/systems/solids.ts @@ -3,6 +3,7 @@ import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from ' import type { Vec2 } from '../../core/model/Properties'; import { dot } from '../../core/util/vec'; import { findAssetOf } from '../../core/model/Project'; +import { liveTilemap, type TileEdits } from '../../core/model/liveTilemap'; import { type CellPolygon, cellPolygonShape, @@ -193,10 +194,10 @@ export interface Contact { export class Solids { /** * The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick – - * painting, or a behavior changing the map, is picked up by the next one – and turned into world + * painting is picked up by the next one, a behavior's `Set tile` at once – and turned into world * shapes only when asked, since the map may move in between. */ - private blocks = new Map(); + private blocks = new Map(); /** Forgets the cells, so the next tick works them out afresh. */ reset(): void { @@ -204,9 +205,11 @@ export class Solids { } private blocksOf(world: World, tilemap: Entity): TilemapBlocks { - let blocks = this.blocks.get(tilemap); - if (!blocks) { - const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap'); + let held = this.blocks.get(tilemap); + // A `Set tile` replaces the edits, so a move later in the same tick already meets the new cell. + if (!held || held.edits !== tilemap.tileEdits) { + const stored = findAssetOf(world.project, tilemap.assetId, 'tilemap'); + const asset = stored && liveTilemap(stored, tilemap.tileEdits); const { project } = world; const { props } = tilemap; // One group per side and surface, so cells only merge with cells that block alike (#159, #193). @@ -217,10 +220,11 @@ export class Solids { rects: solidRects(project, props, asset!, angle ?? false, material), polygons: solidPolygons(project, props, asset!, angle ?? false, material), })); - blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : []; - this.blocks.set(tilemap, blocks); + const blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : []; + held = { edits: tilemap.tileEdits, blocks }; + this.blocks.set(tilemap, held); } - return blocks; + return held.blocks; } /** diff --git a/src/viewport/items.ts b/src/viewport/items.ts index 15b24849..ae35fa2a 100644 --- a/src/viewport/items.ts +++ b/src/viewport/items.ts @@ -18,6 +18,7 @@ import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core import { fontFileOf } from '../core/model/font'; import { resolveTexture, type TextureRef } from '../core/model/texture'; import type { FrameOutline } from '../core/model/frameOutline'; +import { liveTilemap, type TileEdits } from '../core/model/liveTilemap'; import { colliderFrame } from '../core/properties/collider'; import { enabledAlongChain } from '../core/properties/enabled'; import { fontOf } from '../core/properties/fonts'; @@ -96,9 +97,15 @@ export interface TilemapDraw { * project is at hand, for the same reason a sprite's picture is resolved here: drawing has no * access to it. A hidden layer is left out, in the editor and in the running game alike. */ -function resolveTilemap(project: Project, assetId: string, props: PropertyBag): TilemapDraw | null { - const asset = findAssetOf(project, assetId, 'tilemap'); - if (!asset) return null; +function resolveTilemap( + project: Project, + assetId: string, + props: PropertyBag, + edits: TileEdits | undefined, +): TilemapDraw | null { + const stored = findAssetOf(project, assetId, 'tilemap'); + if (!stored) return null; + const asset = liveTilemap(stored, edits); const layers: TilemapLayerDraw[] = []; const used = new Map(); for (const layer of asset.layers) { @@ -140,12 +147,14 @@ function wearsPicture(type: AssetType): boolean { /** * A scene or a container template, in tree order – the tie-break for equal `z` – at the editor * positions, with the content of placed containers resolved in. A container has no background of - * its own, so it keeps the fallback. + * its own, so it keeps the fallback. `run` is the editor run on that scene: the tiles its behaviors + * set are shown on the maps, though they never reach the model (#135). */ export function collectHolderItems( project: Project, holder: ObjectHolder | undefined, fallbackBackground: number, + run: World | null = null, ): DrawList { if (!holder) return { items: [], background: fallbackBackground }; const view = resolvedHolder(project, holder); @@ -169,7 +178,10 @@ export function collectHolderItems( texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null, font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '', particles: asset.type === 'particles' ? asset : null, - tilemap: asset.type === 'tilemap' ? resolveTilemap(project, asset.id, props) : null, + tilemap: + asset.type === 'tilemap' + ? resolveTilemap(project, asset.id, props, liveEdits(run, holder, object.id)) + : null, emits: 0, frame: asset.type === 'collider' ? colliderFrame(project, props) : null, }); @@ -184,6 +196,11 @@ export function collectHolderItems( return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) }; } +/** What a run changed on a placed tilemap of the holder shown, if the run is on that holder. */ +function liveEdits(run: World | null, holder: ObjectHolder, objectId: string): TileEdits | undefined { + return run?.sceneId === holder.id ? run.findByObjectId(objectId)?.tileEdits : undefined; +} + /** The running world: scene content only, since symbols are editor-only. */ export function collectWorldItems(world: World): DrawList { const items = world.entities @@ -207,7 +224,7 @@ export function collectWorldItems(world: World): DrawList { texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null, font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '', particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null, - tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props) : null, + tilemap: e.type === 'tilemap' ? resolveTilemap(world.project, e.assetId, e.props, e.tileEdits) : null, emits: e.emits ?? 0, frame: e.wornFrame?.() ?? null, })); diff --git a/tests/core/node-catalog.test.ts b/tests/core/node-catalog.test.ts index e6cc6a58..57e41f72 100644 --- a/tests/core/node-catalog.test.ts +++ b/tests/core/node-catalog.test.ts @@ -106,6 +106,8 @@ const physicsDouble = (): PhysicsDouble => { }; const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls; const crate = fakeEntity('crate'); +/** A tilemap of 4 x 2 cells of 10 units, placed at (100, 50). */ +const map = { ...fakeEntity('map', 100), y: 50, props: { columns: 4, rows: 2, tileSize: 10 } }; const pointer = (overrides: Partial): Partial => ({ pointer: { ...NO_POINTER, ...overrides }, @@ -1157,6 +1159,76 @@ const CASES: Record = { }, { name: 'no object is a no-op that continues', inputs: { object: null, seconds: 1 }, next: 'exec' }, ], + TileAt: [ + { + name: 'asks the context for the cell, floored to whole cells, and says what lies there (#135)', + inputs: { tilemap: map, layer: 'Ground', cellX: 2.7, cellY: 1 }, + context: { + tileAt: (target, layer, cell) => { + expect([target.id, layer, cell]).toEqual(['map', 'Ground', { x: 2, y: 1 }]); + return 'grass'; + }, + }, + outputs: { tile: 'grass', empty: false }, + }, + { + name: 'an empty cell, and one the context cannot answer, both read as empty', + inputs: { tilemap: map, layer: '', cellX: 0, cellY: 0 }, + context: { tileAt: () => null }, + outputs: { tile: '', empty: true }, + }, + { name: 'no tilemap is an empty cell', inputs: { tilemap: null }, outputs: { tile: '', empty: true } }, + ], + SetTile: [ + { + name: 'hands the cell and the tile to the context and says whether it took them', + inputs: { tilemap: map, layer: 'Ground', cellX: 3, cellY: 4.5, tile: 'door' }, + context: { + setTile: (target, layer, cell, tile) => { + expect([target.id, layer, cell, tile]).toEqual(['map', 'Ground', { x: 3, y: 4 }, 'door']); + return true; + }, + }, + next: 'exec', + outputs: { done: true }, + }, + { + name: 'no tilemap sets nothing and continues', + inputs: { tilemap: null }, + next: 'exec', + outputs: { done: false }, + }, + ], + WorldToCell: [ + { + name: 'finds the cell under a world point of a moved map, and whether it is on the grid', + // 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60). + inputs: { tilemap: map, x: 95, y: 55 }, + context: { isTilemap: () => true }, + outputs: { cellX: 1, cellY: 1, inside: true }, + }, + { + name: 'a point off the grid still names a cell, but not an inside one', + inputs: { tilemap: map, x: 75, y: 45 }, + context: { isTilemap: () => true }, + outputs: { cellX: -1, cellY: 0, inside: false }, + }, + { + name: 'something that is no tilemap has no cells', + inputs: { tilemap: map, x: 95, y: 55 }, + outputs: { cellX: 0, cellY: 0, inside: false }, + }, + ], + CellToWorld: [ + { + name: 'puts out the middle of the cell in world units', + inputs: { tilemap: map, cellX: 1, cellY: 1 }, + context: { isTilemap: () => true }, + outputs: { x: 95, y: 55 }, + }, + { name: 'no tilemap answers the origin', inputs: { tilemap: null, cellX: 1 }, outputs: { x: 0, y: 0 } }, + ], + Tile: [{ name: 'hands out the tile chosen', params: { value: 'door' }, outputs: { value: 'door' } }], Spawn: [ { name: 'no asset chosen logs a hint and continues with no object', diff --git a/tests/core/physics-system.test.ts b/tests/core/physics-system.test.ts index 1c8b3c04..6552953c 100644 --- a/tests/core/physics-system.test.ts +++ b/tests/core/physics-system.test.ts @@ -177,6 +177,27 @@ describe('falling and resting', () => { expect(entity(box).y).toBeCloseTo(192 - 16, 0); }); + it('lands on a floor set during the run and falls on once it is taken away (#135)', () => { + const f = physicsScene(); + const solid = new CreateAssetCommand('tile', 'Wall'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + f.stack.execute(solid); + f.stack.execute(map); + f.stack.execute(new SetAssetPropertyCommand(solid.asset.id, TILE_SOLID_KEY, true)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); + const ground = place(f, map.asset.id, { x: 0, y: 0 }); + const box = f.add(0, 0, { w: 32, body: {} }); + const { seconds, entity, world } = run(f); + for (let x = 0; x < 10; x++) world.setTile(entity(ground), '', { x, y: 9 }, solid.asset.id); + seconds(3); + expect(entity(box).y).toBeCloseTo(192 - 16, 0); + for (let x = 0; x < 10; x++) world.setTile(entity(ground), '', { x, y: 9 }, ''); + seconds(1); + expect(entity(box).y).toBeGreaterThan(300); + expect((findAsset(f.project, map.asset.id) as TilemapAsset).layers[0]!.cells).toEqual({}); + }); + it('a static, a switched-off or a weightless rigidbody stays where it is, and so does all in a scene without gravity', () => { const f = physicsScene(); const still = f.add(-150, 0, { w: 32, body: { bodyType: 'static' } }); diff --git a/tests/core/tile-access.test.ts b/tests/core/tile-access.test.ts new file mode 100644 index 00000000..d9f98755 --- /dev/null +++ b/tests/core/tile-access.test.ts @@ -0,0 +1,233 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; +import { AddColliderCommand } from '../../src/core/commands/colliderCommands'; +import { + AddTilemapLayerCommand, + PaintTilesCommand, + RenameTilemapLayerCommand, +} from '../../src/core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../src/core/model/Asset'; +import { + cellToWorld, + layerNamed, + liveLayers, + liveTileAt, + withTile, + worldToCell, +} from '../../src/core/model/liveTilemap'; +import { transformMatrix, translation } from '../../src/core/model/matrix'; +import { findAsset } from '../../src/core/model/Project'; +import { TILE_KIND_KEY, TILE_SOLID_KEY } from '../../src/core/model/tile'; +import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, tilemapLayerNames } from '../../src/core/model/tilemap'; +import { loadPhysics } from '../../src/runtime/physics/engine'; +import { collectHolderItems, collectWorldItems } from '../../src/viewport/items'; +import { COLLISION_SYSTEMS, makeSpriteScene, place, startWorld } from '../helpers'; + +const layer = (id: string, name: string, cells: Record = {}) => ({ id, name, cells }); +const asset = (): TilemapAsset => ({ + id: 'map', + type: 'tilemap', + name: 'Map', + props: {}, + layers: [layer('l1', 'Ground', { '0,0': 'grass', '1,0': 'grass' }), layer('l2', 'Top'), layer('l3', 'Top')], +}); +const GRID = { [TILEMAP_COLUMNS_KEY]: 4, [TILEMAP_ROWS_KEY]: 2, tileSize: 10 }; + +describe('the cells a run sees (#135)', () => { + it('a layer is found by its name, no name is the first, two of one name are the upper one', () => { + const map = asset(); + expect(layerNamed(map, 'Ground')?.id).toBe('l1'); + expect(layerNamed(map, '')?.id).toBe('l1'); + expect(layerNamed(map, 'Top')?.id).toBe('l2'); + expect(layerNamed(map, 'ground')).toBeUndefined(); + expect(layerNamed({ ...map, layers: [] }, '')).toBeUndefined(); + }); + + it('edits lie over the cells, an empty one erasing, and leave the asset alone', () => { + const map = asset(); + let edits = withTile(map, undefined, GRID, 'Ground', { x: 2, y: 1 }, 'water')!; + edits = withTile(map, edits, GRID, '', { x: 0, y: 0 }, '')!; + expect(edits).toEqual({ l1: { '2,1': 'water', '0,0': '' } }); + expect(liveLayers(map, edits)[0]!.cells).toEqual({ '1,0': 'grass', '2,1': 'water' }); + // The other layers are the asset's own, untouched. + expect(liveLayers(map, edits)[1]).toBe(map.layers[1]); + expect(map.layers[0]!.cells).toEqual({ '0,0': 'grass', '1,0': 'grass' }); + expect(liveTileAt(map, edits, GRID, 'Ground', { x: 2, y: 1 })).toBe('water'); + expect(liveTileAt(map, edits, GRID, 'Ground', { x: 0, y: 0 })).toBe(''); + expect(liveTileAt(map, edits, GRID, 'Ground', { x: 1, y: 0 })).toBe('grass'); + }); + + it('a cell off the grid or on a missing layer cannot be read or set; setting what is there changes nothing', () => { + const map = asset(); + for (const cell of [ + { x: 4, y: 0 }, + { x: 0, y: 2 }, + { x: -1, y: 0 }, + ]) { + expect(liveTileAt(map, undefined, GRID, '', cell)).toBeNull(); + expect(withTile(map, undefined, GRID, '', cell, 'water')).toBeNull(); + } + expect(withTile(map, undefined, GRID, 'Sky', { x: 0, y: 0 }, 'water')).toBeNull(); + const edits = withTile(map, undefined, GRID, '', { x: 3, y: 1 }, 'water')!; + expect(withTile(map, edits, GRID, '', { x: 3, y: 1 }, 'water')).toBe(edits); + }); + + it('offers the layer names of every tilemap, each once', () => { + const f = makeSpriteScene('Hero'); + const a = new CreateAssetCommand('tilemap', 'A'); + const b = new CreateAssetCommand('tilemap', 'B'); + f.stack.execute(a); + f.stack.execute(b); + const add = new AddTilemapLayerCommand(b.asset.id); + f.stack.execute(add); + f.stack.execute(new RenameTilemapLayerCommand(b.asset.id, add.layerId, 'Walls')); + expect(tilemapLayerNames(f.project)).toEqual(['Layer 1', 'Walls']); + }); +}); + +describe('world and cell (#135)', () => { + it('a map moved by (100, 50): cells from the top-left corner, the middle of each cell back', () => { + const m = translation(100, 50); + // 4 x 2 cells of 10 around (100, 50): the grid runs from (80, 40) to (120, 60). + expect(worldToCell(GRID, m, { x: 80, y: 40 })).toEqual({ x: 0, y: 0, inside: true }); + expect(worldToCell(GRID, m, { x: 119.9, y: 59.9 })).toEqual({ x: 3, y: 1, inside: true }); + expect(worldToCell(GRID, m, { x: 120, y: 50 })).toEqual({ x: 4, y: 1, inside: false }); + expect(worldToCell(GRID, m, { x: 79, y: 39 })).toEqual({ x: -1, y: -1, inside: false }); + expect(cellToWorld(GRID, m, { x: 0, y: 0 })).toEqual({ x: 85, y: 45 }); + expect(cellToWorld(GRID, m, { x: 3, y: 1 })).toEqual({ x: 115, y: 55 }); + }); + + it('a turned, scaled and mirrored map: every cell middle comes back to its cell', () => { + for (const m of [ + transformMatrix(30, -20, 90, 2, false, false), + transformMatrix(-7, 3, 33, 0.5, true, false), + transformMatrix(0, 0, 180, 3, false, true), + ]) + for (let y = 0; y < 2; y++) + for (let x = 0; x < 4; x++) + expect(worldToCell(GRID, m, cellToWorld(GRID, m, { x, y }))).toEqual({ x, y, inside: true }); + }); + + it('turned a quarter clockwise and doubled, the top-left cell lies to the right of the origin, at the top', () => { + const m = transformMatrix(0, 0, 90, 2, false, false); + // Its middle (-15, -5) in the map's frame turns to (5, -15) and doubles to (10, -30). + const middle = cellToWorld(GRID, m, { x: 0, y: 0 }); + expect(middle.x).toBeCloseTo(10); + expect(middle.y).toBeCloseTo(-30); + expect(worldToCell(GRID, m, { x: 10, y: -30 })).toEqual({ x: 0, y: 0, inside: true }); + // Scaled to nothing, there are no cells to fall into. + expect(worldToCell(GRID, transformMatrix(0, 0, 0, 0, false, false), { x: 0, y: 0 }).inside).toBe(false); + }); +}); + +/** A 10 x 10 map of cells of 48 around the origin, placed twice, and a hero with a collider on it. */ +function setup() { + const f = makeSpriteScene('Hero'); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'width', 40)); + f.stack.execute(new SetAssetPropertyCommand(f.spriteId, 'height', 40)); + const wall = new CreateAssetCommand('tile', 'Wall'); + const map = new CreateAssetCommand('tilemap', 'Ground'); + for (const command of [wall, map]) f.stack.execute(command); + f.stack.execute(new SetAssetPropertyCommand(wall.asset.id, TILE_SOLID_KEY, true)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_COLUMNS_KEY, 10)); + f.stack.execute(new SetAssetPropertyCommand(map.asset.id, TILEMAP_ROWS_KEY, 10)); + const first = place(f, map.asset.id, { x: 0, y: 0 }); + const second = place(f, map.asset.id, { x: 2000, y: 0 }); + const hero = place(f, f.spriteId, { x: 0, y: 0 }); + f.stack.execute(new AddColliderCommand(f.sceneId, hero)); + const assetOf = () => findAsset(f.project, map.asset.id) as TilemapAsset; + return { ...f, wallId: wall.asset.id, mapId: map.asset.id, first, second, hero, assetOf }; +} + +/** Cell column 6: its left face lies at x = 48, 28 to the right of the hero's edge. */ +const COLUMN_SIX = Array.from({ length: 10 }, (_, y) => ({ x: 6, y })); + +describe.each(COLLISION_SYSTEMS)('$system: a tile set in a run (#135)', ({ physics }) => { + beforeAll(async () => { + await loadPhysics(); + }); + + it('blocks at once, only on its own placement, and never reaches the asset', () => { + const s = setup(); + const { world, collisions } = startWorld(s, { physics, collisions: true }); + const map = world.findByObjectId(s.first)!; + const other = world.findByObjectId(s.second)!; + const hero = world.findByObjectId(s.hero)!; + for (const cell of COLUMN_SIX) expect(world.setTile(map, '', cell, s.wallId)).toBe(true); + // No tick in between: the move already meets the new wall. + expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(true); + expect(hero.x).toBeCloseTo(28, 9); + expect(world.tileAt(map, 'Layer 1', { x: 6, y: 3 })).toBe(s.wallId); + expect(world.tileAt(other, 'Layer 1', { x: 6, y: 3 })).toBe(''); + expect(s.assetOf().layers[0]!.cells).toEqual({}); + // Emptied again, the way is free. + for (const cell of COLUMN_SIX) world.setTile(map, '', cell, ''); + expect(collisions.moveAndSlide(world, hero, 97.3, 0).blockedX).toBe(false); + }); + + it('refuses what it cannot set: no tile, no tilemap, no such layer, off the grid', () => { + const s = setup(); + const { world } = startWorld(s, { physics, collisions: true }); + const map = world.findByObjectId(s.first)!; + const hero = world.findByObjectId(s.hero)!; + expect(world.setTile(map, '', { x: 0, y: 0 }, s.spriteId)).toBe(false); + expect(world.setTile(hero, '', { x: 0, y: 0 }, s.wallId)).toBe(false); + expect(world.setTile(map, 'Sky', { x: 0, y: 0 }, s.wallId)).toBe(false); + expect(world.setTile(map, '', { x: 10, y: 0 }, s.wallId)).toBe(false); + expect(world.tileAt(hero, '', { x: 0, y: 0 })).toBeNull(); + expect(world.isTilemap(map) && !world.isTilemap(hero)).toBe(true); + expect(world.takeTileChange()).toBe(false); + world.setTile(map, '', { x: 0, y: 0 }, s.wallId); + expect(world.takeTileChange()).toBe(true); + expect(world.takeTileChange()).toBe(false); + }); +}); + +describe('a tile set in a run is drawn (#135)', () => { + const cellsDrawn = (tilemap: { layers: { tiles: { x: number; y: number }[] }[] } | null) => + tilemap?.layers[0]!.tiles.map(({ x, y }) => `${x},${y}`); + + it('in the played world, on its own placement only', () => { + const s = setup(); + s.stack.execute( + new PaintTilesCommand(s.mapId, s.assetOf().layers[0]!.id, [{ x: 1, y: 1, tileId: s.wallId }]), + ); + const { world } = startWorld(s); + world.setTile(world.findByObjectId(s.first)!, '', { x: 2, y: 1 }, s.wallId); + world.setTile(world.findByObjectId(s.first)!, '', { x: 1, y: 1 }, ''); + const items = collectWorldItems(world).items; + expect(cellsDrawn(items.find((i) => i.id === s.first)!.tilemap)).toEqual(['2,1']); + expect(cellsDrawn(items.find((i) => i.id === s.second)!.tilemap)).toEqual(['1,1']); + }); + + it('the autotile edges of the cells around it follow the live cells', () => { + const s = setup(); + s.stack.execute(new SetAssetPropertyCommand(s.wallId, TILE_KIND_KEY, 'floor')); + const { world } = startWorld(s); + const map = world.findByObjectId(s.first)!; + const quarters = () => + collectWorldItems(world) + .items.find((i) => i.id === s.first)! + .tilemap!.layers[0]!.tiles.find((t) => t.x === 2 && t.y === 1)!.quadrants; + world.setTile(map, '', { x: 2, y: 1 }, s.wallId); + const alone = quarters(); + world.setTile(map, '', { x: 3, y: 1 }, s.wallId); + // A neighbour to the east turns the two right quarters from an edge into a joint. + expect(quarters()).not.toEqual(alone); + expect([quarters()[0], quarters()[2]]).toEqual([alone[0], alone[2]]); + world.setTile(map, '', { x: 3, y: 1 }, ''); + expect(quarters()).toEqual(alone); + }); + + it('in the editor run over the scene it runs on, and nowhere else', () => { + const s = setup(); + const { world } = startWorld(s); + world.setTile(world.findByObjectId(s.first)!, '', { x: 4, y: 4 }, s.wallId); + const drawn = (run: typeof world | null) => + cellsDrawn( + collectHolderItems(s.project, s.sceneOf(), 0, run).items.find((i) => i.id === s.first)!.tilemap, + ); + expect(drawn(world)).toEqual(['4,4']); + expect(drawn(null)).toEqual([]); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 13881a9b..22f1c465 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -231,6 +231,9 @@ export function fakeContext(overrides: Partial = {}): FakeContext { raycast: () => null, raycastAll: () => [], ignoreOneWay: () => {}, + isTilemap: () => false, + tileAt: () => null, + setTile: () => false, moveAndSlide: (target, dx, dy) => { target.x += dx; target.y += dy; diff --git a/tools/e2e/40-tilemap.mjs b/tools/e2e/40-tilemap.mjs index bf3c79f0..004dbaa5 100644 --- a/tools/e2e/40-tilemap.mjs +++ b/tools/e2e/40-tilemap.mjs @@ -1,3 +1,4 @@ +import { edge, node } from '../lib/builders.mjs'; import { colourDistance } from '../lib/harness.mjs'; import { LANES, MAKE_OBJECT, openTab, pickAssetType } from './lib/scaffold.mjs'; import { quadColour } from './39-tile.mjs'; @@ -61,6 +62,40 @@ async function edgeButton(t, label, glyph) { return null; } +/** + * Every 0.2 s: puts a still tile into cell 2,1 of its owner, a placed tilemap, logs what `Tile at` + * reads there, then turns the cell's middle into world units and back and logs the cell it lands in. + * A timer rather than On start, so the cell changes after the views have drawn the map once. + */ +const tileGraph = { + nodes: [ + node('tl-start', 'OnTimer', 0, 0, { interval: 0.2 }), + node('tl-owner', 'Owner', 0, 200), + node('tl-set', 'SetTile', 300, 0, { cellX: 2, cellY: 1, tile: 'tl-still' }), + node('tl-at', 'TileAt', 300, 350, { cellX: 2, cellY: 1 }), + node('tl-log-tile', 'Log', 650, 0), + node('tl-middle', 'CellToWorld', 300, 550, { cellX: 2, cellY: 1 }), + node('tl-cell', 'WorldToCell', 650, 550), + node('tl-log-x', 'Log', 950, 0), + node('tl-log-y', 'Log', 1250, 0), + ], + edges: [ + edge('tl-e1', 'tl-start', 'exec', 'tl-set', 'exec'), + edge('tl-e2', 'tl-owner', 'object', 'tl-set', 'tilemap'), + edge('tl-e3', 'tl-owner', 'object', 'tl-at', 'tilemap'), + edge('tl-e4', 'tl-owner', 'object', 'tl-middle', 'tilemap'), + edge('tl-e5', 'tl-owner', 'object', 'tl-cell', 'tilemap'), + edge('tl-e6', 'tl-set', 'exec', 'tl-log-tile', 'exec'), + edge('tl-e7', 'tl-at', 'tile', 'tl-log-tile', 'message'), + edge('tl-e8', 'tl-middle', 'x', 'tl-cell', 'x'), + edge('tl-e9', 'tl-middle', 'y', 'tl-cell', 'y'), + edge('tl-e10', 'tl-log-tile', 'exec', 'tl-log-x', 'exec'), + edge('tl-e11', 'tl-cell', 'cellX', 'tl-log-x', 'message'), + edge('tl-e12', 'tl-log-x', 'exec', 'tl-log-y', 'exec'), + edge('tl-e13', 'tl-cell', 'cellY', 'tl-log-y', 'message'), + ], +}; + /** Whether a box lies inside another, give or take a pixel of rounding. */ function inside(inner, outer) { return ( @@ -1040,6 +1075,118 @@ export async function tilemap(t) { scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`); await settle(); + // 40s. A behavior reads and sets cells while the game runs (#135). One map asset is placed twice; + // the behavior sits on the left placement and, on start, puts a tile into cell 2,1, reads it + // back and finds the cell again from its middle in world units. Only that placement shows + // it, and after Stop the asset holds what was painted and nothing more. + await mutate(` + const scene = __gm.state.activeScene; + const assets = __gm.state.project.assets; + // A tile of one picture: an animated one redraws the view every frame and would hide whether a + // changed cell asks for a redraw by itself. + const still = structuredClone(assets.find((a) => a.id === 'at-grass')); + Object.assign(still, { id: 'tl-still', name: 'Still', pictures: ['at-floor-1'] }); + assets.push(still); + const map = structuredClone(assets.find((a) => a.id === ${JSON.stringify(made.id)})); + Object.assign(map, { id: 'tl-map', name: 'Live map', props: { ...map.props, columns: 4, rows: 2, tileSize: 48 }, + layers: [{ id: 'tl-bridge', name: 'Bridge', cells: { '0,0': 'tl-still' } }] }); + assets.push(map); + const logic = structuredClone(assets.find((a) => a.id === 'mover')); + Object.assign(logic, { id: 'tl-behavior', name: 'Tile logic', props: { enabled: true, runInEditor: false }, + variables: [], graph: ${JSON.stringify(tileGraph)} }); + assets.push(logic); + const object = (id, assetId, parentId, x, name) => ({ id, assetId, name, position: { x, y: 0, z: 0 }, + editorOffset: { x: 0, y: 0 }, overrides: {}, parentId, instanceEdits: {} }); + scene.objects.push(object('tl-a', 'tl-map', '', -150, 'Live left'), object('tl-b', 'tl-map', '', 150, 'Live right'), + object('tl-logic', 'tl-behavior', 'tl-a', 0, 'Tiler')); + __gm.state.select(null)`); + await select({ kind: 'node', assetId: 'tl-behavior', nodeId: 'tl-set' }); + await settle(); + const layerField = await json("__gm.inspectorField('layer', true)"); + if (layerField) await click(layerField.x, layerField.y); + const layerChoices = await json('__gm.overlayTexts()'); + if (layerChoices.includes('Bridge')) { + const choice = await t.locate('Bridge', true); + await click(choice.x, choice.y); + } + await settle(); + const setParams = await json( + "__gm.state.project.assets.find((a) => a.id === 'tl-behavior').graph.nodes.find((n) => n.id === 'tl-set').params", + ); + check( + 'the layer of Set tile is picked by name from the layers of the project’s tilemaps', + Boolean(layerField) && + layerChoices.includes('First layer') && + layerChoices.includes('Bridge') && + setParams.layer === 'Bridge', + JSON.stringify({ layerField, layerChoices, setParams }), + ); + + await evaluate('__gm.state.select(null)'); + await openTab(t, 'scene'); + const tileLogFrom = t.consoleLog.length; + await t.play(); + await t.sleep(600); + const leftInGame = await json("__gm.tilemap.gameInfo('tl-a')"); + const rightInGame = await json("__gm.tilemap.gameInfo('tl-b')"); + await t.stop(); + await settle(); + const tilerSaid = t.consoleLog + .slice(tileLogFrom) + .filter((line) => line.startsWith('[behavior Tiler]')) + .map((line) => line.split('] ').pop()); + check( + 'Set tile puts the tile down, Tile at reads it back, and World to cell finds the cell from its middle', + tilerSaid.length >= 3 && + tilerSaid.length % 3 === 0 && + tilerSaid.every((line, i) => line === ['tl-still', '2', '1'][i % 3]), + JSON.stringify(tilerSaid), + ); + check( + 'the running game draws the new tile on that placement only', + leftInGame?.tiles === 2 && rightInGame?.tiles === 1, + JSON.stringify({ leftInGame, rightInGame }), + ); + const liveCells = () => json("__gm.state.project.assets.find((a) => a.id === 'tl-map').layers[0].cells"); + const afterPlay = await liveCells(); + check( + 'after Stop the map asset holds what was painted, not what the game set', + JSON.stringify(afterPlay) === JSON.stringify({ '0,0': 'tl-still' }), + JSON.stringify(afterPlay), + ); + + // The editor run changes the same cell, and the scene view shows it while the run lasts. + await mutate( + "__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = true", + ); + await mutate('__gm.lookAt(0, 0)'); + await t.sleep(500); + const leftRunning = await json("__gm.tilemap.info('tl-a')"); + const rightRunning = await json("__gm.tilemap.info('tl-b')"); + await shot('40-tile-set-in-run'); + await mutate( + "__gm.state.activeScene.objects.find((o) => o.id === 'tl-logic').overrides.runInEditor = false", + ); + await settle(); + const leftStopped = await json("__gm.tilemap.info('tl-a')"); + const afterRun = await liveCells(); + check( + 'the editor run shows the set tile in the scene view, and ending it shows the painted map again', + (await json('__gm.editorRun()')).running === false && + leftRunning?.tiles === 2 && + rightRunning?.tiles === 1 && + leftStopped?.tiles === 1 && + JSON.stringify(afterRun) === JSON.stringify({ '0,0': 'tl-still' }), + JSON.stringify({ leftRunning, rightRunning, leftStopped, afterRun }), + ); + await mutate(` + const p = __gm.state.project; + const scene = __gm.state.activeScene; + scene.objects = scene.objects.filter((o) => !o.id.startsWith('tl-')); + p.assets = p.assets.filter((a) => !a.id.startsWith('tl-')); + __gm.state.select(null)`); + await settle(); + // Leave the editor as the rest of the suite expects to find it. await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })"); await evaluate('__gm.state.select(null)');