ludic/runtime/native/tiled.ludic
Orkuncakilkaya 58e1105f2d
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Successful in 1m53s
commit-lint / conventional-commits (push) Successful in 3s
docs / build-and-deploy (push) Successful in 28s
feat(engine): Tiled P3 — animated tiles (deterministic frame clock) + tile objects (#71)
- Animated tiles: a tileset <animation>'s {tileid, duration} frames advance as a
  pure function of the fixed 60/s engine frame counter (the same clock SpriteAnim
  rides), so an animated GID resolves at draw to the current frame's GID with its
  flip flags preserved — deterministic, frame-identical across runs, ticks for
  free. Tiled.frame_gid(map, gid, frame) / Tiled.animated(map, gid) expose it;
  Tiled.draw_anim(map, camx, camy, frame) draws a map with animations advanced.
- Tile objects: object-layer entries with a `gid` draw the tile image (with their
  own flip flags), bottom-anchored at the object position, as placeable sprites;
  tmap_draw_full now renders object layers alongside tile layers.

Proven by library/tiled_p3.ludic (14 assertions): frame advance at authored
durations + wrap, flip flags riding an animation swap, tile-object parse + draw +
flip mirroring, and the design's named rpg/beach_tileset.tsx (33 <animation>
blocks / 131 frames). x test: 94 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-01 14:58:21 +03:00

