From 29585395143b2f295c4a181d94c7c4cb72117da3 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 15:18:22 +0300 Subject: [PATCH 01/14] ludic.ui: a new component instance's first build is declared - a first show mid-play is not a frame's keep Master's windowed fog profile failed the fence at walk t 35 s: +512 B from value_new / value_put / value_slot / value_lists under bd_class and cmp_keycap_model - a KeyCap first shown mid-walk. It is a first build, bounded, not a per-show leak: an unmounted instance goes to in_free and the next show of its class reuses it (in_reuse, `renew`), its props and model objects kept and filled in place. Only a NEW instance makes them - bounded by how many of that class are up at once, and the ones unmounted less than two builds ago. in_reuse already declared the record; the props and model objects and their first fill, made afterwards in bd_class, were not. They are now: in_reuse marks a new record `fresh`, and bd_class's first fill of it goes through bd_first_fill (@alloc_ok) - a reused instance's fill stays judged, so a real per-show leak would still fail. Golden ui_first_show: a component first shown at frame 700, once the fence is judging, then hidden and shown twice more: bad 0 (the toolchain before this fails frame 701: +464 B value_new from value_slot under cmp_cell_model, value_put grow - the profile's failure). The 37 ui examples pass. Co-Authored-By: Claude Opus 5.5 --- examples/library/ui_first_show.ludic | 27 ++++++++++++++++ examples/library/ui_first_show_parts/Cell.lss | 1 + .../library/ui_first_show_parts/Cell.ludic | 3 ++ examples/library/ui_first_show_parts/Cell.xml | 3 ++ examples/library/ui_first_show_parts/Host.lss | 1 + .../library/ui_first_show_parts/Host.ludic | 4 +++ examples/library/ui_first_show_parts/Host.xml | 4 +++ packages/ludic.ui/build_class.ludic | 31 +++++++++++++------ packages/ludic.ui/instance.ludic | 2 ++ tools/ludic-cli/test.ludic | 1 + 10 files changed, 67 insertions(+), 10 deletions(-) create mode 100644 examples/library/ui_first_show.ludic create mode 100644 examples/library/ui_first_show_parts/Cell.lss create mode 100644 examples/library/ui_first_show_parts/Cell.ludic create mode 100644 examples/library/ui_first_show_parts/Cell.xml create mode 100644 examples/library/ui_first_show_parts/Host.lss create mode 100644 examples/library/ui_first_show_parts/Host.ludic create mode 100644 examples/library/ui_first_show_parts/Host.xml diff --git a/examples/library/ui_first_show.ludic b/examples/library/ui_first_show.ludic new file mode 100644 index 00000000..c3aeb648 --- /dev/null +++ b/examples/library/ui_first_show.ludic @@ -0,0 +1,27 @@ +# ui_first_show.ludic - a component shown for the first time once the fence is judging (a prompt's key +# hint mid-walk) makes its instance's props and model: declared, once per instance, and the failing +# fence lets it through; hidden and shown again, the instance is reused and nothing more is made: +# bad 0 shown 3 +import "ludic.ui" +import "ui_first_show_parts/Cell.ludic" +import "ui_first_show_parts/Host.ludic" +program UiFirstShow { + numbers float + function frame(ui_st: mut UiState) -> UiNode { + let root: UiNode = ui_nodes(ui_st, "Host", null) + ui_place(ui_st, root, 0.0, 0.0, 400.0, 400.0) + return root + } + entry (ui_st: mut UiState) { + var shown = 0 + for f in 0 .. 1400 { + let root = frame(ui_st) + if f == 700 or f == 900 or f == 1000 or f == 1100 or f == 1200 { + ui_press(ui_st, root.children[0].children[0]) + if f != 900 and f != 1100 { shown += 1 } + } + Mem.frame() + } + print(`bad {Mem.bad_frames()} shown {shown}`) + } +} diff --git a/examples/library/ui_first_show_parts/Cell.lss b/examples/library/ui_first_show_parts/Cell.lss new file mode 100644 index 00000000..b8c03a5a --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.lss @@ -0,0 +1 @@ +.cell { width: 100px; height: 40px } diff --git a/examples/library/ui_first_show_parts/Cell.ludic b/examples/library/ui_first_show_parts/Cell.ludic new file mode 100644 index 00000000..0ed005d7 --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.ludic @@ -0,0 +1,3 @@ +component Cell { + prop caption: string = "" +} diff --git a/examples/library/ui_first_show_parts/Cell.xml b/examples/library/ui_first_show_parts/Cell.xml new file mode 100644 index 00000000..a88076ab --- /dev/null +++ b/examples/library/ui_first_show_parts/Cell.xml @@ -0,0 +1,3 @@ +
+ +
diff --git a/examples/library/ui_first_show_parts/Host.lss b/examples/library/ui_first_show_parts/Host.lss new file mode 100644 index 00000000..1bac0ccf --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.lss @@ -0,0 +1 @@ +.t { width: 60px; height: 30px } diff --git a/examples/library/ui_first_show_parts/Host.ludic b/examples/library/ui_first_show_parts/Host.ludic new file mode 100644 index 00000000..a8e1b5ba --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.ludic @@ -0,0 +1,4 @@ +component Host { + state on: bool = false + on flip() { on = not on } +} diff --git a/examples/library/ui_first_show_parts/Host.xml b/examples/library/ui_first_show_parts/Host.xml new file mode 100644 index 00000000..29140558 --- /dev/null +++ b/examples/library/ui_first_show_parts/Host.xml @@ -0,0 +1,4 @@ +
+ + +
diff --git a/packages/ludic.ui/build_class.ludic b/packages/ludic.ui/build_class.ludic index 6885e1db..3351ee19 100644 --- a/packages/ludic.ui/build_class.ludic +++ b/packages/ludic.ui/build_class.ludic @@ -5,17 +5,8 @@ function bd_class(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []Ui let cls = k.cls uc_load(ui_st, cls) let it = in_get(ui_st, key, cls) - if it.props == null { it.props = Value.object() } - let props = it.props - Value.clear(props) - for i in 0 .. len(k.keys) { - let a = k.keys[i] - if a != "class" and a != "style" and a != "id" { Value.put(props, a, ui_eval(ui_st, k.vals[i], e)) } - } - cls.props(it.ptr, props) let ce = env_new(ui_st, null) - if it.model == null { it.model = Value.object() } - ce.model = cls.model(it.ptr, it.model) + if it.fresh { ce.model = bd_first_fill(ui_st, it, k, e) } else { ce.model = bd_fill(ui_st, it, k, e) } ce.inst = it ce.ctx = e.ctx ce.slot = k @@ -34,6 +25,26 @@ function bd_class(ui_st: mut UiState, k: UiTpl, e: UiEnv, key: string, out: []Ui bd_kids(ui_st, root, ce, key, out) if len(out) > at { bd_pass_down(ui_st, out[at], k, e) } } +# an instance's props and model, filled in place into the objects it keeps (a reused one's are kept) +function bd_fill(ui_st: mut UiState, it: UiInst, k: UiTpl, e: UiEnv) -> Val { + if it.props == null { it.props = Value.object() } + let props = it.props + Value.clear(props) + for i in 0 .. len(k.keys) { + let a = k.keys[i] + if a != "class" and a != "style" and a != "id" { Value.put(props, a, ui_eval(ui_st, k.vals[i], e)) } + } + it.cls.props(it.ptr, props) + if it.model == null { it.model = Value.object() } + return it.cls.model(it.ptr, it.model) +} +# a new instance's first fill makes its props and model objects and every Value in them - once per +# instance, which in_reuse bounds (a prompt's key hint first shown mid-walk was judged as a frame's keep) +@alloc_ok("a new component instance's first build: its props and model, then kept or reused") +function bd_first_fill(ui_st: mut UiState, it: UiInst, k: UiTpl, e: UiEnv) -> Val { + it.fresh = false + return bd_fill(ui_st, it, k, e) +} # puts those on the card's own root element; when that # root is itself a component, each user in turn adds theirs (the outermost's id wins) function bd_pass_down(ui_st: mut UiState, n: UiNode, k: UiTpl, e: UiEnv) -> void { diff --git a/packages/ludic.ui/instance.ludic b/packages/ludic.ui/instance.ludic index 1c256d61..4f99a1c5 100644 --- a/packages/ludic.ui/instance.ludic +++ b/packages/ludic.ui/instance.ludic @@ -7,6 +7,7 @@ property UiInst { seen: int = 0 props: Val = null # its props, filled again each frame into the same object model: Val = null # and its model, filled in place by the class's model() + fresh: bool = false # made, not reused: its first build makes its props and model } # builds before an unmounted instance is mounted again: the tree that last named it is good for two const IN_SPARE_AFTER: int = 2 @@ -41,6 +42,7 @@ function in_reuse(ui_st: mut UiState, k: UiClass) -> UiInst { let it = new UiInst it.cls = k it.ptr = k.make() + it.fresh = true return it } # after a build of `screen`: its instances the build did not reach are unmounted diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 5dc9690c..eb5df445 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -1248,6 +1248,7 @@ function cmd_dev_test() -> int { controller_case("library/ui_emit_click", "", "got 2 | pressed: got 1", "ui_emit_click.ludic (L11: a component's `emit click` reaches its user's on-click, by the mouse and by a press)") controller_case("library/ui_select_arrows", "", "prev 0 next next 2", "ui_select_arrows.ludic (L11: a select's < steps back and its > forward)") controller_case("library/ui_pointer_through", "", "pressed 1, hovered #4a5058; > hovered 1", "ui_pointer_through.ludic (L11: the pointer looks through a root with no size to a positioned panel's button; a select's > answers :hover)") + controller_case("library/ui_first_show", "", "bad 0 shown 3", "ui_first_show.ludic (L11: a component first shown once the fence is judging makes its props and model declared, once per instance; shown again, it is reused)") controller_case("library/ui_submit", "", "[hi] chat '' | [hi][yo][Ada] chat '' name 'Ada'", "ui_submit.ludic (L11: on-submit on a text field by Enter or A, clear-on-submit, the focus kept)") controller_case("library/ui_zoom", "", "plain 100 pad 10 text 16 h 20 | half 50 pad 5 text 8 h 10 | half of twice 100 | fit 50 em 16", "ui_zoom.ludic (L11: zoom scales a subtree's lengths, text and layout, multiplies, and takes min() and a length over a length)") controller_case("library/ui_hold", "", "31 frames 500 ms t 500 | 121 frames 500 ms t 500 | pad 16 frames 250 ms t 250 | dududu", "ui_hold.ludic (L11: on-hold every frame a button is held, by the pointer or A, with dt and t on the clock at any rate)") From 93e5cb391efd54fc21ab00c7417443633237b48f Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 15:43:22 +0300 Subject: [PATCH 02/14] nav: nav_tiles_keep_near - a file-backed mesh's tiles kept by a distance the game answers (its players' zones, ludic.zones' zones_nearest): in within pad of it, out only past pad + hyst; the tile counters in resident_count.ludic For loading the world by each player's fog rather than a fixed 900 m. keep_near_test: a zone round one corner brings in its tile, a bigger one its neighbours, an edge nudged back and forth twenty times changes nothing, and a zone moved 2 km away lets them all go. Co-Authored-By: Claude Opus 5.5 --- packages/ludic.nav/index.ludic | 1 + packages/ludic.nav/resident.ludic | 38 +++++++-------- packages/ludic.nav/resident_count.ludic | 21 +++++++++ packages/ludic.nav/tests/keep_near_test.ludic | 47 +++++++++++++++++++ 4 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 packages/ludic.nav/resident_count.ludic create mode 100644 packages/ludic.nav/tests/keep_near_test.ludic diff --git a/packages/ludic.nav/index.ludic b/packages/ludic.nav/index.ludic index 8ad555c6..d4b4936a 100644 --- a/packages/ludic.nav/index.ludic +++ b/packages/ludic.nav/index.ludic @@ -10,3 +10,4 @@ import "build.ludic" import "query.ludic" import "crowd.ludic" import "resident.ludic" +import "resident_count.ludic" diff --git a/packages/ludic.nav/resident.ludic b/packages/ludic.nav/resident.ludic index 43683a9a..1c74ae6a 100644 --- a/packages/ludic.nav/resident.ludic +++ b/packages/ludic.nav/resident.ludic @@ -51,6 +51,23 @@ export function nav_tiles_keep(nav_st: NavState, kind: int, xs: []float, zs: []f } return changed } +# the same ring by a distance the game answers: near(x, z) is how far (x, z) is from what must be +# loaded (0 inside it). A tile comes in when its centre is within pad of that, and goes only past +# pad + hyst, so a player at an edge does not read one in and out; how many changed +export function nav_tiles_keep_near(nav_st: NavState, kind: int, near: fn(float, float) -> float, pad: float, hyst: float) -> int { + let h = nv_mesh(nav_st, kind) + let f = nav_st.nv_files[kind] + if h == null or f == null { return 0 } + var changed = 0 + for i in 0 .. nvc_index_count(h) { + nvc_index_tile(h, i, nav_st.nv_tile) + let m = near(nav_st.nv_tile[0], nav_st.nv_tile[1]) - pad + let is_in = nav_st.nv_tile[2] > 0.5 + if is_in and m > hyst { changed += nvc_index_out(h, i) } + if not is_in and m < 0.0 { changed += nv_tile_read(h, f, i) } + } + return changed +} function nv_tile_read(h: pointer, f: pointer, i: int) -> int { let size = nvc_index_size(h, i) let buf = nvc_stage(size) @@ -62,24 +79,3 @@ function nv_tile_read(h: pointer, f: pointer, i: int) -> int { } return nvc_index_in(h, i, buf, size) } - -# how many of kind's tiles are in, and (nav_tiles_bytes) how many bytes those hold -export function nav_tiles_in(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, false) } -export function nav_tiles_bytes(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, true) } -function nv_tiles_sum(nav_st: NavState, kind: int, bytes: bool) -> int { - let h = nv_mesh(nav_st, kind) - if h == null { return 0 } - var n = 0 - for i in 0 .. nvc_index_count(h) { - nvc_index_tile(h, i, nav_st.nv_tile) - if nav_st.nv_tile[2] > 0.5 and bytes { n += int(nav_st.nv_tile[3]) } - if nav_st.nv_tile[2] > 0.5 and not bytes { n += 1 } - } - return n -} -# how many tiles kind's file holds in all -export function nav_tiles_all(nav_st: NavState, kind: int) -> int { - let h = nv_mesh(nav_st, kind) - if h == null { return 0 } - return nvc_index_count(h) -} diff --git a/packages/ludic.nav/resident_count.ludic b/packages/ludic.nav/resident_count.ludic new file mode 100644 index 00000000..a38ed00a --- /dev/null +++ b/packages/ludic.nav/resident_count.ludic @@ -0,0 +1,21 @@ +# resident_count.ludic - what a file-backed mesh holds now: its tiles in, their bytes, and how many it has +# how many of kind's tiles are in, and (nav_tiles_bytes) how many bytes those hold +export function nav_tiles_in(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, false) } +export function nav_tiles_bytes(nav_st: NavState, kind: int) -> int { return nv_tiles_sum(nav_st, kind, true) } +function nv_tiles_sum(nav_st: NavState, kind: int, bytes: bool) -> int { + let h = nv_mesh(nav_st, kind) + if h == null { return 0 } + var n = 0 + for i in 0 .. nvc_index_count(h) { + nvc_index_tile(h, i, nav_st.nv_tile) + if nav_st.nv_tile[2] > 0.5 and bytes { n += int(nav_st.nv_tile[3]) } + if nav_st.nv_tile[2] > 0.5 and not bytes { n += 1 } + } + return n +} +# how many tiles kind's file holds in all +export function nav_tiles_all(nav_st: NavState, kind: int) -> int { + let h = nv_mesh(nav_st, kind) + if h == null { return 0 } + return nvc_index_count(h) +} diff --git a/packages/ludic.nav/tests/keep_near_test.ludic b/packages/ludic.nav/tests/keep_near_test.ludic new file mode 100644 index 00000000..2ab699f6 --- /dev/null +++ b/packages/ludic.nav/tests/keep_near_test.ludic @@ -0,0 +1,47 @@ +# keep_near_test.ludic - a file-backed mesh's tiles kept by a distance the game answers (its players' +# zones): a zone round one corner brings in its tile only, growing it brings in its neighbours, an +# edge nudged back and forth changes nothing, and a zone moved far away lets them all go +import "ludic.nav" +import "ludic.base" +import "fakes/meadow.ludic" +program KeepNearTest { + numbers float + + state NearState { + x: float = 0.0 + z: float = 0.0 + r: float = 0.0 + } + # metres from (x, z) to the zone's edge, 0 inside: what ludic.zones' zones_nearest answers + function near(near_st: NearState, x: float, z: float) -> float { + let d = Math.sqrt((x - near_st.x) * (x - near_st.x) + (z - near_st.z) * (z - near_st.z)) + return Math.max(d - near_st.r, 0.0) + } + function in_zone(nav_st: NavState) -> int { return nav_tiles_keep_near(nav_st, NAV_PERSON, fn near, 46.0, 200.0) } + + test "tiles come in within the zone, an edge nudged back and forth changes nothing, and a far zone lets them go" (nav_st: mut NavState, near_st: mut NearState) { + let g = ground(128, 0) + expect(nav_tiled(nav_st, NAV_PERSON, 0.0, 0.0, 64.0, 16, 4096)) + for tz in 0 .. 2 { for tx in 0 .. 2 { expect(nav_tile_build(nav_st, NAV_PERSON, tx, tz, g, new NavConfig) > 0) } } + let path = Os.temp_dir() + "/ludic_nav_near.navmesh" + expect(nav_save_file(nav_st, NAV_PERSON, path)) + nav_reset(nav_st) + expect(nav_load_index(nav_st, NAV_PERSON, path)) + near_st.x = 10.0 + near_st.z = 10.0 + near_st.r = 5.0 + expect_eq(in_zone(nav_st), 1) + expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 1) + near_st.r = 80.0 + expect_eq(in_zone(nav_st), 3) + expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 4) + for k in 0 .. 20 { + near_st.r = 5.0 + float(k % 2) * 100.0 + expect_eq(in_zone(nav_st), 0) + } + near_st.x = 2000.0 + expect_eq(in_zone(nav_st), 4) + expect_eq(nav_tiles_in(nav_st, NAV_PERSON), 0) + nav_reset(nav_st) + } +} From 8b85282502c9a26df6c7132310b75a6a6bf5c35b Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 15:53:23 +0300 Subject: [PATCH 03/14] ludic.zones: a disc per player from their fog, merged into clusters; the simulating packages ask whether a place is simulated zones_set / zones_clear / zones_merge, zones_contains / zones_nearest and the clusters' bounding circles, over fixed arrays of ZONES_MAX (16). WildlifeWorld.active freezes an animal outside (no state change, no dice), NpcWorld.active a walker (and no birth outside), VehicleWorld.active a moored boat, and ThingsWorld.restocks keeps a morning's restock inside; every default is "everywhere", so an unbound game is unchanged. Tests: the merge (near players one cluster, far two, a player leaving splits a chain), contains, nearest, the radius; wildlife, npc, vehicles and things pass as before. Co-Authored-By: Claude Opus 5.5 --- changes/zones.md | 10 +++ packages/README.md | 1 + packages/ludic.npc/census.ludic | 1 + packages/ludic.npc/port.ludic | 1 + packages/ludic.npc/tick.ludic | 2 +- packages/ludic.things/dispatch.ludic | 2 +- packages/ludic.things/port.ludic | 2 + packages/ludic.vehicles/ports.ludic | 2 + packages/ludic.vehicles/ride.ludic | 1 + packages/ludic.wildlife/port.ludic | 2 + packages/ludic.wildlife/run.ludic | 5 +- packages/ludic.zones/README.md | 16 +++++ packages/ludic.zones/ask.ludic | 41 ++++++++++++ packages/ludic.zones/index.ludic | 8 +++ packages/ludic.zones/merge.ludic | 70 +++++++++++++++++++++ packages/ludic.zones/package.ludic | 5 ++ packages/ludic.zones/state.ludic | 40 ++++++++++++ packages/ludic.zones/tests/zones_test.ludic | 52 +++++++++++++++ 18 files changed, 257 insertions(+), 4 deletions(-) create mode 100644 changes/zones.md create mode 100644 packages/ludic.zones/README.md create mode 100644 packages/ludic.zones/ask.ludic create mode 100644 packages/ludic.zones/index.ludic create mode 100644 packages/ludic.zones/merge.ludic create mode 100644 packages/ludic.zones/package.ludic create mode 100644 packages/ludic.zones/state.ludic create mode 100644 packages/ludic.zones/tests/zones_test.ludic diff --git a/changes/zones.md b/changes/zones.md new file mode 100644 index 00000000..558be25a --- /dev/null +++ b/changes/zones.md @@ -0,0 +1,10 @@ +bump: minor +type: feature +**`ludic.zones`: where a world is simulated.** A disc per player, `(x, z, r)` with `r = zones_radius(fog)` +(the fog distance plus 40 m, never under 150 m, 900 m with no fog), overlapping discs merged into +clusters with a bounding circle, and `zones_contains` / `zones_nearest` for everything that asks; fixed +arrays, nothing made per frame. The packages that simulate now ask their world port whether a place is +simulated, with a default of everywhere: `WildlifeWorld.active` (an animal outside is frozen - its +state kept, no dice drawn), `NpcWorld.active` (a walker outside is frozen, and nobody is born outside), +`VehicleWorld.active` (a moored boat outside is left where it lies) and `ThingsWorld.restocks` (a +morning restocks only inside). diff --git a/packages/README.md b/packages/README.md index 62f7ecef..5bbcd517 100644 --- a/packages/README.md +++ b/packages/README.md @@ -22,6 +22,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | [ludic.crafting](ludic.crafting/README.md) | recipes: what goes in and out, where and how long, making, refunding, hold-to-make | | [ludic.effects](ludic.effects/README.md) | timed modifiers that run down in game time (a meal's warmth, a drink's legs) | | [ludic.fire](ludic.fire/README.md) | a camp fire: fuel, the rain on it, warmth at a distance, whether it will cook | +| [ludic.zones](ludic.zones/README.md) | where the world is simulated: a disc per player from their fog, merged into clusters | | [ludic.fishing](ludic.fishing/README.md) | a rod at the water: cast, bite, the reel's fight, the landing; species as an open registry | | [ludic.hints](ludic.hints/README.md) | a rail of what is true now, most urgent first, each a card that teaches it and can be muted by key | | [ludic.i18n](ludic.i18n/README.md) | a game in any language: gettext `.po` files, patterns with holes, plurals, a mod folder, a font per language | diff --git a/packages/ludic.npc/census.ludic b/packages/ludic.npc/census.ludic index fabcb699..f1890c3e 100644 --- a/packages/ludic.npc/census.ludic +++ b/packages/ludic.npc/census.ludic @@ -27,6 +27,7 @@ export function npc_spawn(npc_st: mut NpcState, kind: int) -> NpcPerson { let start = k.start start(p) } else { npc_start_near(npc_st, p, NpcWorld.origin_x(), NpcWorld.origin_z(), 6.0, 30.0) } + if not NpcWorld.active(p.x, p.z) { return null } # nobody is born outside every zone; the slot waits p.active = true npc_st.np_n += 1 NpcWorld.born(p) diff --git a/packages/ludic.npc/port.ludic b/packages/ludic.npc/port.ludic index 908588a4..69aa96e8 100644 --- a/packages/ludic.npc/port.ludic +++ b/packages/ludic.npc/port.ludic @@ -5,6 +5,7 @@ export port NpcWorld { water: fn(float, float) -> float = fn nw_dry # the water's surface there slope: fn(float, float) -> float = fn nw_zero2 allowed: fn(float, float) -> bool = fn nw_yes2 # a person may stop here (not the player's camp) + active: fn(float, float) -> bool = fn nw_yes2 # simulated here: outside, a walker is frozen and none is born # (x, z, r, feet, head): true when a still thing takes a body of radius r there; pushed_x / _z say where push: fn(float, float, float, float, float) -> bool = fn nw_no_push pushed_x: fn() -> float = fn nw_asked_x diff --git a/packages/ludic.npc/tick.ludic b/packages/ludic.npc/tick.ludic index ae7c8a48..34a2dae3 100644 --- a/packages/ludic.npc/tick.ludic +++ b/packages/ludic.npc/tick.ludic @@ -6,7 +6,7 @@ export function npc_tick(npc_st: mut NpcState, dt: float) -> void { np_census(npc_st) for i in 0 .. len(npc_st.np_walkers) { let p = npc_st.np_walkers[i] - if not p.active or np_leaves(npc_st, p) { continue } + if not p.active or not NpcWorld.active(p.x, p.z) or np_leaves(npc_st, p) { continue } p.goal_speed = 0.0 if p.act == NPCA_WALK { npc_walk(npc_st, p, dt) } else { npc_act(npc_st, p, dt) } npc_notice(npc_st, p) diff --git a/packages/ludic.things/dispatch.ludic b/packages/ludic.things/dispatch.ludic index 737bb017..7b18648e 100644 --- a/packages/ludic.things/dispatch.ludic +++ b/packages/ludic.things/dispatch.ludic @@ -70,7 +70,7 @@ export function things_restock(things_st: ThingsState) -> void { let all = things_all(things_st) for i in 0 .. len(all) { let t = all[i] - if thing_is_put_down(t) { continue } + if thing_is_put_down(t) or not ThingsWorld.restocks(t.x, t.z) { continue } if not ThingKinds[t.kind].keeps { t.used = 0 } if not t.active { thing_show(t) } } diff --git a/packages/ludic.things/port.ludic b/packages/ludic.things/port.ludic index 8070e0b6..00a633a8 100644 --- a/packages/ludic.things/port.ludic +++ b/packages/ludic.things/port.ludic @@ -5,6 +5,7 @@ export port ThingsWorld { placed: fn(Thing) -> void = fn th_nothing # a Thing exists now: draw it removed: fn(Thing) -> void = fn th_nothing # it is gone for good: stop drawing it shown: fn(Thing) -> void = fn th_nothing # its `active` changed: show or hide it + restocks: fn(float, float) -> bool = fn th_yes2 # a morning restocks here (inside a zone) moved: fn(Thing) -> void = fn th_nothing # it stands somewhere else now keeps: fn(Thing) -> bool = fn th_kind_saves # the save's section carries it put_down: fn(Thing) -> bool = fn th_kind_put_down # set down by someone: a restock leaves it be @@ -12,5 +13,6 @@ export port ThingsWorld { } function th_flat(x: float, z: float) -> float { return 0.0 } function th_nothing(t: Thing) -> void { } +function th_yes2(x: float, z: float) -> bool { return true } function th_kind_saves(t: Thing) -> bool { return thing_kind_ok(t.kind) and ThingKinds[t.kind].saved } function th_kind_put_down(t: Thing) -> bool { return thing_kind_ok(t.kind) and ThingKinds[t.kind].put_down } diff --git a/packages/ludic.vehicles/ports.ludic b/packages/ludic.vehicles/ports.ludic index a6c10395..b60f5419 100644 --- a/packages/ludic.vehicles/ports.ludic +++ b/packages/ludic.vehicles/ports.ludic @@ -11,6 +11,7 @@ export port VehicleWorld { made: fn(Vehicle) -> void = fn ve__nothing # a new vehicle: draw it placed: fn(Vehicle) -> void = fn ve__nothing # it moved: put its model there gone: fn(Vehicle) -> void = fn ve__nothing # its owner left: take it away + active: fn(float, float) -> bool = fn ve__yes2 # simulated here: outside, a moored boat is left as it is } # a boat's hull in the world's physics (Maroon Lake: a buoyant Jolt body), named by the vehicle's @@ -70,6 +71,7 @@ export state VehiclesState { function ve__dry(x: float, z: float) -> float { return -10000.0 } function ve__calm(x: float, z: float, fx: float, fz: float) -> float { return 0.0 } function ve__zero() -> float { return 0.0 } +function ve__yes2(x: float, z: float) -> bool { return true } function ve__next(vehicles_st: mut VehiclesState) -> int { vehicles_st.ve_ids += 1 return vehicles_st.ve_ids diff --git a/packages/ludic.vehicles/ride.ludic b/packages/ludic.vehicles/ride.ludic index 53eb95f5..3ea53bcd 100644 --- a/packages/ludic.vehicles/ride.ludic +++ b/packages/ludic.vehicles/ride.ludic @@ -64,6 +64,7 @@ export function vehicles_tick(vehicles_st: VehiclesState, dt: float) -> void { if w > 0.0 { VehicleHull.drift(v.nid, -Math.sin(wy) * w, -Math.cos(wy) * w) } continue } + if not VehicleWorld.active(v.x, v.z) { continue } # outside every zone: left where it lies ve_hull_read(v) ve_hold(v) VehicleWorld.placed(v) diff --git a/packages/ludic.wildlife/port.ludic b/packages/ludic.wildlife/port.ludic index 7c599b5c..34bd1198 100644 --- a/packages/ludic.wildlife/port.ludic +++ b/packages/ludic.wildlife/port.ludic @@ -38,6 +38,7 @@ export port WildlifeWorld { taught: fn() -> bool = fn wl_no0 # food carries further, feeders mind less about: fn(WildAnimal) -> int = fn wl_about # WILD_ABOUT, _AWAY or _GONE ranges: fn(int) -> int = fn wl_two # ranges a species gets on this map + active: fn(float, float) -> bool = fn wl_yes2 # simulated here: outside, an animal is frozen per_range: fn(int) -> int = fn wl_three # and how many live in each spot: fn(WildAnimal, bool, WildSpot) -> bool = fn wl_no_spot # water (true) or forage for it lures: fn() -> int = fn wl_none0 # how many things to ask `lure` about @@ -62,6 +63,7 @@ function wl_no0() -> bool { return false } function wl_no1(p: int) -> bool { return false } function wl_no2(x: float, z: float) -> bool { return false } function wl_yes1(u: int) -> bool { return true } +function wl_yes2(x: float, z: float) -> bool { return true } function wl_clear(ax: float, ay: float, az: float, bx: float, by: float, bz: float) -> bool { return true } function wl_no_push(x: float, z: float, r: float, y0: float, y1: float) -> bool { return false } function wl_asked_x(wildlife_st: WildlifeState) -> float { return wildlife_st.wl_ask_x } diff --git a/packages/ludic.wildlife/run.ludic b/packages/ludic.wildlife/run.ludic index 248333e5..437fd61b 100644 --- a/packages/ludic.wildlife/run.ludic +++ b/packages/ludic.wildlife/run.ludic @@ -1,5 +1,5 @@ -# run.ludic - one step of every animal: the port says whether it is about at this hour; anything -# within 900 m of a player thinks; the gait follows the ground covered; player 0 sees what is near +# run.ludic - one step of every animal inside a zone (WildlifeWorld.active): the port says whether it +# is about at this hour; within 900 m of a player it thinks; player 0 sees what is near export function wildlife_tick(wildlife_st: mut WildlifeState, dt: float, gh: float) -> void { wl_seed_scan(wildlife_st, dt) let all = wildlife_all(wildlife_st) @@ -9,6 +9,7 @@ export function wildlife_tick(wildlife_st: mut WildlifeState, dt: float, gh: flo function wl_tick_one(wildlife_st: mut WildlifeState, a: WildAnimal, dt: float, gh: float) -> void { if not a.alive { return } + if not WildlifeWorld.active(a.x, a.z) { return } # outside every zone: its state kept, no dice drawn let about = WildlifeWorld.about(a) if about == WILD_AWAY { a.shown = false diff --git a/packages/ludic.zones/README.md b/packages/ludic.zones/README.md new file mode 100644 index 00000000..979d7f82 --- /dev/null +++ b/packages/ludic.zones/README.md @@ -0,0 +1,16 @@ +# ludic.zones + +Where a world is simulated: a disc per player - `(x, z, r)`, `r = zones_radius(fog)`, that is the +fog distance plus 40 m and never under 150 m, 900 m with no fog - and the discs that overlap merged +into clusters with a bounding circle. Uses `ludic.base` and nothing else. + +- `zones_set(pid, x, z, r)`, `zones_clear(pid)`, `zones_reset()`; `zones_merge()` once a frame + after the discs are set, for the clusters. +- `zones_contains(x, z)`: inside any disc. `zones_nearest(x, z)`: metres to the nearest disc's + edge, 0 inside. With no disc set, everywhere is inside (0) - a world before its first frame + freezes nothing. +- `zones_clusters()`, `zones_cluster_x / _z / _r / _n(c)`, `zones_cluster_of(pid)`. + +The rules it keeps: fixed arrays of `ZONES_MAX` (16) slots, nothing made after the state; the +questions allocate nothing. A world gates its ticks on `zones_contains` so what is outside every +zone keeps its state and draws no dice. diff --git a/packages/ludic.zones/ask.ludic b/packages/ludic.zones/ask.ludic new file mode 100644 index 00000000..cefe5ba7 --- /dev/null +++ b/packages/ludic.zones/ask.ludic @@ -0,0 +1,41 @@ +# ask.ludic - the questions: inside any disc, how far outside the nearest, and the clusters. With no +# disc set yet (a world before its first frame) everywhere is inside, so nothing freezes by accident +export function zones_any(zones_st: ZonesState) -> bool { + for i in 0 .. ZONES_MAX { if zones_st.zn_on[i] != 0 { return true } } + return false +} + +export function zones_contains(zones_st: ZonesState, x: float, z: float) -> bool { + var any = false + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 { continue } + any = true + let dx = x - zones_st.zn_x[i] + let dz = z - zones_st.zn_z[i] + if dx * dx + dz * dz <= zones_st.zn_r[i] * zones_st.zn_r[i] { return true } + } + return not any +} + +# metres from (x, z) to the edge of the nearest disc: 0 inside one (and 0 with no disc set) +export function zones_nearest(zones_st: ZonesState, x: float, z: float) -> float { + var best = -1.0 + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 { continue } + let dx = x - zones_st.zn_x[i] + let dz = z - zones_st.zn_z[i] + let d = Math.max(Math.sqrt(dx * dx + dz * dz) - zones_st.zn_r[i], 0.0) + if best < 0.0 or d < best { best = d } + } + if best < 0.0 { return 0.0 } + return best +} + +export function zones_on(zones_st: ZonesState, pid: int) -> bool { return pid >= 0 and pid < ZONES_MAX and zones_st.zn_on[pid] != 0 } +export function zones_r(zones_st: ZonesState, pid: int) -> float { return zones_st.zn_r[pid] } +export function zones_cluster_of(zones_st: ZonesState, pid: int) -> int { return zones_st.zn_cl[pid] } +export function zones_clusters(zones_st: ZonesState) -> int { return zones_st.zn_ncl } +export function zones_cluster_x(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cx[c] } +export function zones_cluster_z(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cz[c] } +export function zones_cluster_r(zones_st: ZonesState, c: int) -> float { return zones_st.zn_cr[c] } +export function zones_cluster_n(zones_st: ZonesState, c: int) -> int { return zones_st.zn_cn[c] } diff --git a/packages/ludic.zones/index.ludic b/packages/ludic.zones/index.ludic new file mode 100644 index 00000000..82819c01 --- /dev/null +++ b/packages/ludic.zones/index.ludic @@ -0,0 +1,8 @@ +# ludic.zones - a disc per player (x, z, r), the discs that overlap merged into clusters with a +# bounding circle, and the questions a world asks of them; fixed arrays, nothing made per frame +module ludic_zones uses ludic_base +numbers float +import "ludic.base" +import "state.ludic" +import "merge.ludic" +import "ask.ludic" diff --git a/packages/ludic.zones/merge.ludic b/packages/ludic.zones/merge.ludic new file mode 100644 index 00000000..884f596c --- /dev/null +++ b/packages/ludic.zones/merge.ludic @@ -0,0 +1,70 @@ +# merge.ludic - discs that overlap (or touch) are one cluster, transitively; a cluster's bounding +# circle is centred on its discs' box and reaches the farthest edge +export function zones_merge(zones_st: mut ZonesState) -> void { + for i in 0 .. ZONES_MAX { zones_st.zn_up[i] = i } + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 { continue } + for j in i + 1 .. ZONES_MAX { if zones_st.zn_on[j] != 0 and zn_touch(zones_st, i, j) { zn_join(zones_st, i, j) } } + } + zones_st.zn_ncl = 0 + for i in 0 .. ZONES_MAX { zones_st.zn_cl[i] = -1 } + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 { continue } + let root = zn_root(zones_st, i) + if zones_st.zn_cl[root] < 0 { + zones_st.zn_cl[root] = zones_st.zn_ncl + zones_st.zn_ncl += 1 + } + zones_st.zn_cl[i] = zones_st.zn_cl[root] + } + for c in 0 .. zones_st.zn_ncl { zn_bound(zones_st, c) } +} + +function zn_touch(zones_st: ZonesState, i: int, j: int) -> bool { + let dx = zones_st.zn_x[i] - zones_st.zn_x[j] + let dz = zones_st.zn_z[i] - zones_st.zn_z[j] + let rr = zones_st.zn_r[i] + zones_st.zn_r[j] + return dx * dx + dz * dz <= rr * rr +} + +function zn_root(zones_st: ZonesState, i: int) -> int { + var k = i + while zones_st.zn_up[k] != k { k = zones_st.zn_up[k] } + return k +} + +function zn_join(zones_st: mut ZonesState, i: int, j: int) -> void { + let a = zn_root(zones_st, i) + let b = zn_root(zones_st, j) + if a < b { zones_st.zn_up[b] = a } + if b < a { zones_st.zn_up[a] = b } +} + +function zn_bound(zones_st: mut ZonesState, c: int) -> void { + var x0 = 1000000000.0 + var x1 = -1000000000.0 + var z0 = 1000000000.0 + var z1 = -1000000000.0 + var n = 0 + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 or zones_st.zn_cl[i] != c { continue } + x0 = Math.min(x0, zones_st.zn_x[i] - zones_st.zn_r[i]) + x1 = Math.max(x1, zones_st.zn_x[i] + zones_st.zn_r[i]) + z0 = Math.min(z0, zones_st.zn_z[i] - zones_st.zn_r[i]) + z1 = Math.max(z1, zones_st.zn_z[i] + zones_st.zn_r[i]) + n += 1 + } + let cx = (x0 + x1) * 0.5 + let cz = (z0 + z1) * 0.5 + var r = 0.0 + for i in 0 .. ZONES_MAX { + if zones_st.zn_on[i] == 0 or zones_st.zn_cl[i] != c { continue } + let dx = zones_st.zn_x[i] - cx + let dz = zones_st.zn_z[i] - cz + r = Math.max(r, Math.sqrt(dx * dx + dz * dz) + zones_st.zn_r[i]) + } + zones_st.zn_cx[c] = cx + zones_st.zn_cz[c] = cz + zones_st.zn_cr[c] = r + zones_st.zn_cn[c] = n +} diff --git a/packages/ludic.zones/package.ludic b/packages/ludic.zones/package.ludic new file mode 100644 index 00000000..1e094e5d --- /dev/null +++ b/packages/ludic.zones/package.ludic @@ -0,0 +1,5 @@ +# ludic.zones - where the world is simulated: a disc per player, overlapping discs merged into +# clusters, asked whether a point is inside any. Uses ludic.base and nothing else. +package "ludic.zones" +version "0.1.0" +kind source diff --git a/packages/ludic.zones/state.ludic b/packages/ludic.zones/state.ludic new file mode 100644 index 00000000..48a76c6e --- /dev/null +++ b/packages/ludic.zones/state.ludic @@ -0,0 +1,40 @@ +# state.ludic - the discs by player slot and the clusters the last merge made +export const ZONES_MAX: int = 16 # player slots a world can hold (at least PLY_MAX) +export const ZONES_NO_FOG: float = 900.0 # the radius of a player who sees to the horizon +export const ZONES_PAST_FOG: float = 40.0 # simulated this far past what a player can see +export const ZONES_LEAST: float = 150.0 # and never less than this + +export state ZonesState { + zn_on: words = words(ZONES_MAX) + zn_x: []float = floats(ZONES_MAX) + zn_z: []float = floats(ZONES_MAX) + zn_r: []float = floats(ZONES_MAX) + zn_cl: words = words(ZONES_MAX) # each disc's cluster, after zones_merge + zn_up: words = words(ZONES_MAX) # the merge's union-find + zn_ncl: int = 0 + zn_cx: []float = floats(ZONES_MAX) # each cluster's bounding circle + zn_cz: []float = floats(ZONES_MAX) + zn_cr: []float = floats(ZONES_MAX) + zn_cn: words = words(ZONES_MAX) # how many discs it holds +} + +# a player's radius from how far they see: fog_dist <= 0 is no fog +export function zones_radius(fog_dist: float) -> float { + if fog_dist <= 0.0 { return ZONES_NO_FOG } + return Math.max(fog_dist + ZONES_PAST_FOG, ZONES_LEAST) +} + +export function zones_set(zones_st: mut ZonesState, pid: int, x: float, z: float, r: float) -> void { + if pid < 0 or pid >= ZONES_MAX { return } + zones_st.zn_on[pid] = 1 + zones_st.zn_x[pid] = x + zones_st.zn_z[pid] = z + zones_st.zn_r[pid] = r +} + +export function zones_clear(zones_st: mut ZonesState, pid: int) -> void { if pid >= 0 and pid < ZONES_MAX { zones_st.zn_on[pid] = 0 } } + +export function zones_reset(zones_st: mut ZonesState) -> void { + for i in 0 .. ZONES_MAX { zones_st.zn_on[i] = 0 } + zones_st.zn_ncl = 0 +} diff --git a/packages/ludic.zones/tests/zones_test.ludic b/packages/ludic.zones/tests/zones_test.ludic new file mode 100644 index 00000000..1206f13b --- /dev/null +++ b/packages/ludic.zones/tests/zones_test.ludic @@ -0,0 +1,52 @@ +# zones_test.ludic - discs merged into clusters (near players one, far ones two, a player leaving +# splits the chain), inside or not, how far to the nearest edge, and the radius a fog gives +import "ludic.zones" +import "ludic.base" +program ZonesTest { + numbers float + + test "two near players are one cluster, two far apart are two" (zones_st: mut ZonesState) { + zones_reset(zones_st) + zones_set(zones_st, 0, 0.0, 0.0, 200.0) + zones_set(zones_st, 1, 300.0, 0.0, 150.0) + zones_merge(zones_st) + expect_eq(zones_clusters(zones_st), 1) + expect_eq(zones_cluster_of(zones_st, 0), zones_cluster_of(zones_st, 1)) + expect_eq(zones_cluster_n(zones_st, 0), 2) + expect(zones_cluster_r(zones_st, 0) >= 325.0) + zones_set(zones_st, 1, 1000.0, 0.0, 150.0) + zones_merge(zones_st) + expect_eq(zones_clusters(zones_st), 2) + expect(zones_cluster_of(zones_st, 0) != zones_cluster_of(zones_st, 1)) + } + + test "a player leaving the middle of a chain splits it" (zones_st: mut ZonesState) { + zones_reset(zones_st) + zones_set(zones_st, 0, 0.0, 0.0, 200.0) + zones_set(zones_st, 3, 350.0, 0.0, 200.0) + zones_set(zones_st, 5, 700.0, 0.0, 200.0) + zones_merge(zones_st) + expect_eq(zones_clusters(zones_st), 1) + zones_clear(zones_st, 3) + zones_merge(zones_st) + expect_eq(zones_clusters(zones_st), 2) + expect(zones_cluster_of(zones_st, 0) != zones_cluster_of(zones_st, 5)) + } + + test "inside, the distance to the nearest edge, and nothing set is everywhere" (zones_st: mut ZonesState) { + zones_reset(zones_st) + expect(zones_contains(zones_st, 5000.0, 5000.0)) + expect(zones_nearest(zones_st, 5000.0, 5000.0) == 0.0) + zones_set(zones_st, 2, 100.0, 100.0, 150.0) + expect(zones_contains(zones_st, 200.0, 100.0)) + expect(not zones_contains(zones_st, 300.0, 100.0)) + expect(zones_nearest(zones_st, 200.0, 100.0) == 0.0) + expect(Math.abs(zones_nearest(zones_st, 350.0, 100.0) - 100.0) < 0.001) + } + + test "a player's radius: past the fog, never under the least, the horizon with none" (zones_st: mut ZonesState) { + expect(zones_radius(0.0) == ZONES_NO_FOG) + expect(zones_radius(30.0) == ZONES_LEAST) + expect(zones_radius(500.0) == 540.0) + } +} From 181d317d20f63eeb8e6335747ca1895167554f98 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Tue, 29 Sep 2026 16:01:43 +0300 Subject: [PATCH 04/14] ludic.ui: