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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 15:53:23 +03:00
parent 2958539514
commit 8b85282502
18 changed files with 257 additions and 4 deletions

View file

@ -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.

View file

@ -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] }

View file

@ -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"

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}