// --------------------------------------------------------------------------- // Engine data contract — the render-ready *MapExport* shape and its primitives. // // This is the seam between map authoring (the editor's `buildMapExport`, which // bakes the live stores into this shape) and map rendering (the engine's // `buildScene`, plus any consuming game). It is deliberately a dependency leaf: // it imports nothing, so a separate game project can copy `src/engine/` verbatim // and depend on this contract without dragging in React, the editor stores, the // lexicon, or atproto. // // A `MapExport` is self-contained: the offset-grid corner-Wang resolution and // the source tilesets are baked away into ONE per-map sprite sheet (the atlas) // plus a flat list of blits, object defs, and region nodes — all in `cell` px // (world scale), so a renderer can draw it with a single uniform zoom and no // auto-tiling logic of its own. See `lib/mapExport.ts` for how it is produced. // --------------------------------------------------------------------------- export const MAP_EXPORT_VERSION = 1 /** A point / size pair in cell px. */ export interface Vec2 { x: number y: number } /** A rectangle in cell px, top-left origin (y-down). x/y may be negative. */ export interface Rect { x: number y: number w: number h: number } /** A custom, game-defined name→value property. Lives on every node kind and is * the escape hatch that keeps the exported format game-agnostic. */ export interface Property { key: string value: string } /** Which world axis depth runs along (larger coordinate = nearer the viewer). * `y` is top-down (default); `x` sorts left-to-right. */ export type DepthAxis = 'y' | 'x' /** A canvas node's depth band (the whole canvas sorts as one unit). * • `dynamic` — joins the depth pool; interleaves with objects along the axis. * • `ground` — pinned behind all dynamic content. * • `sky` — pinned in front of all dynamic content. */ export type CanvasSort = 'dynamic' | 'ground' | 'sky' // --------------------------------------------------------------------------- // Background (the map's backdrop, drawn behind every node). Passed through the // export verbatim from the authoring runtime — kind-tagged (not the wire // `$type` form). A consuming renderer may draw it; the editor Sandbox does not. // --------------------------------------------------------------------------- /** How an image backdrop fills: `tile` repeats; the rest fit one copy. */ export type BackgroundFit = 'tile' | 'cover' | 'contain' | 'stretch' export interface ColorBackground { kind: 'color' color: string } export interface GradientStop { color: string /** percentage along the gradient line (0 = start, 100 = end). */ position: number } export interface GradientBackground { kind: 'gradient' /** gradient line angle in degrees: 0 = left→right, 90 = top→bottom. */ angle: number stops: GradientStop[] } export interface ImageBackground { kind: 'image' name: string /** image bytes as a data URL. */ dataUrl: string fit: BackgroundFit } /** A single tile of the map's tileset (by grid col/row), repeated to fill. */ export interface TileBackground { kind: 'tile' col: number row: number } export type MapBackground = | ColorBackground | GradientBackground | ImageBackground | TileBackground // --------------------------------------------------------------------------- // The baked atlas + the flat scene. // --------------------------------------------------------------------------- /** A region of the baked sheet, in baked-sheet px (already cell-scaled). */ export interface AtlasEntry { id: number x: number y: number w: number h: number } /** One blit: an atlas entry placed at a world-px position (size = entry size). */ export interface Sprite2D { atlas: number x: number y: number } /** An animated blit: one display tile that cycles through `frames` (atlas entry * ids) on a lockstep clock. Emitted for cells whose resolved Wang tile changes * as an animated material advances — the engine plays it as a sprite sequence * exactly like an animated object. */ export interface AnimSprite2D { /** atlas entry ids, frame 0 first. */ frames: number[] x: number y: number frameDurationMs: number } export interface CanvasExport { kind: 'canvas' id: string name: string /** the parent canvas's depth band. */ sort: CanvasSort /** for a `dynamic` band: its far-edge depth (cell px along axis). Retained for * provenance; `sortKey` is what the runtime sorts by. */ depth: number /** the node's concrete, pre-resolved sort index (cell px along the axis) — the * runtime sorts by this directly, no dynamic-depth derivation. Set for every * dynamic band (= its far-edge `depth`) and for composite members (the unit's * shared depth, plus a tiny per-member tiebreak). Absent for ground/sky, which * sort by their fixed band. */ sortKey?: number /** pre-resolved tile blits, in cell px. The engine draws these as-is. */ sprites: Sprite2D[] /** animated tile blits (cells painted with an animated material). Drawn the * same way as `sprites`, but cycling their frames. Empty for static layers. */ animSprites: AnimSprite2D[] } /** A self-contained object definition: carries its own collision + frame art so * the engine never loads the source tileset. `source` is a back-pointer for * editing only. All geometry is in cell px, relative to the art's top-left. */ export interface ObjectDefExport { id: string source: { tilesetRef: string; objectId: string } /** anchor offset from the art's top-left (cell px). */ origin: Vec2 /** art size of one frame (cell px). */ size: { w: number; h: number } /** collision boxes relative to the art's top-left (cell px). */ collision: Rect[] /** atlas entry ids, one per animation frame (frame 0 first). */ frames: number[] frameDurationMs: number /** custom game data; the animation play mode lives here under * `world.tilemap.animationMode` (absent = `'loop'`). */ properties: Property[] } export interface ObjectExport { kind: 'object' id: string name: string defId: string /** world position of the anchor (cell px). */ pos: Vec2 /** uniform scale about the anchor (1 = unchanged). */ scale: number /** clockwise rotation about the anchor, in quarter-turns (0..3). */ rotation: number flipX: boolean flipY: boolean /** when this object belongs to a composite group, the unit's shared depth * (cell px, plus a tiny per-member tiebreak) — overrides the per-object far * edge so the whole composite sorts as one. Absent otherwise. */ sortKey?: number /** custom game data; per-instance animation state and the * `town.deception.autoGate` auto-door flag live here. */ properties: Property[] } export interface CollisionExport { kind: 'collision' id: string name: string /** solid box in cell px. */ rect: Rect properties: Property[] } export interface AreaExport { kind: 'area' id: string name: string /** trigger boxes in cell px (behaviour comes from `properties`). */ rects: Rect[] properties: Property[] } export interface MarkerExport { kind: 'marker' id: string name: string /** anchored point in cell px (meaning comes from `properties`). */ x: number y: number properties: Property[] } export type ExportNode = CanvasExport | ObjectExport | CollisionExport | AreaExport | MarkerExport export interface MapExport { version: number /** canonical cell pixel size — the atlas bake size and the renderer's scale. */ cell: { w: number; h: number } /** source tilesets (provenance only; not needed to render). */ sources: string[] sheet: { /** PNG data URL of the baked atlas, or null when there's nothing to bake. */ dataUrl: string | null width: number height: number entries: AtlasEntry[] } objectDefs: ObjectDefExport[] depthAxis: DepthAxis /** the map's backdrop, drawn behind every node (null = none). Passed through * verbatim; a `tile` background resolves its col/row against `sources`. */ background: MapBackground | null /** painted world-px extent of all canvas sprites, or null when empty. */ bounds: Rect | null /** visible leaves only — groups are organisational and flattened out here. */ nodes: ExportNode[] } /** Project a world point onto the scene's depth axis to get its scalar depth. * Shared so static drawables and live entities sort together (larger = nearer). */ export function projectDepth(axis: DepthAxis, x: number, y: number): number { return axis === 'x' ? x : y }