Tilemaps are a new placeable asset type that is painted with autotiles in the scene view, with layers, brush, eraser and rectangle, and is drawn at runtime and in the export (#128)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m51s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-18 06:41:47 +02:00
1 parent 0607221423
commit 33a7b1b63e
30 files changed
+2060 -20

No files matched your search

+3
View File
@@ -41,6 +41,9 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
return { ...base, type: 'animation', root: '', tracks: [] };
case 'autotile':
return { ...base, type: 'autotile', pictures: [] };
// A tilemap without a layer could not be painted on, so it starts with one.
case 'tilemap':
return { ...base, type: 'tilemap', layers: [{ id: newId(), name: 'Layer 1', cells: {} }] };
case 'statemachine':
return { ...base, type: 'statemachine', graph: createEmptyGraph() };
case 'shader':
+5
View File
@@ -14,6 +14,11 @@ export function nextAssetName(project: Project, base: string): string {
return taken.has(base) ? firstFree(taken, (i) => `${base} ${i}`, 2) : base;
}
/** "Layer 1", "Layer 2", … – the first numbered name none of `taken` holds. */
export function nextNumberedName(taken: readonly string[], base: string): string {
return firstFree(new Set(taken), (i) => `${base} ${i}`, 1);
}
/** "var1", "var2", … – the first variable name not yet used by the behavior. */
export function nextVariableName(asset: BehaviorAsset | VariablesAsset, base = 'var'): string {
return firstFree(new Set(asset.variables.map((v) => v.key)), (i) => `${base}${i}`, 1);
+22 -1
View File
@@ -1,7 +1,16 @@
/** Lookups that throw a `CommandError` when the target does not exist. */
import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
import type { Asset, AutotileAsset, BehaviorAsset, StateMachineAsset, VariablesAsset } from '../model/Asset';
import type {
Asset,
AutotileAsset,
BehaviorAsset,
StateMachineAsset,
TilemapAsset,
TilemapLayer,
VariablesAsset,
} from '../model/Asset';
import { findLayer } from '../model/tilemap';
import { findObject, type ObjectTree } from '../model/hierarchy';
import { isTemplateContent, objectRef, type ObjectRef } from '../model/instance';
import {
@@ -85,6 +94,18 @@ export function requireAutotile(project: Project, assetId: string): AutotileAsse
return asset;
}
export function requireTilemap(project: Project, assetId: string): TilemapAsset {
const asset = findAssetOf(project, assetId, 'tilemap');
if (!asset) throw new CommandError(`Tilemap ${assetId} not found`);
return asset;
}
export function requireTilemapLayer(project: Project, assetId: string, layerId: string): TilemapLayer {
const layer = findLayer(requireTilemap(project, assetId), layerId);
if (!layer) throw new CommandError(`Layer ${layerId} not found`);
return layer;
}
export function requireStateMachine(project: Project, assetId: string): StateMachineAsset {
const asset = findAssetOf(project, assetId, 'statemachine');
if (!asset) throw new CommandError(`State machine ${assetId} not found`);
+184
View File
@@ -0,0 +1,184 @@
/**
* Painting a tilemap and managing its layers (#128). One stroke is one command whatever it covers:
* the pointer decides where a stroke ends, so an undo has to take back the whole line and not the
* last cell of it. The grid size and the cell size are ordinary properties and go through
* `SetAssetPropertyCommand`.
*/
import { newId } from '../ids';
import type { TilemapLayer } from '../model/Asset';
import { cellKey } from '../model/tilemap';
import type { Project } from '../model/Project';
import { nextNumberedName } from './naming';
import { type Command, CommandError } from './Command';
import { requireTilemap, requireTilemapLayer } from './require';
/** One cell as a stroke wants it: `autotileId` empty erases. */
export interface CellEdit {
x: number;
y: number;
autotileId: string;
}
/**
* Sets a run of cells at once. What each one held before is remembered per cell, so an undo puts
* a stroke over another autotile back exactly – not merely empty.
*/
export class PaintTilesCommand implements Command {
readonly label: string;
private previous = new Map<string, string>();
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly edits: readonly CellEdit[],
label?: string,
) {
this.label = label ?? 'Paint tiles';
}
/** Nothing to do when every cell already holds what the stroke would write – no empty undo step. */
static changes(layer: TilemapLayer, edits: readonly CellEdit[]): boolean {
return edits.some((edit) => (layer.cells[cellKey(edit.x, edit.y)] ?? '') !== edit.autotileId);
}
execute(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
this.previous = new Map();
for (const edit of this.edits) {
const key = cellKey(edit.x, edit.y);
if (!this.previous.has(key)) this.previous.set(key, layer.cells[key] ?? '');
if (edit.autotileId) layer.cells[key] = edit.autotileId;
else delete layer.cells[key];
}
}
undo(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
for (const [key, value] of this.previous) {
if (value) layer.cells[key] = value;
else delete layer.cells[key];
}
}
}
export class AddTilemapLayerCommand implements Command {
readonly label = 'Add layer';
readonly layerId = newId();
constructor(private readonly assetId: string) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
const name = nextNumberedName(
asset.layers.map((layer) => layer.name),
'Layer',
);
asset.layers.push({ id: this.layerId, name, cells: {} });
}
undo(project: Project): void {
const asset = requireTilemap(project, this.assetId);
asset.layers = asset.layers.filter((layer) => layer.id !== this.layerId);
}
}
/** Takes a layer out with everything painted on it; the last layer stays, there must be one to paint on. */
export class RemoveTilemapLayerCommand implements Command {
readonly label = 'Remove layer';
private removed: TilemapLayer | null = null;
private index = -1;
constructor(
private readonly assetId: string,
private readonly layerId: string,
) {}
execute(project: Project): void {
const asset = requireTilemap(project, this.assetId);
if (asset.layers.length <= 1) throw new CommandError('A tilemap keeps at least one layer');
this.index = asset.layers.findIndex((layer) => layer.id === this.layerId);
const layer = asset.layers[this.index];
if (!layer) throw new CommandError(`Layer ${this.layerId} not found`);
this.removed = layer;
asset.layers.splice(this.index, 1);
}
undo(project: Project): void {
if (this.removed) requireTilemap(project, this.assetId).layers.splice(this.index, 0, this.removed);
}
}
/** Moves a layer in the drawing order; the last layer in the list is drawn on top. */
export class MoveTilemapLayerCommand implements Command {
readonly label = 'Reorder layers';
constructor(
private readonly assetId: string,
private readonly from: number,
private readonly to: number,
) {}
execute(project: Project): void {
this.move(project, this.from, this.to);
}
undo(project: Project): void {
this.move(project, this.to, this.from);
}
private move(project: Project, from: number, to: number): void {
const asset = requireTilemap(project, this.assetId);
const layer = asset.layers[from];
if (!layer || to < 0 || to >= asset.layers.length) throw new CommandError('No such layer');
asset.layers.splice(from, 1);
asset.layers.splice(to, 0, layer);
}
}
export class RenameTilemapLayerCommand implements Command {
readonly label = 'Rename layer';
private previous = '';
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly name: string,
) {}
execute(project: Project): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
this.previous = layer.name;
layer.name = this.name;
}
undo(project: Project): void {
requireTilemapLayer(project, this.assetId, this.layerId).name = this.previous;
}
}
/** A hidden layer keeps its cells and is left out of the drawing, in the editor and in the game alike. */
export class SetTilemapLayerHiddenCommand implements Command {
readonly label: string;
constructor(
private readonly assetId: string,
private readonly layerId: string,
private readonly hidden: boolean,
) {
this.label = hidden ? 'Hide layer' : 'Show layer';
}
execute(project: Project): void {
this.apply(project, this.hidden);
}
undo(project: Project): void {
this.apply(project, !this.hidden);
}
private apply(project: Project, hidden: boolean): void {
const layer = requireTilemapLayer(project, this.assetId, this.layerId);
if (hidden) layer.hidden = true;
else delete layer.hidden;
}
}
+23
View File
@@ -195,6 +195,28 @@ export interface AutotileAsset extends AssetBase {
pictures: string[];
}
/**
* One drawing layer of a tilemap: `cells` maps `"x,y"` to the id of the autotile lying there, and
* a layer nobody switched off is drawn. Which tile of that autotile a cell shows is worked out
* from its neighbours while drawing, never stored – see `model/tilemap.ts`.
*/
export interface TilemapLayer {
id: string;
name: string;
hidden?: boolean;
cells: Record<string, string>;
}
/**
* A grid painted with autotiles, placed in a scene as a sprite is. How wide the grid is and how
* big one cell is are properties, so the inspector edits them with undo like any other row; the
* layers are intrinsic data, as a texture's frames are (#128).
*/
export interface TilemapAsset extends AssetBase {
type: 'tilemap';
layers: TilemapLayer[];
}
/**
* The graph is the motion of one particle: every output of its `Output` node is a function of the
* particle's age and its own random number (see `core/particles/compile.ts`). A placed object is
@@ -217,6 +239,7 @@ export type Asset =
| TextureAsset
| AnimationAsset
| AutotileAsset
| TilemapAsset
| StateMachineAsset
| ShaderAsset
| FontAsset
+4 -1
View File
@@ -10,6 +10,7 @@ export const ASSET_TYPES = [
'texture',
'animation',
'autotile',
'tilemap',
'statemachine',
'shader',
'font',
@@ -31,6 +32,7 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
texture: 'Texture',
animation: 'Animation',
autotile: 'Autotile',
tilemap: 'Tilemap',
statemachine: 'State machine',
shader: 'Shader',
font: 'Font',
@@ -43,7 +45,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
* as a shader is worn through `shader` and a font through `font`. A state machine is not either:
* a behavior refers to it by id, and a Variables asset is declarations a graph reads by id. A
* sound is nowhere on screen at all – a graph plays it, so there is nothing to place. An autotile
* is a tile a tilemap draws with (#128), so it is placed no more directly than a texture is.
* is a tile a tilemap draws with, so it is placed no more directly than a texture is – what is
* placed is the tilemap.
*/
export type PlaceableAssetType = Exclude<
AssetType,
+202
View File
@@ -0,0 +1,202 @@
/**
* A tilemap is a grid of cells, and every cell names the autotile lying there. Which of that
* autotile's tiles a cell shows is *not* stored: it follows from the neighbours and is worked out
* while drawing (#128), so painting one cell fixes the edges of the ones around it by itself.
*
* Cells are sparse – a map from `"x,y"` to an autotile id – rather than one entry per grid square.
* A map is what painting, erasing and reading a neighbour all want, and changing the grid size then
* moves nothing: what falls outside is simply not drawn and comes back when the grid grows again.
*/
import type { AutotileAsset, TilemapLayer } from './Asset';
import {
type AutotileKind,
autotileKind,
type Neighbours,
type TileQuadrants,
tileQuadrants,
} from './autotile';
import type { PropertyBag } from './Properties';
import { findAssetOf, type Project } from './Project';
export const TILEMAP_COLUMNS_KEY = 'columns';
export const TILEMAP_ROWS_KEY = 'rows';
export const TILEMAP_TILE_KEY = 'tileSize';
/**
* The grid is read from a property bag rather than from the asset: a placed tilemap may override
* its size like any other row, and the footprint the scene view picks and frames by has nothing
* but the bag (see `properties/bounds.ts`).
*/
export function tilemapColumns(props: PropertyBag): number {
return wholeAbove(props[TILEMAP_COLUMNS_KEY], 20);
}
export function tilemapRows(props: PropertyBag): number {
return wholeAbove(props[TILEMAP_ROWS_KEY], 15);
}
/** Side of one cell in world units; the autotile's own pictures are scaled onto it. */
export function tilemapTileSize(props: PropertyBag): number {
const size = Number(props[TILEMAP_TILE_KEY]);
return Number.isFinite(size) && size > 0 ? size : 48;
}
function wholeAbove(value: unknown, fallback: number): number {
const count = Math.floor(Number(value));
return Number.isFinite(count) && count >= 1 ? count : fallback;
}
export function tilemapSize(props: PropertyBag): { w: number; h: number } {
const tile = tilemapTileSize(props);
return { w: tilemapColumns(props) * tile, h: tilemapRows(props) * tile };
}
export function cellKey(x: number, y: number): string {
return `${x},${y}`;
}
export function parseCellKey(key: string): { x: number; y: number } | null {
const [x, y] = key.split(',').map(Number);
return Number.isInteger(x) && Number.isInteger(y) ? { x: x as number, y: y as number } : null;
}
export function cellAt(layer: TilemapLayer, x: number, y: number): string {
return layer.cells[cellKey(x, y)] ?? '';
}
export function isLayerVisible(layer: TilemapLayer): boolean {
return layer.hidden !== true;
}
export function findLayer(asset: { layers: TilemapLayer[] }, layerId: string): TilemapLayer | undefined {
return asset.layers.find((layer) => layer.id === layerId);
}
export function inGrid(props: PropertyBag, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < tilemapColumns(props) && y < tilemapRows(props);
}
/**
* Which neighbours a cell is joined to. Only the *same* autotile joins: a different one counts as
* empty, so every autotile draws its own border against everything else – what the RPG-Maker sheets
* are drawn for. Outside the grid counts as empty too, so a map ends with a proper edge.
*/
export function neighboursOf(
props: PropertyBag,
layer: TilemapLayer,
x: number,
y: number,
autotileId: string,
): Neighbours {
const same = (dx: number, dy: number): boolean =>
inGrid(props, x + dx, y + dy) && cellAt(layer, x + dx, y + dy) === autotileId;
return {
n: same(0, -1),
e: same(1, 0),
s: same(0, 1),
w: same(-1, 0),
ne: same(1, -1),
se: same(1, 1),
sw: same(-1, 1),
nw: same(-1, -1),
};
}
/** One cell as it is drawn: where it sits, which autotile it is and which quarters build it. */
export interface TileDraw {
x: number;
y: number;
autotileId: string;
quadrants: TileQuadrants;
}
/**
* Every cell of one layer that is inside the grid and names an autotile that exists, in reading
* order so the result is the same list twice. Cells naming a deleted autotile are skipped rather
* than dropped from the model: an undo brings the autotile, and with it the drawing, back.
*/
export function layerTiles(project: Project, props: PropertyBag, layer: TilemapLayer): TileDraw[] {
const kinds = new Map<string, AutotileKind>();
const kindOf = (id: string): AutotileKind | null => {
const known = kinds.get(id);
if (known) return known;
const autotile: AutotileAsset | undefined = findAssetOf(project, id, 'autotile');
if (!autotile) return null;
const kind = autotileKind(autotile);
kinds.set(id, kind);
return kind;
};
const tiles: TileDraw[] = [];
for (const [key, autotileId] of Object.entries(layer.cells)) {
const at = parseCellKey(key);
if (!at || !autotileId || !inGrid(props, at.x, at.y)) continue;
const kind = kindOf(autotileId);
if (!kind) continue;
tiles.push({
x: at.x,
y: at.y,
autotileId,
quadrants: tileQuadrants(kind, neighboursOf(props, layer, at.x, at.y, autotileId)),
});
}
return tiles.sort((a, b) => a.y - b.y || a.x - b.x);
}
/** The autotiles a tilemap paints with, in the order they first appear – its palette in use. */
export function tilemapAutotileIds(asset: { layers: TilemapLayer[] }): string[] {
const ids: string[] = [];
for (const layer of asset.layers)
for (const id of Object.values(layer.cells)) if (id && !ids.includes(id)) ids.push(id);
return ids;
}
/**
* The cell a point in the tilemap's own frame falls into. The grid is centred on the object's
* position, as every footprint is, so the top-left corner lies half a map up and to the left.
*/
export function cellAtPoint(props: PropertyBag, x: number, y: number): { x: number; y: number } {
const tile = tilemapTileSize(props);
const { w, h } = tilemapSize(props);
return { x: Math.floor((x + w / 2) / tile), y: Math.floor((y + h / 2) / tile) };
}
/** Where the top-left corner of a cell sits in the tilemap's own frame, in world units. */
export function cellOrigin(props: PropertyBag, x: number, y: number): { x: number; y: number } {
const tile = tilemapTileSize(props);
const { w, h } = tilemapSize(props);
return { x: x * tile - w / 2, y: y * tile - h / 2 };
}
/** The cells of the rectangle between two corners, both included and clipped to the grid. */
export function cellsInRect(
props: PropertyBag,
a: { x: number; y: number },
b: { x: number; y: number },
): { x: number; y: number }[] {
const cells: { x: number; y: number }[] = [];
const x0 = Math.max(0, Math.min(a.x, b.x));
const x1 = Math.min(tilemapColumns(props) - 1, Math.max(a.x, b.x));
const y0 = Math.max(0, Math.min(a.y, b.y));
const y1 = Math.min(tilemapRows(props) - 1, Math.max(a.y, b.y));
for (let y = y0; y <= y1; y++) for (let x = x0; x <= x1; x++) cells.push({ x, y });
return cells;
}
/**
* The cells a stroke from one cell to another covers. A pointer jumps between events, so without
* this a fast drag would paint dots instead of a line.
*/
export function cellsAlong(
from: { x: number; y: number },
to: { x: number; y: number },
): { x: number; y: number }[] {
const steps = Math.max(Math.abs(to.x - from.x), Math.abs(to.y - from.y));
if (steps === 0) return [{ ...to }];
const cells: { x: number; y: number }[] = [];
for (let i = 1; i <= steps; i++)
cells.push({
x: Math.round(from.x + ((to.x - from.x) * i) / steps),
y: Math.round(from.y + ((to.y - from.y) * i) / steps),
});
return cells;
}
+10 -1
View File
@@ -6,7 +6,7 @@
import { isKindWith } from '../properties/kinds';
import { schemaForAsset } from '../properties/PropertySchema';
import { referencesAny, trackDef } from './animation';
import type { AnimationAsset, Asset, AutotileAsset } from './Asset';
import type { AnimationAsset, Asset, AutotileAsset, TilemapAsset } from './Asset';
import { overrideSites } from './instance';
import { type ObjectHolder, objectHolders, type Project } from './Project';
import type { SceneObject } from './SceneObject';
@@ -71,6 +71,15 @@ export function autotilesUsing(project: Project, ids: readonly string[]): Autoti
);
}
/** The tilemaps painted with any of these autotiles – intrinsic data, like an autotile's pictures. */
export function tilemapsUsing(project: Project, ids: readonly string[]): TilemapAsset[] {
const wanted = new Set(ids);
return project.assets.filter(
(a): a is TilemapAsset =>
a.type === 'tilemap' && a.layers.some((l) => Object.values(l.cells).some((id) => wanted.has(id))),
);
}
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
const wanted = new Set(ids);
+10
View File
@@ -21,6 +21,7 @@ import {
} from './kinds';
import { findAssetOf, type Project } from '../model/Project';
import { AUTOTILE_FPS_KEY, AUTOTILE_KIND_KEY, AUTOTILE_PLAYBACK_KEY } from '../model/autotile';
import { TILEMAP_COLUMNS_KEY, TILEMAP_ROWS_KEY, TILEMAP_TILE_KEY } from '../model/tilemap';
import { SLICE_SCALE_KEY } from '../model/texture';
import type { SceneObject } from '../model/SceneObject';
import { flatListOf, listItemOf } from './listItems';
@@ -140,6 +141,7 @@ const TRANSFORM_TYPES: ReadonlySet<AssetType> = new Set([
'collider',
'container',
'particles',
'tilemap',
]);
export function hasTransformProps(type: AssetType): boolean {
@@ -244,6 +246,14 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
],
},
],
// The layers and their cells are intrinsic data on the asset (see TilemapAsset); these three say
// how far the grid reaches and how big one cell is drawn. An autotile's own pictures are scaled
// onto that cell, so one tilemap can mix autotiles drawn at different resolutions.
tilemap: [
{ key: TILEMAP_COLUMNS_KEY, label: 'Columns', kind: 'number', default: 20, min: 1, max: 1000, step: 1 },
{ key: TILEMAP_ROWS_KEY, label: 'Rows', kind: 'number', default: 15, min: 1, max: 1000, step: 1 },
{ key: TILEMAP_TILE_KEY, label: 'Tile size', kind: 'number', default: 48, min: 1, step: 1 },
],
// Behaviors declare their own properties (variables) per asset too – see `schemaForAsset`.
behavior: [{ key: 'runInEditor', label: 'Run in editor', kind: 'boolean', default: false }],
// Every row is declared on the asset itself – see `schemaForBag`.
+5
View File
@@ -15,6 +15,7 @@ import {
translation,
} from '../model/matrix';
import { type PropertyBag, textOf, type Vec2 } from '../model/Properties';
import { tilemapSize } from '../model/tilemap';
import { cameraFrame } from './camera';
import { colliderSize } from './collider';
import { defaultsFor } from './PropertySchema';
@@ -64,6 +65,10 @@ export function footprintOf(type: AssetType, props: PropertyBag): { w: number; h
// framed is the source, which is a point with a handle around it.
case 'particles':
return { w: PARTICLES_SYMBOL, h: PARTICLES_SYMBOL };
// The whole grid, centred on the object's position like every other footprint – so its cells
// are the same squares for drawing, for picking and for the outline around it.
case 'tilemap':
return tilemapSize(props);
default:
return cameraFrame(props);
}
+31 -1
View File
@@ -19,7 +19,15 @@ import {
import { isParticleNodeDef } from '../particles/nodes';
import { isShaderNodeDef } from '../shader/nodes';
import { isKeyInterpolation, normalizeKeys } from '../model/animation';
import type { AnimationKey, AnimationTrack, Asset, StateMachineAsset, TextureFrame } from '../model/Asset';
import type {
AnimationKey,
AnimationTrack,
Asset,
StateMachineAsset,
TextureFrame,
TilemapLayer,
} from '../model/Asset';
import { parseCellKey } from '../model/tilemap';
import { isAssetType } from '../model/AssetTypes';
import { isFontFile } from '../model/font';
import { isSoundFile } from '../model/sound';
@@ -175,6 +183,8 @@ export function deserializeProject(raw: unknown, fallbackId = ''): Project {
(id): id is string => typeof id === 'string' && id !== '',
);
assets.push({ id, type, name, props, pictures });
} else if (type === 'tilemap') {
assets.push({ id, type, name, props, layers: normalizeLayers(a.layers) });
} else if (type === 'animation') {
// A root or row aiming at something gone is kept: it reads as missing and keeps its keys.
const root = typeof a.root === 'string' ? a.root : '';
@@ -493,6 +503,26 @@ function normalizeTracks(raw: unknown): AnimationTrack[] {
return tracks;
}
/**
* The layers of a tilemap. A cell whose key is not a pair of whole numbers, or which names nothing,
* is dropped; one naming a deleted autotile is kept, like an animation's dangling row, so an undo
* brings the drawing back. A doubled layer id is dropped as a doubled node is.
*/
function normalizeLayers(raw: unknown): TilemapLayer[] {
const layers: TilemapLayer[] = [];
for (const item of Array.isArray(raw) ? raw : []) {
const l = asRecord(item);
if (typeof l.id !== 'string' || !l.id || layers.some((o) => o.id === l.id)) continue;
const cells: Record<string, string> = {};
for (const [key, value] of Object.entries(asRecord(l.cells)))
if (parseCellKey(key) && typeof value === 'string' && value !== '') cells[key] = value;
const layer: TilemapLayer = { id: l.id, name: typeof l.name === 'string' ? l.name : 'Layer', cells };
if (l.hidden === true) layer.hidden = true;
layers.push(layer);
}
return layers;
}
function normalizeBag(raw: unknown, schema: PropertySchema, sparse: boolean): PropertyBag {
const r = asRecord(raw);
const bag: PropertyBag = {};