feat(engine): Tiled P1 — rt_tmap model + GID resolver + render + projection (#69)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 20s
ci / build-and-test (push) Successful in 1m48s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 27s

The heart of Tiled support: load a map and draw it.

- rt_tmap model (Tmap/TmLayer/TmTileset in tiled.ludic): map header + ordered
  layers (dense int32 GID arrays, heap-allocated to w*h, lifting the 96x64 cap)
  + tilesets. Built from the intermediate Value tree, so the TMX and TMJ paths
  both feed it.
- GID resolver (Tiled.resolve): gid -> (tileset, localId, flipH/V/D); the three
  flip flags masked off before the local-id lookup, returned alongside. gid==0
  is empty.
- Image-backed render (Tiled.draw): every visible tile layer in file order,
  blitting each tile from its tileset image with flips applied at draw.
- Compatibility projection (Tiled.project / auto on load): a designated
  collision layer projects to the legacy byte tilemap ('#' solid, '=' one-way
  via the oneway property, ' ' empty) so Grid.*/Path.*/esys_move are unchanged.
- Tiled.load resolves external tilesets + images relative to the map file and
  auto-projects a collision/solids/walls layer.
- The grid and physics_tiles demos now run off a loaded map (grid_maze.tmx /
  physics_map.tmx) instead of hand-authored Map.row strings, byte-identically.

Two compiler fixes fell out of this (see the changeset):
- emit_index_addr set g_addr_ty before evaluating the index, so slice[obj.field]
  came back mis-typed; set it last, like the raw-pointer branches.
- @strcmp was declared by both the world table and the fs prelude; centralise
  it in the head prelude so a game that uses Fs/Path links.

Proven by library/tiled_p1.ludic (14 assertions: loads+renders Kenney map
identically from .tmx and .tmj, flip mirroring) + the converted grid/
physics_tiles demos. x test: 92 passed; self-host bootstrap fixpoint intact.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 14:43:29 +03:00
parent 071c268de7
commit bc301c8d17
29 changed files with 12932 additions and 9582 deletions

View file

@ -442,3 +442,349 @@ function tiled_read_tsx(path: pointer) -> Val {
if tiled_first_byte(text) == 60 { return tsx_to_value(xml_parse(text)) }
return json_parse(text)
}
# ============================================================================
# P1 (#69) — the runtime map model (`rt_tmap`), the GID resolver, the legacy
# `rt_map` compatibility projection, and image-backed rendering. Built from the
# intermediate Value tree above, so both the TMX and TMJ paths feed it.
# ============================================================================
property TmTileset {
firstgid: int = 0
columns: int = 0
tilew: int = 0
tileh: int = 0
spacing: int = 0
margin: int = 0
tilecount: int = 0
imgid: int = 0 - 1 # loaded image handle (rt_image_load), -1 = none
meta: Val # the tileset Value object (per-tile metadata)
}
property TmLayer {
kind: int = 0 # 0 tilelayer, 1 objectgroup, 2 imagelayer, 3 group
name: pointer = null
w: int = 0
h: int = 0
visible: int = 1
gids: []int # dense w*h GID array (tilelayer only)
data: Val # the layer Value object (objects, image, …)
}
property Tmap {
w: int = 0 # map width in tiles
h: int = 0
tilew: int = 0 # map tile size in pixels
tileh: int = 0
orientation: pointer = null
layers: []TmLayer
tilesets: []TmTileset
tree: Val # the source intermediate tree
coll: int = 0 - 1 # designated collision layer index, or -1
}
# ---- GID resolver ----------------------------------------------------------
property GidInfo {
tileset: int = 0 - 1 # index into Tmap.tilesets (-1 = none/empty)
local: int = 0 # local tile id within that tileset
fh: int = 0 # horizontal flip
fv: int = 0 # vertical flip
fd: int = 0 # anti-diagonal flip
empty: int = 0 # gid was 0
}
# decompose a raw GID: strip the three flip flags (0x80000000 H, 0x40000000 V,
# 0x20000000 D), keep the low 29 bits as the global tile id, and find the tileset
# whose firstgid is the greatest not exceeding it. gid == 0 is the empty cell.
function tmap_resolve(m: Tmap, gid: int) -> GidInfo {
let r = new GidInfo
if gid == 0 { r.empty = 1; r.tileset = 0 - 1; return r }
let H = 1 << 31
let V = 1 << 30
let D = 1 << 29
if (gid & H) != 0 { r.fh = 1 }
if (gid & V) != 0 { r.fv = 1 }
if (gid & D) != 0 { r.fd = 1 }
let id = gid & 536870911 # low 29 bits = global tile id
var best = 0 - 1
var bestfg = 0
var i = 0
while i < len(m.tilesets) {
let fg = m.tilesets[i].firstgid
if fg <= id and fg >= bestfg { bestfg = fg; best = i }
i = i + 1
}
r.tileset = best
if best >= 0 { r.local = id - m.tilesets[best].firstgid }
return r
}
# ---- accessors -------------------------------------------------------------
function tmap_width(m: Tmap) -> int { return m.w }
function tmap_height(m: Tmap) -> int { return m.h }
function tmap_layer_count(m: Tmap) -> int { return len(m.layers) }
function tmap_layer_name(m: Tmap, i: int) -> pointer {
if i < 0 or i >= len(m.layers) { return "" }
if m.layers[i].name == null { return "" }
return m.layers[i].name
}
function tmap_tree(m: Tmap) -> Val { return m.tree }
# raw GID at (x,y) in tile layer `layer` (0 out of bounds / non-tile layer).
function tmap_gid(m: Tmap, layer: int, x: int, y: int) -> int {
if layer < 0 or layer >= len(m.layers) { return 0 }
let l = m.layers[layer]
if l.kind != 0 { return 0 }
if x < 0 or y < 0 or x >= l.w or y >= l.h { return 0 }
return l.gids[y * l.w + x]
}
# ---- per-tile metadata (property convention) -------------------------------
# does the tile a GID resolves to carry bool custom property `name` = true?
function tmap_tile_prop(m: Tmap, gid: int, name: pointer) -> int {
let r = tmap_resolve(m, gid)
if r.tileset < 0 { return 0 }
let ts = m.tilesets[r.tileset]
if value_kind(ts.meta) != 6 { return 0 }
let tiles = value_get(ts.meta, "tiles")
if value_kind(tiles) != 5 { return 0 }
var i = 0
while i < value_count(tiles) {
let t = value_at(tiles, i)
if value_as_int(value_get(t, "id")) == r.local {
let props = value_get(t, "properties")
if value_kind(props) == 5 {
var j = 0
while j < value_count(props) {
let p = value_at(props, j)
if value_as_str(value_get(p, "name")) == name {
if value_as_str(value_get(p, "value")) == "true" { return 1 }
return 0
}
j = j + 1
}
}
return 0
}
i = i + 1
}
return 0
}
# ---- legacy rt_map compatibility projection --------------------------------
# project the designated collision layer down to the byte tilemap so Grid.* /
# Path.* / esys_move keep working unchanged: an empty cell is ' ' (passable), a
# tile whose GID carries the `oneway` property is '=' (61), any other non-zero
# GID is '#' (35, solid). The legacy 96x64 cap clips, so old code is byte-exact.
function tmap_project(m: Tmap, layer: int) -> void {
m.coll = layer
rt_map_size(m.w, m.h) # clamps to 96x64, clears to ' '
if layer < 0 or layer >= len(m.layers) { return }
let l = m.layers[layer]
if l.kind != 0 { return }
var y = 0
while y < l.h {
if y < 64 {
var x = 0
while x < l.w {
if x < 96 {
let gid = l.gids[y * l.w + x]
if gid != 0 {
var ch = 35 # '#'
if tmap_tile_prop(m, gid, "oneway") == 1 { ch = 61 } # '='
rt_map[y * 96 + x] = ch
}
}
x = x + 1
}
}
y = y + 1
}
}
# find a tile layer named collision/solids/walls (case-sensitive), or -1.
function tmap_find_collision(m: Tmap) -> int {
var i = 0
while i < len(m.layers) {
let n = m.layers[i].name
if m.layers[i].kind == 0 and (n == "collision" or n == "solids" or n == "walls" or n == "Collision" or n == "Solids") { return i }
i = i + 1
}
return 0 - 1
}
# ---- rendering -------------------------------------------------------------
# blit one tilew x tileh tile from a tileset image (source origin sx,sy) to the
# framebuffer at (dx,dy), applying the three flip flags. Square tiles assumed
# for the diagonal flip (Kenney art is 16x16), which is the orthogonal case.
function tmap_blit_tile(imgid: int, sx: int, sy: int, tw: int, th: int, dx: int, dy: int, fh: int, fv: int, fd: int) -> void {
if imgid < 0 { return }
let s: words = img_px[imgid]
let iw = img_w[imgid]
var j = 0
while j < th {
var i = 0
while i < tw {
var u = i
var v = j
if fd == 1 { u = j; v = i }
if fh == 1 { u = tw - 1 - u }
if fv == 1 { v = th - 1 - v }
let argb = s[(sy + v) * iw + (sx + u)]
rt_blend_px(dx + i, dy + j, argb)
i = i + 1
}
j = j + 1
}
}
# draw one tile GID at map cell (x,y) with the camera offset already applied.
function tmap_draw_gid(m: Tmap, gid: int, dx: int, dy: int) -> void {
if gid == 0 { return }
let r = tmap_resolve(m, gid)
if r.tileset < 0 { return }
let ts = m.tilesets[r.tileset]
if ts.imgid < 0 { return }
let cols = ts.columns
if cols <= 0 { return }
let cx = r.local - (r.local / cols) * cols
let cy = r.local / cols
let sx = ts.margin + cx * (ts.tilew + ts.spacing)
let sy = ts.margin + cy * (ts.tileh + ts.spacing)
# Tiled anchors a tile by its bottom-left, so a tile taller than the map cell
# rises above the cell.
let ddy = dy - (ts.tileh - m.tileh)
tmap_blit_tile(ts.imgid, sx, sy, ts.tilew, ts.tileh, dx, ddy, r.fh, r.fv, r.fd)
}
# draw every visible tile layer in file order, offset by the camera (camx,camy).
function tmap_draw(m: Tmap, camx: int, camy: int) -> void {
var li = 0
while li < len(m.layers) {
let l = m.layers[li]
if l.kind == 0 and l.visible != 0 {
var y = 0
while y < l.h {
var x = 0
while x < l.w {
let gid = l.gids[y * l.w + x]
if gid != 0 {
tmap_draw_gid(m, gid, x * m.tilew - camx, y * m.tileh - camy)
}
x = x + 1
}
y = y + 1
}
}
li = li + 1
}
}
# ---- build the model from the intermediate Value tree ----------------------
function tmap_gids_from_layer(lv: Val) -> []int {
let out = new []int
let data = value_get(lv, "data")
var i = 0
while i < value_count(data) { push(out, value_as_int(value_at(data, i))); i = i + 1 }
return out
}
# fill a TmTileset's geometry from a tileset Value object (its per-tile metadata
# stays in `.meta` for the resolver / property convention).
function tmap_tileset_from_value(tv: Val) -> TmTileset {
let ts = new TmTileset
ts.firstgid = value_as_int(value_get(tv, "firstgid"))
ts.tilew = value_as_int(value_get(tv, "tilewidth"))
ts.tileh = value_as_int(value_get(tv, "tileheight"))
ts.spacing = value_as_int(value_get(tv, "spacing"))
ts.margin = value_as_int(value_get(tv, "margin"))
ts.tilecount = value_as_int(value_get(tv, "tilecount"))
ts.columns = value_as_int(value_get(tv, "columns"))
if ts.columns <= 0 and ts.tilew > 0 { # derive columns from the image
let iw = value_as_int(value_get(tv, "imagewidth"))
if iw > 0 { ts.columns = (iw - 2 * ts.margin + ts.spacing) / (ts.tilew + ts.spacing) }
}
ts.meta = tv
return ts
}
function tiled_join(a: pointer, b: pointer) -> pointer { return Path.normalize(Path.join(a, b)) }
# build the runtime map from an intermediate tree, resolving external tilesets
# and loading tileset images relative to `basedir`.
function tmap_build(tree: Val, basedir: pointer) -> Tmap {
let m = new Tmap
m.tree = tree
m.layers = new []TmLayer
m.tilesets = new []TmTileset
m.orientation = value_as_str(value_get(tree, "orientation"))
m.w = value_as_int(value_get(tree, "width"))
m.h = value_as_int(value_get(tree, "height"))
m.tilew = value_as_int(value_get(tree, "tilewidth"))
m.tileh = value_as_int(value_get(tree, "tileheight"))
# tilesets
let tss = value_get(tree, "tilesets")
var i = 0
while i < value_count(tss) {
var tv = value_at(tss, i)
var imgdir = basedir
let src = value_as_str(value_get(tv, "source"))
if src != "" { # external .tsx/.tsj
let tsxpath = tiled_join(basedir, src)
let ext = tiled_read_tsx(tsxpath)
value_put(ext, "firstgid", value_get(tv, "firstgid"))
tv = ext
imgdir = Path.dir(tsxpath)
}
let ts = tmap_tileset_from_value(tv)
let img = value_as_str(value_get(tv, "image"))
if img != "" { ts.imgid = rt_image_load(tiled_join(imgdir, img)) }
push(m.tilesets, ts)
i = i + 1
}
# layers (flat; groups' children are lifted so tile layers render/resolve)
let ls = value_get(tree, "layers")
i = 0
while i < value_count(ls) { tmap_add_layer(m, value_at(ls, i)); i = i + 1 }
return m
}
function tmap_add_layer(m: Tmap, lv: Val) -> void {
let ty = value_as_str(value_get(lv, "type"))
let l = new TmLayer
l.data = lv
l.name = value_as_str(value_get(lv, "name"))
var vis = 1
if value_has(lv, "visible") == 1 { if value_as_int(value_get(lv, "visible")) == 0 { vis = 0 } }
l.visible = vis
if ty == "tilelayer" {
l.kind = 0
l.w = value_as_int(value_get(lv, "width"))
l.h = value_as_int(value_get(lv, "height"))
l.gids = tmap_gids_from_layer(lv)
push(m.layers, l)
return
}
if ty == "objectgroup" { l.kind = 1; push(m.layers, l); return }
if ty == "imagelayer" { l.kind = 2; push(m.layers, l); return }
if ty == "group" {
l.kind = 3; push(m.layers, l)
let sub = value_get(lv, "layers")
var i = 0
while i < value_count(sub) { tmap_add_layer(m, value_at(sub, i)); i = i + 1 }
return
}
push(m.layers, l)
}
# ---- top-level load --------------------------------------------------------
# load a Tiled map file into the runtime model: read + parse (TMX or TMJ), build
# the model (resolving external tilesets + images), and project the collision
# layer (a tile layer named collision/solids/walls) down to the legacy tilemap.
function tiled_load(path: pointer) -> Tmap {
let tree = tiled_read(path)
let m = tmap_build(tree, Path.dir(path))
let c = tmap_find_collision(m)
if c >= 0 { tmap_project(m, c) }
return m
}