918 lines
37 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================================
# tiled.ludic — Tiled map support (`Tiled.*`), the design record of #66
# (issues #67–#74). Native TMX/TSX/TX (XML) and TMJ/TSJ/TJ (JSON) both map onto
# ONE intermediate — a generic `Value.*` tree in Tiled's JSON schema — and one
# format-independent core turns that into the runtime map model. So the two
# readers below (P0.5, #68) each feed the same `rt_tmap` builder (P1, #69).
#
# The JSON path is `Json.parse` + a normalisation pass; the XML path is the
# `xml_parse` walk in this file. Layer data is decoded to a dense GID int list at
# read time (CSV split, or base64 -> zlib/gzip inflate -> little-endian u32s), so
# a CSV `.tmx` and a base64 `.tmj` of the same map yield structurally identical
# trees.
#
# ludicc splices this file (with core + xml + base64 + value) when a program
# mentions `Tiled.*` (parse.ludic).
# ============================================================================
# ---- layer-data decode -----------------------------------------------------
# a CSV of GIDs -> a Value list of int nodes. Non-digit separators (commas,
# whitespace, newlines) delimit; overflow wraps to the correct 32-bit GID
# (a flip-flagged GID like 0x80000001 lands as the matching negative i32).
function tiled_csv_list(text: pointer) -> Val {
let out = value_list()
let n = len(text)
var i = 0
var cur = 0
var have = 0
while i < n {
let c = text[i]
if c >= 48 and c <= 57 { cur = cur * 10 + (c - 48); have = 1 }
else {
if c == 44 or c == 32 or c == 9 or c == 10 or c == 13 {
if have == 1 { push(out.kids, value_int(cur)); cur = 0; have = 0 }
}
}
i = i + 1
}
if have == 1 { push(out.kids, value_int(cur)) }
return out
}
# a base64 (optionally zlib/gzip-compressed) blob of `count` little-endian u32
# GIDs -> a Value list of int nodes.
function tiled_b64_list(text: pointer, compression: pointer, count: int) -> Val {
let comp = bytes(len(text) + 4)
let clen = b64_decode(text, comp)
let outcap = count * 4 + 16
var raw = comp
var rawlen = clen
if compression == "zlib" {
let d = bytes(outcap); let dn = z_uncompress(comp, clen, d, outcap); raw = d; rawlen = dn
} else { if compression == "gzip" {
let d = bytes(outcap); let dn = z_gunzip(comp, clen, d, outcap); raw = d; rawlen = dn
} }
let out = value_list()
var i = 0
while i + 3 < rawlen {
let g = raw[i] | (raw[i + 1] << 8) | (raw[i + 2] << 16) | (raw[i + 3] << 24)
push(out.kids, value_int(g))
i = i + 4
}
return out
}
# decode a `<data>` element (child of a `<layer>`) into a Value list of GIDs.
function tiled_data_list(data: Xml, count: int) -> Val {
let enc = xml_attr(data, "encoding")
let comp = xml_attr(data, "compression")
if enc == "base64" { return tiled_b64_list(xml_text(data), comp, count) }
# csv (or the legacy tag-per-tile form we don't emit) -> split the text
return tiled_csv_list(xml_text(data))
}
# ---- custom properties -----------------------------------------------------
# `<properties>` -> a Value list of {name, type, value} objects (P4 reads these;
# P2 reads the `solid`/`oneway`/`trigger` bool convention off the same list).
function tmx_props_list(parent: Xml) -> Val {
let out = value_list()
let props = xml_find(parent, "properties")
if xml_tag(props) != "properties" { return out }
var i = 0
while i < xml_child_count(props) {
let p = xml_child(props, i)
if xml_tag(p) == "property" {
let o = value_object()
value_put(o, "name", value_str(xml_attr(p, "name")))
var ty = xml_attr(p, "type")
if ty == "" { ty = "string" }
value_put(o, "type", value_str(ty))
# value may be an attribute or (for multiline) the element text
var v = xml_attr(p, "value")
if xml_has(p, "value") == 0 { v = xml_text(p) }
value_put(o, "value", value_str(v))
push(out.kids, o)
}
i = i + 1
}
return out
}
# ---- objects (shapes) ------------------------------------------------------
# one `<object>` -> a Value object mirroring Tiled's JSON object shape.
function tmx_object_to_value(ob: Xml) -> Val {
let o = value_object()
value_put(o, "id", value_int(xml_attr_int(ob, "id", 0)))
if xml_has(ob, "name") == 1 { value_put(o, "name", value_str(xml_attr(ob, "name"))) }
if xml_has(ob, "type") == 1 { value_put(o, "type", value_str(xml_attr(ob, "type"))) }
if xml_has(ob, "class") == 1 { value_put(o, "type", value_str(xml_attr(ob, "class"))) }
value_put(o, "x", value_int(xml_attr_int(ob, "x", 0)))
value_put(o, "y", value_int(xml_attr_int(ob, "y", 0)))
value_put(o, "width", value_int(xml_attr_int(ob, "width", 0)))
value_put(o, "height", value_int(xml_attr_int(ob, "height", 0)))
if xml_has(ob, "gid") == 1 { value_put(o, "gid", value_int(xml_attr_int(ob, "gid", 0))) }
if xml_has(ob, "rotation") == 1 { value_put(o, "rotation", value_int(xml_attr_int(ob, "rotation", 0))) }
# shape markers: ellipse / point / polygon / polyline / text
let el = xml_find(ob, "ellipse"); if xml_tag(el) == "ellipse" { value_put(o, "ellipse", value_bool(1)) }
let pt = xml_find(ob, "point"); if xml_tag(pt) == "point" { value_put(o, "point", value_bool(1)) }
let pg = xml_find(ob, "polygon")
if xml_tag(pg) == "polygon" { value_put(o, "polygon", tmx_points_list(xml_attr(pg, "points"))) }
let pl = xml_find(ob, "polyline")
if xml_tag(pl) == "polyline" { value_put(o, "polyline", tmx_points_list(xml_attr(pl, "points"))) }
let tx = xml_find(ob, "text")
if xml_tag(tx) == "text" {
let t = value_object()
value_put(t, "text", value_str(xml_text(tx)))
if xml_has(tx, "pixelsize") == 1 { value_put(t, "pixelsize", value_int(xml_attr_int(tx, "pixelsize", 16))) }
if xml_has(tx, "bold") == 1 { value_put(t, "bold", value_bool(1)) }
if xml_has(tx, "italic") == 1 { value_put(t, "italic", value_bool(1)) }
if xml_has(tx, "halign") == 1 { value_put(t, "halign", value_str(xml_attr(tx, "halign"))) }
if xml_has(tx, "valign") == 1 { value_put(t, "valign", value_str(xml_attr(tx, "valign"))) }
value_put(o, "text", t)
}
let props = tmx_props_list(ob)
if len(props.kids) > 0 { value_put(o, "properties", props) }
return o
}
# "x,y x,y ..." -> a Value list of {x,y} objects.
function tmx_points_list(s: pointer) -> Val {
let out = value_list()
let n = len(s)
var i = 0
while i < n {
while i < n and (s[i] == 32 or s[i] == 9) { i = i + 1 }
if i >= n { break }
# read "x,y"
var xv = 0; var xn = 0; var xs = 0
if s[i] == 45 { xs = 1; i = i + 1 }
while i < n and s[i] >= 48 and s[i] <= 57 { xv = xv * 10 + (s[i] - 48); xn = 1; i = i + 1 }
if xs == 1 { xv = 0 - xv }
if i < n and s[i] == 44 { i = i + 1 }
var yv = 0; var ys = 0
if i < n and s[i] == 45 { ys = 1; i = i + 1 }
while i < n and s[i] >= 48 and s[i] <= 57 { yv = yv * 10 + (s[i] - 48); i = i + 1 }
if ys == 1 { yv = 0 - yv }
if xn == 1 {
let o = value_object()
value_put(o, "x", value_int(xv))
value_put(o, "y", value_int(yv))
push(out.kids, o)
}
}
return out
}
# ---- tilesets --------------------------------------------------------------
# one `<tile id=..>` inside a tileset -> a Value object with its metadata:
# animation frames, per-tile collision objectgroup, class/type, properties.
function tmx_tile_to_value(t: Xml) -> Val {
let o = value_object()
value_put(o, "id", value_int(xml_attr_int(t, "id", 0)))
if xml_has(t, "type") == 1 { value_put(o, "type", value_str(xml_attr(t, "type"))) }
if xml_has(t, "class") == 1 { value_put(o, "type", value_str(xml_attr(t, "class"))) }
if xml_has(t, "probability") == 1 { value_put(o, "probability", value_str(xml_attr(t, "probability"))) }
# per-tile image (image-collection tilesets)
let img = xml_find(t, "image")
if xml_tag(img) == "image" {
value_put(o, "image", value_str(xml_attr(img, "source")))
value_put(o, "imagewidth", value_int(xml_attr_int(img, "width", 0)))
value_put(o, "imageheight", value_int(xml_attr_int(img, "height", 0)))
}
# animation frames
let anim = xml_find(t, "animation")
if xml_tag(anim) == "animation" {
let frames = value_list()
var i = 0
while i < xml_child_count(anim) {
let fr = xml_child(anim, i)
if xml_tag(fr) == "frame" {
let f = value_object()
value_put(f, "tileid", value_int(xml_attr_int(fr, "tileid", 0)))
value_put(f, "duration", value_int(xml_attr_int(fr, "duration", 0)))
push(frames.kids, f)
}
i = i + 1
}
value_put(o, "animation", frames)
}
# per-tile collision shapes
let og = xml_find(t, "objectgroup")
if xml_tag(og) == "objectgroup" {
let objs = value_list()
var j = 0
while j < xml_child_count(og) {
let ch = xml_child(og, j)
if xml_tag(ch) == "object" { push(objs.kids, tmx_object_to_value(ch)) }
j = j + 1
}
let ogo = value_object()
value_put(ogo, "objects", objs)
value_put(o, "objectgroup", ogo)
}
let props = tmx_props_list(t)
if len(props.kids) > 0 { value_put(o, "properties", props) }
return o
}
# fill the geometry + tiles of a `<tileset>` element into `o` (shared by an
# embedded tileset and a standalone `.tsx` root).
function tmx_fill_tileset(o: Val, ts: Xml) -> void {
if xml_has(ts, "name") == 1 { value_put(o, "name", value_str(xml_attr(ts, "name"))) }
value_put(o, "tilewidth", value_int(xml_attr_int(ts, "tilewidth", 0)))
value_put(o, "tileheight", value_int(xml_attr_int(ts, "tileheight", 0)))
value_put(o, "spacing", value_int(xml_attr_int(ts, "spacing", 0)))
value_put(o, "margin", value_int(xml_attr_int(ts, "margin", 0)))
value_put(o, "columns", value_int(xml_attr_int(ts, "columns", 0)))
value_put(o, "tilecount", value_int(xml_attr_int(ts, "tilecount", 0)))
let img = xml_find(ts, "image")
if xml_tag(img) == "image" {
value_put(o, "image", value_str(xml_attr(img, "source")))
value_put(o, "imagewidth", value_int(xml_attr_int(img, "width", 0)))
value_put(o, "imageheight", value_int(xml_attr_int(img, "height", 0)))
}
let tiles = value_list()
var i = 0
while i < xml_child_count(ts) {
let ch = xml_child(ts, i)
if xml_tag(ch) == "tile" { push(tiles.kids, tmx_tile_to_value(ch)) }
i = i + 1
}
if len(tiles.kids) > 0 { value_put(o, "tiles", tiles) }
}
# a `<tileset>` child of a `<map>` -> a Value object. External (`source=`) keeps
# the {firstgid, source} reference (as TMJ does); embedded is inlined in full.
function tmx_tileset_to_value(ts: Xml) -> Val {
let o = value_object()
value_put(o, "firstgid", value_int(xml_attr_int(ts, "firstgid", 1)))
if xml_has(ts, "source") == 1 {
value_put(o, "source", value_str(xml_attr(ts, "source")))
return o
}
tmx_fill_tileset(o, ts)
return o
}
# a standalone `.tsx` root -> a tileset Value object (no firstgid — that comes
# from the map's reference).
function tsx_to_value(root: Xml) -> Val {
let o = value_object()
tmx_fill_tileset(o, root)
return o
}
# ---- layers ----------------------------------------------------------------
function tmx_layer_common(o: Val, el: Xml) -> void {
value_put(o, "id", value_int(xml_attr_int(el, "id", 0)))
value_put(o, "name", value_str(xml_attr(el, "name")))
if xml_has(el, "class") == 1 { value_put(o, "class", value_str(xml_attr(el, "class"))) }
var vis = 1
if xml_has(el, "visible") == 1 { vis = xml_attr_int(el, "visible", 1) }
value_put(o, "visible", value_bool(vis))
if xml_has(el, "opacity") == 1 { value_put(o, "opacity", value_str(xml_attr(el, "opacity"))) }
if xml_has(el, "offsetx") == 1 { value_put(o, "offsetx", value_int(xml_attr_int(el, "offsetx", 0))) }
if xml_has(el, "offsety") == 1 { value_put(o, "offsety", value_int(xml_attr_int(el, "offsety", 0))) }
if xml_has(el, "parallaxx") == 1 { value_put(o, "parallaxx", value_str(xml_attr(el, "parallaxx"))) }
if xml_has(el, "parallaxy") == 1 { value_put(o, "parallaxy", value_str(xml_attr(el, "parallaxy"))) }
if xml_has(el, "tintcolor") == 1 { value_put(o, "tintcolor", value_str(xml_attr(el, "tintcolor"))) }
let props = tmx_props_list(el)
if len(props.kids) > 0 { value_put(o, "properties", props) }
}
function tmx_tilelayer_to_value(el: Xml, mapw: int, maph: int) -> Val {
let o = value_object()
value_put(o, "type", value_str("tilelayer"))
tmx_layer_common(o, el)
let w = xml_attr_int(el, "width", mapw)
let h = xml_attr_int(el, "height", maph)
value_put(o, "width", value_int(w))
value_put(o, "height", value_int(h))
let data = xml_find(el, "data")
value_put(o, "data", tiled_data_list(data, w * h))
return o
}
function tmx_objectlayer_to_value(el: Xml) -> Val {
let o = value_object()
value_put(o, "type", value_str("objectgroup"))
tmx_layer_common(o, el)
let objs = value_list()
var i = 0
while i < xml_child_count(el) {
let ch = xml_child(el, i)
if xml_tag(ch) == "object" { push(objs.kids, tmx_object_to_value(ch)) }
i = i + 1
}
value_put(o, "objects", objs)
return o
}
# ---- map -------------------------------------------------------------------
# a `<map>` root -> the intermediate map Value tree (Tiled JSON schema).
function tmx_to_value(root: Xml) -> Val {
let m = value_object()
value_put(m, "type", value_str("map"))
value_put(m, "version", value_str(xml_attr(root, "version")))
value_put(m, "orientation", value_str(xml_attr(root, "orientation")))
value_put(m, "renderorder", value_str(xml_attr(root, "renderorder")))
value_put(m, "width", value_int(xml_attr_int(root, "width", 0)))
value_put(m, "height", value_int(xml_attr_int(root, "height", 0)))
value_put(m, "tilewidth", value_int(xml_attr_int(root, "tilewidth", 0)))
value_put(m, "tileheight", value_int(xml_attr_int(root, "tileheight", 0)))
value_put(m, "infinite", value_bool(xml_attr_int(root, "infinite", 0)))
if xml_has(root, "backgroundcolor") == 1 { value_put(m, "backgroundcolor", value_str(xml_attr(root, "backgroundcolor"))) }
if xml_has(root, "hexsidelength") == 1 { value_put(m, "hexsidelength", value_int(xml_attr_int(root, "hexsidelength", 0))) }
if xml_has(root, "staggeraxis") == 1 { value_put(m, "staggeraxis", value_str(xml_attr(root, "staggeraxis"))) }
if xml_has(root, "staggerindex") == 1 { value_put(m, "staggerindex", value_str(xml_attr(root, "staggerindex"))) }
let mapw = xml_attr_int(root, "width", 0)
let maph = xml_attr_int(root, "height", 0)
let tilesets = value_list()
let layers = value_list()
var i = 0
while i < xml_child_count(root) {
let ch = xml_child(root, i)
let tag = xml_tag(ch)
if tag == "tileset" { push(tilesets.kids, tmx_tileset_to_value(ch)) }
else { if tag == "layer" { push(layers.kids, tmx_tilelayer_to_value(ch, mapw, maph)) }
else { if tag == "objectgroup" { push(layers.kids, tmx_objectlayer_to_value(ch)) }
else { if tag == "imagelayer" { push(layers.kids, tmx_imagelayer_to_value(ch)) }
else { if tag == "group" { push(layers.kids, tmx_group_to_value(ch, mapw, maph)) } } } } }
i = i + 1
}
let props = tmx_props_list(root)
if len(props.kids) > 0 { value_put(m, "properties", props) }
value_put(m, "tilesets", tilesets)
value_put(m, "layers", layers)
return m
}
# `<imagelayer>` -> a Value object (P5 renders these).
function tmx_imagelayer_to_value(el: Xml) -> Val {
let o = value_object()
value_put(o, "type", value_str("imagelayer"))
tmx_layer_common(o, el)
let img = xml_find(el, "image")
if xml_tag(img) == "image" { value_put(o, "image", value_str(xml_attr(img, "source"))) }
if xml_has(el, "repeatx") == 1 { value_put(o, "repeatx", value_bool(xml_attr_int(el, "repeatx", 0))) }
if xml_has(el, "repeaty") == 1 { value_put(o, "repeaty", value_bool(xml_attr_int(el, "repeaty", 0))) }
return o
}
# `<group>` -> a Value object carrying its nested layers (P5 renders recursively).
function tmx_group_to_value(el: Xml, mapw: int, maph: int) -> Val {
let o = value_object()
value_put(o, "type", value_str("group"))
tmx_layer_common(o, el)
let layers = value_list()
var i = 0
while i < xml_child_count(el) {
let ch = xml_child(el, i)
let tag = xml_tag(ch)
if tag == "layer" { push(layers.kids, tmx_tilelayer_to_value(ch, mapw, maph)) }
else { if tag == "objectgroup" { push(layers.kids, tmx_objectlayer_to_value(ch)) }
else { if tag == "imagelayer" { push(layers.kids, tmx_imagelayer_to_value(ch)) }
else { if tag == "group" { push(layers.kids, tmx_group_to_value(ch, mapw, maph)) } } } }
i = i + 1
}
value_put(o, "layers", layers)
return o
}
# ---- JSON (TMJ) normalisation ----------------------------------------------
# The JSON reader already yields a Value tree; normalise it so it matches the XML
# path: decode any base64 `data` string into a dense GID int list, in place, for
# every tile layer (recursing into groups).
function tmj_normalize_layer(layer: Val) -> void {
if value_kind(layer) != 6 { return }
let ty = value_as_str(value_get(layer, "type"))
if ty == "group" {
let ls = value_get(layer, "layers")
var i = 0
while i < value_count(ls) { tmj_normalize_layer(value_at(ls, i)); i = i + 1 }
return
}
if ty != "tilelayer" { return }
let data = value_get(layer, "data")
if value_kind(data) == 4 { # a base64 string
let enc = value_as_str(value_get(layer, "encoding"))
let comp = value_as_str(value_get(layer, "compression"))
let w = value_as_int(value_get(layer, "width"))
let h = value_as_int(value_get(layer, "height"))
if enc == "base64" { value_put(layer, "data", tiled_b64_list(value_as_str(data), comp, w * h)) }
}
}
function tmj_normalize(m: Val) -> Val {
let ls = value_get(m, "layers")
var i = 0
while i < value_count(ls) { tmj_normalize_layer(value_at(ls, i)); i = i + 1 }
return m
}
# ---- top-level readers -----------------------------------------------------
# skip leading whitespace and return the first non-space byte (0 if none).
function tiled_first_byte(s: pointer) -> int {
var i = 0
let n = len(s)
while i < n {
let c = s[i]
if c == 32 or c == 9 or c == 10 or c == 13 { i = i + 1 } else { return c }
}
return 0
}
# read a map file (TMX or TMJ, auto-detected by first byte) -> the normalised
# intermediate map Value tree.
function tiled_read(path: pointer) -> Val {
let text = Fs.read_text(path)
if text == null { return value_null() }
if text == "" { return value_null() }
if tiled_first_byte(text) == 60 { # '<' -> XML
return tmx_to_value(xml_parse(text))
}
return tmj_normalize(json_parse(text)) # '{' -> JSON
}
# read a tileset file (TSX or TSJ) -> a tileset Value object.
function tiled_read_tsx(path: pointer) -> Val {
let text = Fs.read_text(path)
if text == null { return value_null() }
if text == "" { return value_null() }
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?
# the metadata Value object for the tile a GID resolves to (a `null` node if the
# tile carries none). Shared by the property / objectgroup / animation lookups.
function tmap_tile_meta(m: Tmap, gid: int) -> Val {
let r = tmap_resolve(m, gid)
if r.tileset < 0 { return value_null() }
let ts = m.tilesets[r.tileset]
if value_kind(ts.meta) != 6 { return value_null() }
let tiles = value_get(ts.meta, "tiles")
if value_kind(tiles) != 5 { return value_null() }
var i = 0
while i < value_count(tiles) {
let t = value_at(tiles, i)
if value_as_int(value_get(t, "id")) == r.local { return t }
i = i + 1
}
return value_null()
}
# 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 t = tmap_tile_meta(m, gid)
if value_kind(t) != 6 { return 0 }
let props = value_get(t, "properties")
if value_kind(props) != 5 { return 0 }
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
}
# does the tile a GID resolves to carry a per-tile <objectgroup> collision shape?
function tmap_tile_has_shapes(m: Tmap, gid: int) -> int {
let t = tmap_tile_meta(m, gid)
if value_kind(t) != 6 { return 0 }
let og = value_get(t, "objectgroup")
if value_kind(og) != 6 { return 0 }
let objs = value_get(og, "objects")
if value_count(objs) > 0 { return 1 }
return 0
}
# ---- collision normalisation (P2, #70) -------------------------------------
# the collision kind of a GID for the byte-grid projection, from the tile's
# metadata alone (design §3.5 priority): per-tile <objectgroup> hitboxes, then
# the solid/oneway/trigger property convention. 0 = no collision metadata,
# 1 = solid, 2 = one-way, 3 = trigger. The designated-collision-layer fallback
# (any non-zero GID is solid) is applied by the projection, not here.
function tmap_collision_kind(m: Tmap, gid: int) -> int {
if gid == 0 { return 0 }
if tmap_tile_prop(m, gid, "trigger") == 1 { return 3 }
if tmap_tile_prop(m, gid, "oneway") == 1 { return 2 }
if tmap_tile_has_shapes(m, gid) == 1 { return 1 }
if tmap_tile_prop(m, gid, "solid") == 1 { return 1 }
return 0
}
# ---- legacy rt_map compatibility projection --------------------------------
# project a layer down to the byte tilemap so Grid.* / Path.* / esys_move keep
# working unchanged. `whole_layer_solid` picks the source (design §3.5): true for
# a designated collision LAYER (a bare non-zero GID with no metadata still counts
# as solid — the fallback source); false to drive collision from per-tile
# metadata alone (objectgroup shapes / property convention on a visual layer).
# solid -> '#' (35), one-way -> '=' (61), trigger/empty -> ' ' (32, passable).
function tmap_project_layer(m: Tmap, layer: int, whole_layer_solid: 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 k = tmap_collision_kind(m, gid)
if k == 0 and whole_layer_solid != 0 { k = 1 } # collision-layer fallback
if k == 1 { rt_map[y * 96 + x] = 35 } # '#'
if k == 2 { rt_map[y * 96 + x] = 61 } # '='
}
}
x = x + 1
}
}
y = y + 1
}
}
# project a designated collision layer (non-zero GID is solid unless its tile
# metadata says otherwise) — the P1 default, called automatically on load.
function tmap_project(m: Tmap, layer: int) -> void { tmap_project_layer(m, layer, 1) }
# drive collision from per-tile metadata alone (objectgroup hitboxes / property
# convention) over any layer — a tile with no collision metadata stays passable.
function tmap_collide(m: Tmap, layer: int) -> void { tmap_project_layer(m, layer, 0) }
# 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)
}
# (tmap_draw / tmap_draw_anim are defined in the P3 section below, over the shared
# tmap_draw_full — a static draw plus animated tiles + tile objects.)
# ---- 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
}
# ============================================================================
# P3 (#71) — animated tiles + tile objects.
#
# Animated tiles: a tileset's <animation> is a list of {tileid, duration(ms)}
# frames. The current frame is a pure function of the engine frame counter
# (fixed 60/s, deterministic — the same mechanism SpriteAnim rides), so it ticks
# for free and reproduces frame-for-frame across runs. An animated GID resolves,
# at draw, to the current frame's GID (its flip flags preserved).
#
# Tile objects: an object-layer entry with a `gid` draws the tile image (with its
# own flip flags), bottom-anchored at the object position, as a placeable sprite.
# ============================================================================
# the three flip-flag bits of a GID (0xE0000000), kept when swapping an animation
# frame's tile id in.
function tmap_flag_bits(gid: int) -> int { return gid & ((1 << 31) | (1 << 30) | (1 << 29)) }
# the GID an (animated) GID resolves to at engine `frame` — frames not carrying an
# <animation> pass through unchanged. elapsed ms = frame * 1000 / 60 (the fixed
# 60/s clock), then walk the frame durations to the current one.
function tmap_frame_gid(m: Tmap, gid: int, frame: int) -> int {
if gid == 0 { return 0 }
let r = tmap_resolve(m, gid)
if r.tileset < 0 { return gid }
let t = tmap_tile_meta(m, gid)
if value_kind(t) != 6 { return gid }
let anim = value_get(t, "animation")
if value_kind(anim) != 5 { return gid }
let nfr = value_count(anim)
if nfr == 0 { return gid }
var total = 0
var i = 0
while i < nfr { total = total + value_as_int(value_get(value_at(anim, i), "duration")); i = i + 1 }
if total <= 0 { return gid }
let t_ms = (frame * 1000 / 60) % total
var cur = value_as_int(value_get(value_at(anim, 0), "tileid"))
var acc = 0
i = 0
while i < nfr {
let fr = value_at(anim, i)
acc = acc + value_as_int(value_get(fr, "duration"))
if t_ms < acc { cur = value_as_int(value_get(fr, "tileid")); i = nfr }
else { i = i + 1 }
}
let ts = m.tilesets[r.tileset]
return (ts.firstgid + cur) | tmap_flag_bits(gid)
}
# is a GID animated (its tile carries a non-empty <animation>)?
function tmap_is_animated(m: Tmap, gid: int) -> int {
let t = tmap_tile_meta(m, gid)
if value_kind(t) != 6 { return 0 }
let anim = value_get(t, "animation")
if value_kind(anim) == 5 and value_count(anim) > 0 { return 1 }
return 0
}
# draw every visible tile layer (animated tiles resolved for `frame`) plus every
# tile object on the object layers, in file order, offset by the camera.
function tmap_draw_full(m: Tmap, camx: int, camy: int, frame: int, animate: int) -> void {
var li = 0
while li < len(m.layers) {
let l = m.layers[li]
if l.kind == 0 and l.visible != 0 { # tile layer
var y = 0
while y < l.h {
var x = 0
while x < l.w {
var gid = l.gids[y * l.w + x]
if gid != 0 {
if animate != 0 { gid = tmap_frame_gid(m, gid, frame) }
tmap_draw_gid(m, gid, x * m.tilew - camx, y * m.tileh - camy)
}
x = x + 1
}
y = y + 1
}
}
if l.kind == 1 and l.visible != 0 { # object layer: draw tile objects
let objs = value_get(l.data, "objects")
var oi = 0
while oi < value_count(objs) {
let ob = value_at(objs, oi)
if value_has(ob, "gid") == 1 {
var g = value_as_int(value_get(ob, "gid"))
if animate != 0 { g = tmap_frame_gid(m, g, frame) }
let ox = value_as_int(value_get(ob, "x"))
let oy = value_as_int(value_get(ob, "y"))
# Tiled anchors a tile object by its bottom-left corner
tmap_draw_gid(m, g, ox - camx, oy - m.tileh - camy)
}
oi = oi + 1
}
}
li = li + 1
}
}
# static draw (no animation) — the P1 entry point.
function tmap_draw(m: Tmap, camx: int, camy: int) -> void { tmap_draw_full(m, camx, camy, 0, 0) }
# animated draw at engine `frame` (pass Time.frame): animated tiles advance,
# deterministically and frame-identically across runs.
function tmap_draw_anim(m: Tmap, camx: int, camy: int, frame: int) -> void { tmap_draw_full(m, camx, camy, frame, 1) }