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
@@ -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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```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
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -179,6 +179,18 @@ export interface NodeContext {
|
|||||||
): RayOutcome[];
|
): RayOutcome[];
|
||||||
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
/** Lets `target` fall through one-way colliders and tiles for `seconds` (#155). */
|
||||||
ignoreOneWay(target: EntityHandle, seconds: number): void;
|
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. */
|
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||||
physics?: PhysicsControl;
|
physics?: PhysicsControl;
|
||||||
log(message: string): void;
|
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 { Spawn } from './action/Spawn';
|
||||||
import { StopGame } from './action/StopGame';
|
import { StopGame } from './action/StopGame';
|
||||||
import { StopSound } from './action/StopSound';
|
import { StopSound } from './action/StopSound';
|
||||||
|
import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes';
|
||||||
import { Abs } from './data/Abs';
|
import { Abs } from './data/Abs';
|
||||||
import { Add } from './data/Add';
|
import { Add } from './data/Add';
|
||||||
import { And } from './data/And';
|
import { And } from './data/And';
|
||||||
@@ -227,6 +228,11 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
|||||||
Raycast,
|
Raycast,
|
||||||
RaycastAll,
|
RaycastAll,
|
||||||
IgnoreOneWay,
|
IgnoreOneWay,
|
||||||
|
TileAt,
|
||||||
|
SetTile,
|
||||||
|
WorldToCell,
|
||||||
|
CellToWorld,
|
||||||
|
TileLiteral,
|
||||||
Spawn,
|
Spawn,
|
||||||
Destroy,
|
Destroy,
|
||||||
Emit,
|
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);
|
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. */
|
/** The tiles a tilemap paints with, in the order they first appear – its palette in use. */
|
||||||
export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
|
export function tilemapTileIds(asset: { layers: TilemapLayer[] }): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
|
|||||||
@@ -155,6 +155,8 @@ export const KINDS = [
|
|||||||
{ kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf },
|
{ kind: 'layer', label: 'Layer', travels: 'number', empty: 0, coerce: numberOf },
|
||||||
/** A set of collision layers as the 16 bits of a mask (#152). */
|
/** A set of collision layers as the 16 bits of a mask (#152). */
|
||||||
{ kind: 'layers', label: 'Layers', travels: 'number', empty: 0, coerce: numberOf },
|
{ 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[];
|
] as const satisfies readonly KindRow[];
|
||||||
|
|
||||||
type Row = (typeof KINDS)[number];
|
type Row = (typeof KINDS)[number];
|
||||||
|
|||||||
@@ -69,7 +69,9 @@ export class EditorRun {
|
|||||||
this.commit(write);
|
this.commit(write);
|
||||||
// A shared value belongs to no object, so a frame that only moved one writes nothing here and
|
// 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.
|
// 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. */
|
/** Which state each instance of a machine holds in this run; empty when no editor run is on. */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type Value,
|
type Value,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import { layerIndexOf, layerMaskOf, layerName, maskLabel } from '../../core/model/collisionLayers';
|
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 type { Project } from '../../core/model/Project';
|
||||||
import { isList, type PropertyValue } from '../../core/model/Properties';
|
import { isList, type PropertyValue } from '../../core/model/Properties';
|
||||||
import type { PropertyDef } from '../../core/properties/PropertySchema';
|
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 ?? [];
|
const layers = project?.collisionLayers ?? [];
|
||||||
if (def.kind === 'layer') return layerName(layers, layerIndexOf(value));
|
if (def.kind === 'layer') return layerName(layers, layerIndexOf(value));
|
||||||
if (def.kind === 'layers') return maskLabel(layerMaskOf(value), layers);
|
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);
|
if (def.kind === 'enum') return def.options.find((o) => o.value === value)?.label ?? formatValue(value);
|
||||||
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
return referenceLabel(def, project, asString(value)) ?? formatValue(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import {
|
|||||||
assetRefLabel,
|
assetRefLabel,
|
||||||
NO_ASSET_LABEL,
|
NO_ASSET_LABEL,
|
||||||
} from '../../core/properties/assetRef';
|
} 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 { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
||||||
import {
|
import {
|
||||||
emptyFieldValue,
|
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': {
|
case 'key': {
|
||||||
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
|
const button = new KeyCaptureButton(textOf(value), (key) => handlers.onChange(key));
|
||||||
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
|
return { widget: button, grow: true, setValue: (v) => button.setKey(textOf(v)) };
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export class GameViewport extends WorldViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private modelFrame(): Frame {
|
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. */
|
/** `camera`: object id of the camera the view is through, empty when none is switched on. */
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export class SceneViewport extends FreeViewport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected override collectFrame(): Frame {
|
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 {
|
protected override get baseColor(): number {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { ENTITY_HANDLE } from '../core/graph/NodeContext';
|
|||||||
import type { AnimationPlayer } from '../core/model/animation';
|
import type { AnimationPlayer } from '../core/model/animation';
|
||||||
import type { AssetType } from '../core/model/AssetTypes';
|
import type { AssetType } from '../core/model/AssetTypes';
|
||||||
import type { FrameOutline } from '../core/model/frameOutline';
|
import type { FrameOutline } from '../core/model/frameOutline';
|
||||||
|
import type { TileEdits } from '../core/model/liveTilemap';
|
||||||
import type { PropertyBag } from '../core/model/Properties';
|
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. */
|
/** 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.
|
* call, so a changed `frame` prop or a redrawn outline counts at once. Absent outside a world.
|
||||||
*/
|
*/
|
||||||
wornFrame?: () => FrameOutline | null;
|
wornFrame?: () => FrameOutline | null;
|
||||||
|
/** For a placed tilemap: the cells a behavior set during the run (#135), never written back. */
|
||||||
|
tileEdits?: TileEdits;
|
||||||
}
|
}
|
||||||
+41
-2
@@ -1,12 +1,14 @@
|
|||||||
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
import { ENTITY_HANDLE, isHandleEnabled, type PointerState, worldMatrixOf } from '../core/graph';
|
||||||
import { newId } from '../core/ids';
|
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 type { PropertyBag, Vec2 } from '../core/model/Properties';
|
||||||
import { isPlaceableType } from '../core/model/AssetTypes';
|
import { isPlaceableType } from '../core/model/AssetTypes';
|
||||||
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
import { bakeInstances, materializeInstance, objectRef, resolveObjects } from '../core/model/instance';
|
||||||
import { treeOrder } from '../core/model/hierarchy';
|
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 { 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 { createSceneObject, type SceneObject } from '../core/model/SceneObject';
|
||||||
import { footprintContains } from '../core/properties/bounds';
|
import { footprintContains } from '../core/properties/bounds';
|
||||||
import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider';
|
import { colliderFrame, colliderShape, shapeContains } from '../core/properties/collider';
|
||||||
@@ -89,6 +91,7 @@ export class World {
|
|||||||
private elapsed = 0;
|
private elapsed = 0;
|
||||||
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
|
/** Set when `spawn` added an object; reported (and cleared) by the next `syncToModel`. */
|
||||||
private structureChanged = false;
|
private structureChanged = false;
|
||||||
|
private tilesChanged = false;
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
readonly project: Project,
|
readonly project: Project,
|
||||||
@@ -160,6 +163,42 @@ export class World {
|
|||||||
return false;
|
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. */
|
/** 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 {
|
setSceneProp(key: string, value: PropertyBag[string]): void {
|
||||||
this.sceneProps[key] = value;
|
this.sceneProps[key] = value;
|
||||||
|
|||||||
@@ -707,6 +707,18 @@ export class BehaviorSystem implements System {
|
|||||||
const found = resolve(target);
|
const found = resolve(target);
|
||||||
if (found) world.ignoreOneWay(found, seconds);
|
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:
|
physics:
|
||||||
this.collisions instanceof PhysicsSystem
|
this.collisions instanceof PhysicsSystem
|
||||||
? physicsControl(world, this.collisions, resolve)
|
? physicsControl(world, this.collisions, resolve)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { applyVector, determinant, IDENTITY, invert, linearOf, type Mat } from '
|
|||||||
import type { Vec2 } from '../../core/model/Properties';
|
import type { Vec2 } from '../../core/model/Properties';
|
||||||
import { dot } from '../../core/util/vec';
|
import { dot } from '../../core/util/vec';
|
||||||
import { findAssetOf } from '../../core/model/Project';
|
import { findAssetOf } from '../../core/model/Project';
|
||||||
|
import { liveTilemap, type TileEdits } from '../../core/model/liveTilemap';
|
||||||
import {
|
import {
|
||||||
type CellPolygon,
|
type CellPolygon,
|
||||||
cellPolygonShape,
|
cellPolygonShape,
|
||||||
@@ -193,10 +194,10 @@ export interface Contact {
|
|||||||
export class Solids {
|
export class Solids {
|
||||||
/**
|
/**
|
||||||
* The blocking rectangles and outlined cells of each tilemap, in cells. Worked out once per tick –
|
* 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.
|
* shapes only when asked, since the map may move in between.
|
||||||
*/
|
*/
|
||||||
private blocks = new Map<Entity, TilemapBlocks>();
|
private blocks = new Map<Entity, { edits: TileEdits | undefined; blocks: TilemapBlocks }>();
|
||||||
|
|
||||||
/** Forgets the cells, so the next tick works them out afresh. */
|
/** Forgets the cells, so the next tick works them out afresh. */
|
||||||
reset(): void {
|
reset(): void {
|
||||||
@@ -204,9 +205,11 @@ export class Solids {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
|
private blocksOf(world: World, tilemap: Entity): TilemapBlocks {
|
||||||
let blocks = this.blocks.get(tilemap);
|
let held = this.blocks.get(tilemap);
|
||||||
if (!blocks) {
|
// A `Set tile` replaces the edits, so a move later in the same tick already meets the new cell.
|
||||||
const asset = findAssetOf(world.project, tilemap.assetId, 'tilemap');
|
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 { project } = world;
|
||||||
const { props } = tilemap;
|
const { props } = tilemap;
|
||||||
// One group per side and surface, so cells only merge with cells that block alike (#159, #193).
|
// 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),
|
rects: solidRects(project, props, asset!, angle ?? false, material),
|
||||||
polygons: solidPolygons(project, props, asset!, angle ?? false, material),
|
polygons: solidPolygons(project, props, asset!, angle ?? false, material),
|
||||||
}));
|
}));
|
||||||
blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : [];
|
const blocks = asset ? [...groups(null), ...oneWayAngles(project, asset).flatMap(groups)] : [];
|
||||||
this.blocks.set(tilemap, blocks);
|
held = { edits: tilemap.tileEdits, blocks };
|
||||||
|
this.blocks.set(tilemap, held);
|
||||||
}
|
}
|
||||||
return blocks;
|
return held.blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+23
-6
@@ -18,6 +18,7 @@ import { findAsset, findAssetOf, type ObjectHolder, type Project } from '../core
|
|||||||
import { fontFileOf } from '../core/model/font';
|
import { fontFileOf } from '../core/model/font';
|
||||||
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
import { resolveTexture, type TextureRef } from '../core/model/texture';
|
||||||
import type { FrameOutline } from '../core/model/frameOutline';
|
import type { FrameOutline } from '../core/model/frameOutline';
|
||||||
|
import { liveTilemap, type TileEdits } from '../core/model/liveTilemap';
|
||||||
import { colliderFrame } from '../core/properties/collider';
|
import { colliderFrame } from '../core/properties/collider';
|
||||||
import { enabledAlongChain } from '../core/properties/enabled';
|
import { enabledAlongChain } from '../core/properties/enabled';
|
||||||
import { fontOf } from '../core/properties/fonts';
|
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
|
* 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.
|
* 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 {
|
function resolveTilemap(
|
||||||
const asset = findAssetOf(project, assetId, 'tilemap');
|
project: Project,
|
||||||
if (!asset) return null;
|
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 layers: TilemapLayerDraw[] = [];
|
||||||
const used = new Map<string, TilemapTile>();
|
const used = new Map<string, TilemapTile>();
|
||||||
for (const layer of asset.layers) {
|
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
|
* 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
|
* 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(
|
export function collectHolderItems(
|
||||||
project: Project,
|
project: Project,
|
||||||
holder: ObjectHolder | undefined,
|
holder: ObjectHolder | undefined,
|
||||||
fallbackBackground: number,
|
fallbackBackground: number,
|
||||||
|
run: World | null = null,
|
||||||
): DrawList {
|
): DrawList {
|
||||||
if (!holder) return { items: [], background: fallbackBackground };
|
if (!holder) return { items: [], background: fallbackBackground };
|
||||||
const view = resolvedHolder(project, holder);
|
const view = resolvedHolder(project, holder);
|
||||||
@@ -169,7 +178,10 @@ export function collectHolderItems(
|
|||||||
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
|
texture: wearsPicture(asset.type) ? resolveTexture(project, props) : null,
|
||||||
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
|
font: asset.type === 'text' ? fontFileOf(project, fontOf(props)) : '',
|
||||||
particles: asset.type === 'particles' ? asset : null,
|
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,
|
emits: 0,
|
||||||
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
|
frame: asset.type === 'collider' ? colliderFrame(project, props) : null,
|
||||||
});
|
});
|
||||||
@@ -184,6 +196,11 @@ export function collectHolderItems(
|
|||||||
return { items, background: Number(holder.props.backgroundColor ?? fallbackBackground) };
|
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. */
|
/** The running world: scene content only, since symbols are editor-only. */
|
||||||
export function collectWorldItems(world: World): DrawList {
|
export function collectWorldItems(world: World): DrawList {
|
||||||
const items = world.entities
|
const items = world.entities
|
||||||
@@ -207,7 +224,7 @@ export function collectWorldItems(world: World): DrawList {
|
|||||||
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
|
texture: wearsPicture(e.type) ? resolveTexture(world.project, e.props) : null,
|
||||||
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
|
font: e.type === 'text' ? fontFileOf(world.project, fontOf(e.props)) : '',
|
||||||
particles: e.type === 'particles' ? (findAssetOf(world.project, e.assetId, 'particles') ?? null) : null,
|
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,
|
emits: e.emits ?? 0,
|
||||||
frame: e.wornFrame?.() ?? null,
|
frame: e.wornFrame?.() ?? null,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ const physicsDouble = (): PhysicsDouble => {
|
|||||||
};
|
};
|
||||||
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
|
const physicsCalls = (ctx: FakeContext) => (ctx.physics as PhysicsDouble).calls;
|
||||||
const crate = fakeEntity('crate');
|
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<NodeContext['pointer']>): Partial<NodeContext> => ({
|
const pointer = (overrides: Partial<NodeContext['pointer']>): Partial<NodeContext> => ({
|
||||||
pointer: { ...NO_POINTER, ...overrides },
|
pointer: { ...NO_POINTER, ...overrides },
|
||||||
@@ -1157,6 +1159,76 @@ const CASES: Record<string, Case[]> = {
|
|||||||
},
|
},
|
||||||
{ name: 'no object is a no-op that continues', inputs: { object: null, seconds: 1 }, next: 'exec' },
|
{ 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: [
|
Spawn: [
|
||||||
{
|
{
|
||||||
name: 'no asset chosen logs a hint and continues with no object',
|
name: 'no asset chosen logs a hint and continues with no object',
|
||||||
|
|||||||
@@ -177,6 +177,27 @@ describe('falling and resting', () => {
|
|||||||
expect(entity(box).y).toBeCloseTo(192 - 16, 0);
|
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', () => {
|
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 f = physicsScene();
|
||||||
const still = f.add(-150, 0, { w: 32, body: { bodyType: 'static' } });
|
const still = f.add(-150, 0, { w: 32, body: { bodyType: 'static' } });
|
||||||
|
|||||||
@@ -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<string, string> = {}) => ({ 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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -231,6 +231,9 @@ export function fakeContext(overrides: Partial<NodeContext> = {}): FakeContext {
|
|||||||
raycast: () => null,
|
raycast: () => null,
|
||||||
raycastAll: () => [],
|
raycastAll: () => [],
|
||||||
ignoreOneWay: () => {},
|
ignoreOneWay: () => {},
|
||||||
|
isTilemap: () => false,
|
||||||
|
tileAt: () => null,
|
||||||
|
setTile: () => false,
|
||||||
moveAndSlide: (target, dx, dy) => {
|
moveAndSlide: (target, dx, dy) => {
|
||||||
target.x += dx;
|
target.x += dx;
|
||||||
target.y += dy;
|
target.y += dy;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { edge, node } from '../lib/builders.mjs';
|
||||||
import { colourDistance } from '../lib/harness.mjs';
|
import { colourDistance } from '../lib/harness.mjs';
|
||||||
import { LANES, MAKE_OBJECT, openTab, pickAssetType } from './lib/scaffold.mjs';
|
import { LANES, MAKE_OBJECT, openTab, pickAssetType } from './lib/scaffold.mjs';
|
||||||
import { quadColour } from './39-tile.mjs';
|
import { quadColour } from './39-tile.mjs';
|
||||||
@@ -61,6 +62,40 @@ async function edgeButton(t, label, glyph) {
|
|||||||
return null;
|
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. */
|
/** Whether a box lies inside another, give or take a pixel of rounding. */
|
||||||
function inside(inner, outer) {
|
function inside(inner, outer) {
|
||||||
return (
|
return (
|
||||||
@@ -1040,6 +1075,118 @@ export async function tilemap(t) {
|
|||||||
scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`);
|
scene.objects.find((o) => o.id === ${JSON.stringify(MAP_OBJECT)}).position = ${JSON.stringify(mapBefore)};`);
|
||||||
await settle();
|
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.
|
// Leave the editor as the rest of the suite expects to find it.
|
||||||
await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })");
|
await evaluate("__gm.state.setTilemapPaint({ tool: 'select' })");
|
||||||
await evaluate('__gm.state.select(null)');
|
await evaluate('__gm.state.select(null)');
|
||||||
|
|||||||
Reference in new issue
Block a user