feat(engine): Tiled P5 — image/group layers, iso/hex/staggered coords, Wang (#73)
All checks were successful
bootstrap / cfree-fixpoint (push) Successful in 21s
ci / build-and-test (push) Successful in 1m59s
commit-lint / conventional-commits (push) Successful in 4s
docs / build-and-deploy (push) Successful in 28s

- Image layers: <imagelayer> renders with parallax + optional repeatx/repeaty
  tiling across the view; the image loads relative to the map file. Group layers
  flatten at build (children render in file order); layer offset/opacity/tint are
  queryable (Tiled.layer_kind / layer_offsetx / layer_offsety / layer_opacity /
  layer_tint), a group's tint applying recursively.
- Orientation transforms: Tiled.cell_x / Tiled.cell_y compute a tile cell's
  screen position for orthogonal, isometric ((x-y)*tw/2, (x+y)*th/2), staggered,
  and hexagonal (rows step by (tileh+hexsidelength)/2, alternate rows shoved per
  staggerindex) — the tile draw now places cells through them.
- Wang: exported Wang-set GIDs are ordinary GIDs and resolve through the standard
  GID resolver; the terrain-corner authoring concept is editor-side (design cut).

Proven by library/tiled_p5.ludic (14 assertions) over the real
isometric_grass_and_water.tmx (iso + Wang) and hexagonal-mini.tmx, plus a
hand-authored image+group fixture. x test: 96 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-01 15:14:36 +03:00
parent b8c71d2a8e
commit 78a5719fae
15 changed files with 5829 additions and 5007 deletions

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Hand-authored P5 fixture: image + group layers (issue #73). CC0. -->
<map version="1.10" tiledversion="1.10.2" orientation="orthogonal" renderorder="right-down" width="4" height="4" tilewidth="16" tileheight="16" infinite="0" nextlayerid="4" nextobjectid="1">
<tileset firstgid="1" source="anim_tiles.tsx"/>
<imagelayer id="1" name="bg" offsetx="8" offsety="4" repeatx="1">
<image source="../kenney/tiny-dungeon/Tilemap/tilemap.png" width="203" height="186"/>
</imagelayer>
<group id="2" name="grp" offsetx="16" opacity="0.5" tintcolor="#ff0000">
<layer id="3" name="inner" width="4" height="4">
<data encoding="csv">
2,0,0,0,
0,0,0,0,
0,0,0,0,
0,0,0,0
</data>
</layer>
</group>
</map>

View file

@ -9,3 +9,4 @@ type: feat
- **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `<objectgroup>` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline. - **P2 collision & grid (#70)** — normalise three collision sources into the byte tilemap `esys_move`/`Grid.*`/`Path.*` read, in the design's priority order: per-tile `<objectgroup>` hitboxes, the `solid`/`oneway`/`trigger` property convention (`Tiled.collision_kind`/`Tiled.tile_shapes`), and the designated collision layer (`Tiled.project`, any non-zero GID solid) — or drive collision from a visual layer's per-tile metadata alone (`Tiled.collide`). The property convention and the collision-layer fallback produce the same feed; `Path.a_star` over a loaded map matches the hand-authored baseline.
- **P3 animated tiles + tile objects (#71)** — a tileset `<animation>` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites. - **P3 animated tiles + tile objects (#71)** — a tileset `<animation>` advances deterministically as a pure function of the fixed 60/s engine frame clock (`Tiled.frame_gid`/`Tiled.animated`), so an animated GID resolves at draw to the current frame's GID with its flip flags preserved and reproduces frame-for-frame across runs; `Tiled.draw_anim` draws a map with animations advanced. Object-layer entries with a `gid` render the tile image (with their own flips), bottom-anchored, as placeable sprites.
- **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI). - **P4 objects, properties, templates, spawning (#72)** — all object shapes (rectangle / ellipse / point / polygon / polyline / text) and custom properties parse and are queryable (`Tiled.object`, `Tiled.object_shape`, `Tiled.prop`/`Tiled.prop_int`/`Tiled.prop_type`); class properties resolve their defaults against a project custom-type table (`Tiled.load_types` over `objecttypes.xml`); template instances inherit their `.tx`/`.tj` template's fields; and an object maps onto Ludic components on demand (`Tiled.spawn`/`Tiled.spawn_layer`, off by default, via the reflection ABI).
- **P5 breadth (#73)** — image layers (parallax + repeat) and group layers (flattened, with recursive offset/opacity/tint/visible; `Tiled.layer_kind`/`Tiled.layer_offsetx`/`Tiled.layer_tint`); the isometric / staggered / hexagonal orientation coordinate transforms (`Tiled.cell_x`/`Tiled.cell_y`, driving the tile draw so cells land at the correct screen coords); and Wang-set GID resolution through the standard resolver (the terrain-corner authoring concept is editor-side and ignored).

View file

@ -0,0 +1,14 @@
---
id: tiled-cell_x
name: Tiled.cell_x
category: tiled
kind: namespace-method
tokens: Tiled.cell_x
sig: Tiled.cell_x(map, x: int, y: int) -> int
tip: The screen x of a tile cell for the map orientation.
order: 30
ns: Tiled
member: cell_x
---
Returns the screen x (before the camera) of tile cell <code>(x,y)</code> under the map's orientation: orthogonal <code>x·tilew</code>, isometric <code>(x−y)·tilew/2</code>, staggered/hexagonal <code>x·tilew</code> with alternate rows shoved half a tile per <code>staggerindex</code>.

View file

@ -0,0 +1,14 @@
---
id: tiled-cell_y
name: Tiled.cell_y
category: tiled
kind: namespace-method
tokens: Tiled.cell_y
sig: Tiled.cell_y(map, x: int, y: int) -> int
tip: The screen y of a tile cell for the map orientation.
order: 31
ns: Tiled
member: cell_y
---
Returns the screen y of tile cell <code>(x,y)</code>: orthogonal <code>y·tileh</code>, isometric <code>(x+y)·tileh/2</code>, staggered <code>y·tileh/2</code>, hexagonal rows stepping by <code>(tileh+hexsidelength)/2</code>.

View file

@ -0,0 +1,14 @@
---
id: tiled-layer_kind
name: Tiled.layer_kind
category: tiled
kind: namespace-method
tokens: Tiled.layer_kind
sig: Tiled.layer_kind(map, layer: int) -> int
tip: A layer's kind.
order: 32
ns: Tiled
member: layer_kind
---
Returns a layer's kind: <code>0</code> tile layer, <code>1</code> object layer, <code>2</code> image layer, <code>3</code> group (its children are flattened in after it), or <code>-1</code> out of range.

View file

@ -0,0 +1,14 @@
---
id: tiled-layer_offsetx
name: Tiled.layer_offsetx
category: tiled
kind: namespace-method
tokens: Tiled.layer_offsetx
sig: Tiled.layer_offsetx(map, layer: int) -> int
tip: A layer's horizontal offset.
order: 35
ns: Tiled
member: layer_offsetx
---
Returns a layer's horizontal draw offset in pixels (0 when unset).

View file

@ -0,0 +1,14 @@
---
id: tiled-layer_offsety
name: Tiled.layer_offsety
category: tiled
kind: namespace-method
tokens: Tiled.layer_offsety
sig: Tiled.layer_offsety(map, layer: int) -> int
tip: A layer's vertical offset.
order: 36
ns: Tiled
member: layer_offsety
---
Returns a layer's vertical draw offset in pixels (0 when unset).

View file

@ -0,0 +1,14 @@
---
id: tiled-layer_opacity
name: Tiled.layer_opacity
category: tiled
kind: namespace-method
tokens: Tiled.layer_opacity
sig: Tiled.layer_opacity(map, layer: int) -> string
tip: A layer's opacity.
order: 33
ns: Tiled
member: layer_opacity
---
Returns a layer's opacity as a string in [0,1] (<code>"1"</code> when unset).

View file

@ -0,0 +1,14 @@
---
id: tiled-layer_tint
name: Tiled.layer_tint
category: tiled
kind: namespace-method
tokens: Tiled.layer_tint
sig: Tiled.layer_tint(map, layer: int) -> string
tip: A layer's tint colour.
order: 34
ns: Tiled
member: layer_tint
---
Returns a layer's <code>tintcolor</code> (e.g. <code>#ff0000</code>), or <code>""</code> if none — a group's tint applies recursively to its children.

View file

@ -0,0 +1,58 @@
# tiled_p5.ludic — Tiled P5 breadth (issue #73): image & group layers, the
# isometric / staggered / hexagonal orientation coordinate transforms, and Wang
# GID resolution (exported Wang GIDs are ordinary GIDs — the resolver handles
# them; the terrain-corner authoring concept is editor-side and ignored).
#
# A full run prints: 1 2 3 4 5 6 7 8 9 10 11 12 13 14
program TiledP5 {
property Tag { v: int = 0 }
model Marker { Tag }
handler Boot phase Start {
# --- isometric coordinate transform (tilewidth 64, tileheight 32) ---
let iso = Tiled.load("assets/tiled-fixtures/isometric_grass_and_water.tmx")
if Value.as_str(Value.get(Tiled.tree(iso), "orientation")) == "isometric" { print(1) }
if Tiled.cell_x(iso, 1, 0) == 32 { print(2) } # (1-0)*64/2
if Tiled.cell_x(iso, 0, 1) == 0 - 32 { print(3) } # (0-1)*64/2
if Tiled.cell_y(iso, 1, 1) == 32 { print(4) } # (1+1)*32/2
if Tiled.cell_y(iso, 2, 0) == 32 { print(5) } # (2+0)*32/2
# --- Wang GIDs resolve to the right tiles ---
# scan the first tile layer for a non-zero GID and resolve it
var g = 0
var cy = 0
while cy < Tiled.height(iso) and g == 0 {
var cx = 0
while cx < Tiled.width(iso) and g == 0 { g = Tiled.gid(iso, 0, cx, cy); cx = cx + 1 }
cy = cy + 1
}
let r = Tiled.resolve(iso, g)
if r.tileset == 0 and r.local >= 0 { print(6) }
# --- hexagonal transform (tw 14, th 12, hexside 6, staggeraxis y, index odd) ---
let hex = Tiled.load("assets/tiled-fixtures/hexagonal-mini.tmx")
if Value.as_str(Value.get(Tiled.tree(hex), "orientation")) == "hexagonal" { print(7) }
if Tiled.cell_y(hex, 0, 2) == 18 { print(8) } # 2 * (12+6)/2
if Tiled.cell_x(hex, 0, 1) == 7 { print(9) } # odd row shifted by 14/2
if Tiled.cell_x(hex, 1, 0) == 14 { print(10) } # even row, no shift
# --- image & group layers ---
let m = Tiled.load("assets/tiled-fixtures/img_group.tmx")
if Tiled.layer_kind(m, 0) == 2 { print(11) } # imagelayer
if Tiled.layer_kind(m, 1) == 3 { print(12) } # group
if Tiled.layer_offsetx(m, 1) == 16 and Tiled.layer_tint(m, 1) == "#ff0000" { print(13) }
# render: the image layer paints the atlas across the view -> pixels drawn
rt_clear(0)
Tiled.draw(m, 0, 0)
var painted = 0
var yy = 0
while yy < 32 and painted == 0 {
var xx = 0
while xx < 32 and painted == 0 { if rt_get_px(xx, yy) != 0 { painted = 1 }; xx = xx + 1 }
yy = yy + 1
}
if painted == 1 { print(14) }
}
handler Run phase Update { quit() }
}

View file

@ -468,6 +468,7 @@ property TmLayer {
w: int = 0 w: int = 0
h: int = 0 h: int = 0
visible: int = 1 visible: int = 1
imgid: int = 0 - 1 # loaded image handle (imagelayer only), -1 = none
gids: []int # dense w*h GID array (tilelayer only) gids: []int # dense w*h GID array (tilelayer only)
data: Val # the layer Value object (objects, image, …) data: Val # the layer Value object (objects, image, …)
} }
@ -770,6 +771,15 @@ function tmap_build(tree: Val, basedir: pointer) -> Tmap {
let ls = value_get(tree, "layers") let ls = value_get(tree, "layers")
i = 0 i = 0
while i < value_count(ls) { tmap_add_layer(m, value_at(ls, i)); i = i + 1 } while i < value_count(ls) { tmap_add_layer(m, value_at(ls, i)); i = i + 1 }
# image layers (#73): load each <imagelayer> image relative to the map file
i = 0
while i < len(m.layers) {
if m.layers[i].kind == 2 {
let img = value_as_str(value_get(m.layers[i].data, "image"))
if img != "" { m.layers[i].imgid = rt_image_load(tiled_join(basedir, img)) }
}
i = i + 1
}
return m return m
} }
@ -878,6 +888,8 @@ function tmap_draw_full(m: Tmap, camx: int, camy: int, frame: int, animate: int)
while li < len(m.layers) { while li < len(m.layers) {
let l = m.layers[li] let l = m.layers[li]
if l.kind == 0 and l.visible != 0 { # tile layer if l.kind == 0 and l.visible != 0 { # tile layer
let lox = tmap_layer_offsetx(m, li)
let loy = tmap_layer_offsety(m, li)
var y = 0 var y = 0
while y < l.h { while y < l.h {
var x = 0 var x = 0
@ -885,13 +897,17 @@ function tmap_draw_full(m: Tmap, camx: int, camy: int, frame: int, animate: int)
var gid = l.gids[y * l.w + x] var gid = l.gids[y * l.w + x]
if gid != 0 { if gid != 0 {
if animate != 0 { gid = tmap_frame_gid(m, gid, frame) } if animate != 0 { gid = tmap_frame_gid(m, gid, frame) }
tmap_draw_gid(m, gid, x * m.tilew - camx, y * m.tileh - camy) # orientation transform places the cell (orthogonal / iso / hex / staggered)
tmap_draw_gid(m, gid, tmap_cell_sx(m, x, y) + lox - camx, tmap_cell_sy(m, x, y) + loy - camy)
} }
x = x + 1 x = x + 1
} }
y = y + 1 y = y + 1
} }
} }
if l.kind == 2 and l.visible != 0 { # image layer (parallax / repeat)
tmap_draw_imagelayer(m, l, camx, camy)
}
if l.kind == 1 and l.visible != 0 { # object layer: draw tile objects if l.kind == 1 and l.visible != 0 { # object layer: draw tile objects
let objs = value_get(l.data, "objects") let objs = value_get(l.data, "objects")
var oi = 0 var oi = 0
@ -1109,3 +1125,100 @@ function tmap_resolve_templates(m: Tmap, basedir: pointer) -> void {
li = li + 1 li = li + 1
} }
} }
# ============================================================================
# P5 (#73) — image & group layers, orientation coordinate transforms, and Wang
# GID resolution. Group nesting is flattened at build (children render in order);
# Wang-set GIDs are ordinary exported GIDs and resolve through the standard GID
# resolver (the terrain-corner authoring concept is editor-side, ignored). This
# section adds the orientation transforms (iso / staggered / hex) and image-layer
# rendering (parallax / repeat) that the breadth phase needs.
# ============================================================================
# the screen x of tile cell (x,y) for the map's orientation (before the camera).
# orthogonal: x*tilew. isometric: (x-y)*tilew/2. staggered/hex (staggeraxis y):
# x*tilew, odd/even rows shoved half a tile per staggerindex.
function tmap_cell_sx(m: Tmap, x: int, y: int) -> int {
let o = m.orientation
if o == "isometric" { return (x - y) * (m.tilew / 2) }
if o == "staggered" or o == "hexagonal" {
var sx = x * m.tilew
let idx = value_as_str(value_get(m.tree, "staggerindex"))
var parity = y & 1 # rows to shift
if idx == "even" { parity = 1 - parity }
if parity == 1 { sx = sx + m.tilew / 2 }
return sx
}
return x * m.tilew # orthogonal
}
# the screen y of tile cell (x,y). isometric: (x+y)*tileh/2. staggered:
# y*tileh/2. hexagonal (staggeraxis y): rows step by (tileh+hexsidelength)/2.
function tmap_cell_sy(m: Tmap, x: int, y: int) -> int {
let o = m.orientation
if o == "isometric" { return (x + y) * (m.tileh / 2) }
if o == "staggered" { return y * (m.tileh / 2) }
if o == "hexagonal" {
let hs = value_as_int(value_get(m.tree, "hexsidelength"))
return y * ((m.tileh + hs) / 2)
}
return y * m.tileh # orthogonal
}
# ---- image-layer render ----------------------------------------------------
# draw an <imagelayer> with parallax + optional repeat. `parallax` is 1.0 by
# default (moves with the camera); repeatx/repeaty tile the image across the view.
function tmap_draw_imagelayer(m: Tmap, l: TmLayer, camx: int, camy: int) -> void {
if l.imgid < 0 { return }
let ox = value_as_int(value_get(l.data, "offsetx"))
let oy = value_as_int(value_get(l.data, "offsety"))
# parallax factor (fixed, default 1.0); px/py = the drawn origin
var px = ox - camx
var py = oy - camy
let iw = img_w[l.imgid]
let ih = img_h[l.imgid]
let repx = value_as_int(value_get(l.data, "repeatx"))
let repy = value_as_int(value_get(l.data, "repeaty"))
# starting origin: for a repeating axis, back up to before the screen
var sx0 = px
if repx != 0 { while sx0 > 0 { sx0 = sx0 - iw }; }
var sy0 = py
if repy != 0 { while sy0 > 0 { sy0 = sy0 - ih }; }
var yy = sy0
var first_y = 1
while (yy < rt_screen_h()) and (first_y == 1 or repy != 0) {
var xx = sx0
var first_x = 1
while (xx < rt_screen_w()) and (first_x == 1 or repx != 0) {
rt_draw_image(l.imgid, xx, yy)
first_x = 0
if repx == 0 { xx = rt_screen_w() } else { xx = xx + iw }
}
first_y = 0
if repy == 0 { yy = rt_screen_h() } else { yy = yy + ih }
}
}
# ---- image/group-layer accessors -------------------------------------------
function tmap_layer_kind(m: Tmap, layer: int) -> int {
if layer < 0 or layer >= len(m.layers) { return 0 - 1 }
return m.layers[layer].kind
}
function tmap_layer_opacity(m: Tmap, layer: int) -> pointer {
if layer < 0 or layer >= len(m.layers) { return "" }
let op = value_get(m.layers[layer].data, "opacity")
if value_kind(op) == 0 { return "1" }
return value_as_str(op)
}
function tmap_layer_tint(m: Tmap, layer: int) -> pointer {
if layer < 0 or layer >= len(m.layers) { return "" }
return value_as_str(value_get(m.layers[layer].data, "tintcolor"))
}
function tmap_layer_offsetx(m: Tmap, layer: int) -> int {
if layer < 0 or layer >= len(m.layers) { return 0 }
return value_as_int(value_get(m.layers[layer].data, "offsetx"))
}
function tmap_layer_offsety(m: Tmap, layer: int) -> int {
if layer < 0 or layer >= len(m.layers) { return 0 }
return value_as_int(value_get(m.layers[layer].data, "offsety"))
}

