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
@@ -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;
|
||||
}
|
||||
+41
-2
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<Entity, TilemapBlocks>();
|
||||
private blocks = new Map<Entity, { edits: TileEdits | undefined; blocks: TilemapBlocks }>();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user