Let behaviors read and set tilemap cells while the game runs (#135)
A behavior could move sprites around but had no way to look at or
change the tilemap it was standing on. Doors, breakable blocks and
"is there ground ahead?" checks all need that.
- New nodes Tile at, Set tile, World to cell and Cell to world. The
conversion honours position, rotation, scale and mirroring of the
placed map. A small Tile literal node lets a graph compare what it
read against a fixed tile.
- The layer is picked by name from all tilemap layers in the project
through a new field kind. "First layer" takes the map's first one.
Because layer is an input, a text can also be wired in.
- Each placement keeps only its edited cells in liveTilemap.ts,
layered over the asset's cells. A second placement of the same map
stays untouched, and the asset itself is never written.
- A read sees a Set tile from earlier in the same flow. Drawing picks
up the edit at once and neighbouring autotile edges adapt.
- Solids see the edits straight away, so a Move in the same frame
already hits the new cell. Rapier rebuilds its shapes on the next
step.
- After Stop the map is back to what was painted. The editor run shows
the edit in the scene view while it lasts and does not touch the
project.
- An unwired tilemap input means no map, like Get position. Cells
outside the grid are refused and done reports false.
Tests:
- ./check.sh: 155 files, 2271 tests, CHECK EXIT 0 (before 2245).
- tests/core/tile-access.test.ts (14), one physics test for a floor
set at runtime, catalog entries for the five nodes.
- e2e 39,40: all passed, 86 checks (before 81), five new in 40s.
- Counter-checks: collision ignoring edits fails 3 unit tests, the
solids cache missing same-tick edits fails only the "at once" test,
drawing ignoring edits fails the 2 drawing checks, the editor run
not reporting a new tile fails only its check.
- 40s uses a still tile. The animated fixture tile redraws every frame
and hid the last counter-check at first.
- Check total 1351 + 5 = 1356, computed. CI confirmed 1351 at cb32b4b.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
cb32b4bc23
commit
d444b27fae
22 files changed
+848
-20
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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 ?? '') } }),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<string, Readonly<Record<string, string>>>>;
|
||||
|
||||
/** 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<string, string> = { ...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 });
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user