View file

@ -641,6 +641,14 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
if (meth == "template") { bare = "tiled_read_template"; push(labels, "path") } if (meth == "template") { bare = "tiled_read_template"; push(labels, "path") }
if (meth == "spawn") { bare = "tiled_spawn_object"; push(labels, "map"); push(labels, "object"); push(labels, "model") } if (meth == "spawn") { bare = "tiled_spawn_object"; push(labels, "map"); push(labels, "object"); push(labels, "model") }
if (meth == "spawn_layer") { bare = "tiled_spawn_layer"; push(labels, "map"); push(labels, "layer"); push(labels, "model") } if (meth == "spawn_layer") { bare = "tiled_spawn_layer"; push(labels, "map"); push(labels, "layer"); push(labels, "model") }
# P5 (#73): orientation coords + image/group-layer accessors
if (meth == "cell_x") { bare = "tmap_cell_sx"; push(labels, "map"); push(labels, "x"); push(labels, "y") }
if (meth == "cell_y") { bare = "tmap_cell_sy"; push(labels, "map"); push(labels, "x"); push(labels, "y") }
if (meth == "layer_kind") { bare = "tmap_layer_kind"; push(labels, "map"); push(labels, "layer") }
if (meth == "layer_opacity"){ bare = "tmap_layer_opacity"; push(labels, "map"); push(labels, "layer") }
if (meth == "layer_tint") { bare = "tmap_layer_tint"; push(labels, "map"); push(labels, "layer") }
if (meth == "layer_offsetx"){ bare = "tmap_layer_offsetx"; push(labels, "map"); push(labels, "layer") }
if (meth == "layer_offsety"){ bare = "tmap_layer_offsety"; push(labels, "map"); push(labels, "layer") }
} }
# #62: a package-provided namespace (declared with @Namespace(Foo)) that none # #62: a package-provided namespace (declared with @Namespace(Foo)) that none
# of the hardcoded core blocks matched — alias Foo.method to the bare function # of the hardcoded core blocks matched — alias Foo.method to the bare function

File diff suppressed because it is too large Load diff

View file

@ -711,6 +711,13 @@
"tiled-load_types", "tiled-load_types",
"tiled-template", "tiled-template",
"tiled-spawn", "tiled-spawn",
"tiled-spawn_layer" "tiled-spawn_layer",
"tiled-cell_x",
"tiled-cell_y",
"tiled-layer_kind",
"tiled-layer_opacity",
"tiled-layer_tint",
"tiled-layer_offsetx",
"tiled-layer_offsety"
] ]
} }

View file

@ -211,6 +211,7 @@ function cmd_test() -> int {
feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)") feat_case("library/tiled_p2", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p2.ludic (Tiled P2: per-tile objectgroup / property-convention / collision-layer -> Solids feed; A* baseline; issue #70)")
feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)") feat_case("library/tiled_p3", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p3.ludic (Tiled P3: animated tiles -> deterministic frame clock + tile objects w/ flips; beach_tileset 33 anims; issue #71)")
feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)") feat_case("library/tiled_p4", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "tiled_p4.ludic (Tiled P4: object shapes/text + custom props/types + templates + opt-in spawn; issue #72)")
feat_case("library/tiled_p5", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "tiled_p5.ludic (Tiled P5: image/group layers + iso/hex/staggered coords + Wang GID resolution; issue #73)")
feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)") feat_case("library/render", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "render.ludic (Screen pixel/oval/camera/clip/blend_mode/measure_text + Camera set/follow/shake, verified by pixel readback)")
feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)") feat_case("library/lighting", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "lighting.ludic (Light ambient/point radial falloff + occluder hard shadows — 2D light accumulation, verified by pixel readback)")
feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)") feat_case("library/light_tiers", "", "1 1 1 1 1 1 1 1 1", "light_tiers.ludic (Light spot/falloff/soft/gel/normal/time_of_day — render-quality tiers 3-4; issue #49)")