Merge branch 'lang/foundations' into r3d/fog-wall

This commit is contained in:
Orkun ÇAKILKAYA 2026-09-29 17:43:14 +03:00
commit f8d811aa97
78 changed files with 43349 additions and 40047 deletions

View file

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

View file

@ -0,0 +1,66 @@
# baked.ludic - what is deterministic made at build time (`ludic bake`) by the game's own code, and
# read at run time. Every baked file: a 32-byte header - "LBAK", the header's format, the bake's
# generator version, the payload's offset, a hash of its inputs, the payload's length - its key, and
# the payload, whose layout is the bake's own. The runtime checks the header, never the inputs:
# `ludic bake --check` holds a baked file to its inputs at build time.
export const BAKE_FORMAT: int = 1
const BAKE_HEAD: int = 32
export state BakeState {
said: []string = new []string # the keys a dev build has said it made at run time
}
# a map's bake: assets/baked/maps/<map>/<file>, which ships in that map's own pack (.lmap), never the
# game's; anything else: assets/baked/<file>, in the game's pack
export function baked_path(map: string, file: string) -> string {
if len(map) == 0 { return "assets/baked/" + file }
return "assets/baked/maps/" + map + "/" + file
}
# the payload of a baked file that is this bake at this version, or null (missing, not a baked file,
# another format, another key or version: the caller makes it at run time)
export function baked_open(path: string, key: string, version: int) -> []byte {
let b = Fs.read_bytes(path)
if b == null or len(b) < BAKE_HEAD { return null }
let off = bk_head_ok(b, len(b), key, version)
if off < 0 or long(off) + bk_u64(b, 24) > long(len(b)) { return null }
return view(b, off, int(bk_u64(b, 24))) # the payload, in place: no copy of a large bake
}
# the payload's offset when `b` (its first `have` bytes) is this bake's header and key, else -1
function bk_head_ok(b: []byte, have: int, key: string, version: int) -> int {
if have < BAKE_HEAD or b[0] != 76 or b[1] != 66 or b[2] != 65 or b[3] != 75 { return -1 } # "LBAK"
if bk_u32(b, 4) != BAKE_FORMAT or bk_u32(b, 8) != version { return -1 }
let off = bk_u32(b, 12)
if off < BAKE_HEAD + len(key) + 1 or have < BAKE_HEAD + len(key) + 1 { return -1 }
for i in 0 .. len(key) { if b[BAKE_HEAD + i] != key[i] { return -1 } }
if b[BAKE_HEAD + len(key)] != 0 { return -1 }
return off
}
# a dev build's line, once a key: the bake is missing or stale, so it is made at run time
export function bake_missing(bake_st: mut BakeState, key: string) -> void {
for i in 0 .. len(bake_st.said) { if bake_st.said[i] == key { return } }
push(bake_st.said, key)
print(`bake: {key} missing or stale - made at run time (ludic bake)`)
}
# a baked file written: its header, key and `n` bytes of payload; the directories made
export function bake_write(path: string, key: string, version: int, inputs_hash: long, payload: []byte, n: int) -> bool {
let off = BAKE_HEAD + ((len(key) + 1 + 7) / 8) * 8
let b = buffer(off + n)
b[0] = 76
b[1] = 66
b[2] = 65
b[3] = 75
bk_put32(b, 4, BAKE_FORMAT)
bk_put32(b, 8, version)
bk_put32(b, 12, off)
bk_put64(b, 16, inputs_hash)
bk_put64(b, 24, long(n))
for i in 0 .. len(key) { b[BAKE_HEAD + i] = key[i] }
for i in 0 .. n { b[off + i] = payload[i] }
let slash = bk_last_slash(path)
if slash > 0 { Fs.mkdir(path[0 .. slash]) }
return Fs.write_bytes(path, b, off + n)
}

View file

@ -0,0 +1,40 @@
# baked_hash.ludic - a bake's inputs as one number, and the header's little-endian fields. The hash
# is FNV-1a 64 over each input in order: its path's bytes, a 0, then its bytes (tools/bake/baked.py
# and the check compute the same)
const BK_FNV_OFFSET: long = -3750763034362895579 # 0xcbf29ce484222325
const BK_FNV_PRIME: long = 1099511628211
# `inputs` space-separated, as a Bakes row lists them; a missing input hashes as its path alone
export function bake_inputs_hash(inputs: string) -> long {
var h = BK_FNV_OFFSET
var a = 0
while a < len(inputs) {
while a < len(inputs) and inputs[a] == 32 { a += 1 }
var e = a
while e < len(inputs) and inputs[e] != 32 { e += 1 }
if e > a {
let p = inputs[a .. e]
for i in 0 .. len(p) { h = bk_fnv(h, p[i]) }
h = bk_fnv(h, 0)
let b = Fs.read_bytes(p)
if b != null { for i in 0 .. len(b) { h = bk_fnv(h, b[i]) } }
}
a = e
}
return h
}
function bk_fnv(h: long, c: int) -> long { return (h ^ long(c & 255)) * BK_FNV_PRIME }
function bk_u32(b: []byte, at: int) -> int { return (b[at] & 255) | ((b[at + 1] & 255) << 8) | ((b[at + 2] & 255) << 16) | ((b[at + 3] & 255) << 24) }
function bk_u64(b: []byte, at: int) -> long { return long(bk_u32(b, at)) & 4294967295 | (long(bk_u32(b, at + 4)) << 32) }
function bk_put32(b: []byte, at: int, v: int) -> void {
for i in 0 .. 4 { b[at + i] = (v >> (i * 8)) & 255 }
}
function bk_put64(b: []byte, at: int, v: long) -> void {
for i in 0 .. 8 { b[at + i] = int((v >> long(i * 8)) & 255) }
}
function bk_last_slash(p: string) -> int {
var k = -1
for i in 0 .. len(p) { if p[i] == 47 { k = i } }
return k
}

View file

@ -0,0 +1,72 @@
# baked_stream.ludic - a large bake read a piece at a time (placements by chunk, terrain by tile): the
# header checked once, the file kept open, and each read into the caller's own buffer at a payload
# offset, so a ring streaming chunks in makes nothing per chunk. Pack-aware (file_open)
export property BakedFile {
f: pointer = null
off: int = 0 # where the payload starts in the file
n: long = 0 # the payload's length
}
# this bake's file kept open for reading, or null (missing, or another format, key or version)
@alloc_ok("once per baked file opened: its header and record")
export function baked_open_range(path: string, key: string, version: int) -> BakedFile {
let f = file_open(path, "rb")
if f == null { return null }
let want = 32 + len(key) + 1
let h = buffer(want)
if file_read(f, h, want) != want or bk_head_ok(h, want, key, version) < 0 {
file_close(f)
return null
}
let bf = new BakedFile
bf.f = f
bf.off = bk_u32(h, 12)
bf.n = bk_u64(h, 24)
return bf
}
# `n` bytes from payload offset `at` into `into` (from its start): the count read, 0 on a failure;
# held to the buffer and to the payload
export function baked_read(bf: BakedFile, at: int, n: int, into: []byte) -> int {
if bf == null or bf.f == null or at < 0 or n <= 0 { return 0 }
var k = n
if k > len(into) { k = len(into) }
if long(at) + long(k) > bf.n { k = int(bf.n - long(at)) }
if k <= 0 { return 0 }
if file_seek(bf.f, bf.off + at, 0) != 0 { return 0 }
return file_read(bf.f, into, k)
}
export function baked_len(bf: BakedFile) -> long {
if bf == null { return 0 }
return bf.n
}
export function baked_close(bf: BakedFile) -> void {
if bf == null or bf.f == null { return }
file_close(bf.f)
bf.f = null
}
# what a baked file says it is, its header alone read (the stale check: key, version, inputs hash)
export property BakedHead {
key: string = ""
version: int = 0
inputs: long = 0
}
@alloc_ok("the stale check: once a baked file")
export function baked_head(path: string) -> BakedHead {
let f = file_open(path, "rb")
if f == null { return null }
let h = buffer(288)
let got = file_read(f, h, 288)
file_close(f)
if got < 32 or h[0] != 76 or h[1] != 66 or h[2] != 65 or h[3] != 75 or bk_u32(h, 4) != BAKE_FORMAT { return null }
var e = 32
while e < got and h[e] != 0 { e += 1 }
let r = new BakedHead
r.key = intern(text_of(view(h, 32, e - 32), e - 32))
r.version = bk_u32(h, 8)
r.inputs = bk_u64(h, 16)
return r
}

View file

@ -10,6 +10,9 @@ import "queue_ring.ludic"
import "text_buf.ludic"
import "text_intern.ludic"
import "text_ring.ludic"
import "baked.ludic"
import "baked_hash.ludic"
import "baked_stream.ludic"
import "rng.ludic"
import "save.ludic"
import "save_fields.ludic"

View file

@ -0,0 +1,47 @@
# baked_test.ludic - a baked file round-trips its payload; another version or key reads as null; the
# inputs hash is FNV-1a 64 over path, 0 and bytes (tools/bake/baked.py computes the same)
import "ludic.base"
program BakedTest {
function dir() -> string { return Os.temp_dir() + "/ludic-baked-test" }
test "a baked file's payload comes back, for its key and version only" {
let p = buffer(5)
for i in 0 .. 5 { p[i] = 65 + i }
let path = dir() + "/a/b.bin"
expect(bake_write(path, "tiles", 3, 42, p, 5))
let b = baked_open(path, "tiles", 3)
expect(b != null)
expect_eq(len(b), 5)
expect_eq(b[4], 69)
expect(baked_open(path, "tiles", 4) == null)
expect(baked_open(path, "tile", 3) == null)
expect(baked_open(dir() + "/none.bin", "tiles", 3) == null)
}
test "a streamed bake reads a piece of its payload at an offset into the caller's buffer" {
let p = buffer(10)
for i in 0 .. 10 { p[i] = 48 + i }
let path = dir() + "/s.bin"
expect(bake_write(path, "chunks", 1, 0, p, 10))
let bf = baked_open_range(path, "chunks", 1)
expect(bf != null)
expect(baked_len(bf) == 10)
let into = buffer(4)
expect_eq(baked_read(bf, 6, 4, into), 4)
expect_eq(into[0], 54)
expect_eq(baked_read(bf, 8, 4, into), 2)
baked_close(bf)
expect(baked_open_range(path, "chunks", 2) == null)
}
test "the inputs hash follows the path and the bytes" {
Fs.write_text(dir() + "/in.txt", "abc")
let h1 = bake_inputs_hash(dir() + "/in.txt")
Fs.write_text(dir() + "/in.txt", "abd")
expect(bake_inputs_hash(dir() + "/in.txt") != h1)
expect(bake_inputs_hash("") == -3750763034362895579)
}
test "a dev build says a missing bake once" (bake_st: mut BakeState) {
bake_missing(bake_st, "k")
bake_missing(bake_st, "k")
expect_eq(len(bake_st.said), 1)
}
}

View file

@ -10,3 +10,4 @@ import "build.ludic"
import "query.ludic"
import "crowd.ludic"
import "resident.ludic"
import "resident_count.ludic"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,10 @@ export property PhysPose {
function ph_add(physics_st: mut PhysicsState, shape: int, x: float, y: float, z: float, yaw: float, motion: int, layer: int, mass: float) -> int {
let s = ph_shape(physics_st, shape)
if physics_st.ph_world == null or s == null { return -1 }
return jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
let id = jph_body_add(physics_st.ph_world, s, x, y, z, yaw, motion, layer, mass, 0.6, 0.1)
# 25.5a: a world full to the bodies it was opened for is a failure, never a thing quietly not there
if id < 0 and jph_world_bodies(physics_st.ph_world) >= physics_st.ph_max { Mem.over("a physics body past phys_open's max_bodies") }
return id
}
# the ground: a heightfield or a mesh at the world's origin

View file

@ -8,6 +8,7 @@ import "state.ludic"
import "fact_pool.ludic"
import "shape_ids.ludic"
import "owned.ludic"
import "world.ludic"
import "shapes.ludic"
import "bodies.ludic"
import "queries.ludic"

Binary file not shown.

View file

@ -1,6 +1,6 @@
# native.ludic - the shim's symbols (native/shim/jph_shim.cpp). A world and a shape are handles
# the package keeps; a body is an int id. None of this is exported.
extern function jph_world_new(max_bodies: int, threads: int) -> pointer = "jph_world_new"
extern function jph_world_new(max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> pointer = "jph_world_new"
extern function jph_world_free(w: pointer) -> void = "jph_world_free"
extern function jph_world_gravity(w: pointer, y: float) -> void = "jph_world_gravity"
extern function jph_world_step(w: pointer, dt: float, sub: int) -> int = "jph_world_step"

View file

@ -93,11 +93,11 @@ public:
struct World {
// pairs and contacts are for what MOVES: a valley of sixty thousand still trunks makes none
// between them, so these are Jolt's own sizes, not the body count's; the temporary memory falls
// back to the heap rather than aborting a step
World(int max_bodies, int threads)
: temp(32 * 1024 * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, 65536, 20480, bpl, ovb, olp);
// between them, so they are sized for the moving things, not the body count; the temporary memory
// falls back to the heap rather than aborting a step. All four are the package's (phys_open)
World(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb)
: temp(size_t(temp_kb) * 1024), jobs(cMaxPhysicsJobs, cMaxPhysicsBarriers, threads) {
sys.Init(max_bodies, 0, max_pairs, max_contacts, bpl, ovb, olp);
sys.SetContactListener(&contacts);
}
TempAllocatorImplWithMallocFallback temp;

View file

@ -1,13 +1,14 @@
// jph_world.inl - a physics world: made, stepped, emptied of its contacts, freed
// a world for up to max_bodies; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads) {
// a world for up to max_bodies, max_pairs touching pairs and max_contacts contact constraints, with a
// temp heap of temp_kb; threads 0 means the machine's cores less one, at most four
JPH_SHIM void *jph_world_new(int max_bodies, int threads, int max_pairs, int max_contacts, int temp_kb) {
if (jph_init() != 0) return nullptr;
if (threads <= 0) {
int hw = int(std::thread::hardware_concurrency()) - 1;
threads = hw < 1 ? 1 : (hw > 4 ? 4 : hw);
}
World *w = new World(max_bodies, threads);
World *w = new World(max_bodies, threads, max_pairs, max_contacts, temp_kb);
w->sys.SetGravity(Vec3(0.0f, -9.81f, 0.0f));
return w;
}

View file

@ -1,43 +1,5 @@
# shapes.ludic - the world opened and closed, and shapes: made in C, kept here, named by an int.
# A shape may be used by any number of bodies; phys_close frees them all.
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads)
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}
# shapes.ludic - shapes: made in C, kept here, named by an int. A shape may be used by any number of
# bodies; phys_close (world.ludic) frees them all.
# a crate: half extents in metres
@creates(PhysShape)

View file

@ -42,6 +42,7 @@ export state PhysicsState {
ph_step: float = 0.016666668 # seconds a step
ph_sub: int = 1 # collision steps in each
ph_max_steps: int = 4 # a slow frame catches up this far and no further
ph_max: int = 0 # the bodies the world was opened for: one more is a failure, not a -1
ph_acc: float = 0.0
ph_steps: int = 0
ph_gen: int = 0 # how many worlds have been opened: an id from an older one is stale
@ -75,3 +76,9 @@ export function phys_config(physics_st: mut PhysicsState, step: float, sub: int,
physics_st.ph_hard = hard
}
function ph_facts__new() -> Queue<PhysFact> { return queue_new("physics.facts") }
# what moves, sized: touching pairs, contact constraints and the step's temp heap (KB). A valley's still
# things make no pairs between them; drops, boats and the walkers do. Past a size a step fails loudly
export const PH_PAIRS: int = 4096
export const PH_CONTACTS: int = 2048
export const PH_TEMP_KB: int = 4096

View file

@ -23,7 +23,8 @@ export function phys_step(physics_st: mut PhysicsState) -> void {
if physics_st.ph_world == null { return }
ph_buffers()
ph_buoy_all(physics_st)
jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub)
# 25.5a: Jolt's pairs or contacts full (PH_PAIRS, PH_CONTACTS) is a failure: past it things pass through
if jph_world_step(physics_st.ph_world, physics_st.ph_step, physics_st.ph_sub) != 0 { Mem.over("a physics step past phys_open's pairs or contacts") }
physics_st.ph_steps += 1
ph_contacts(physics_st)
}

View file

@ -0,0 +1,25 @@
# jolt_floor_test.ludic - what an empty world costs in Jolt's own heap at the game's size (plan 25):
# the floor under every body, from the body table, the broad phase, the contact caches and the temp heap
import "ludic.physics"
import "ludic.base"
program JoltFloorTest {
numbers float
function floor_at(physics_st: mut PhysicsState, bodies: int) -> long {
expect(phys_open(physics_st, 64, 1))
phys_close(physics_st)
let before = phys_heap_bytes()
expect(phys_open(physics_st, bodies, 0))
let f = phys_heap_bytes() - before
phys_close(physics_st)
return f
}
# it was 51839 KB at 200000 bodies with 65536 pairs, 20480 contacts and a 32 MB temp heap
test "an empty world at the game's size costs under 16 MB" (physics_st: mut PhysicsState) {
let a = floor_at(physics_st, 200000)
let b = floor_at(physics_st, 98304)
print(`jolt floor: {a / long(1024)} KB at 200000 bodies, {b / long(1024)} KB at 98304`)
expect(b < long(16 * 1024 * 1024))
expect(a > b)
}
}

View file

@ -0,0 +1,43 @@
# world.ludic - the world opened and closed: its sizes, and everything in it let go at once
# a world for up to max_bodies (threads 0: the cores less one, at most four); false if C refused.
# Pairs, contacts and the temp heap are sized for the things that MOVE (PH_PAIRS, ...)
export function phys_open(physics_st: mut PhysicsState, max_bodies: int, threads: int) -> bool { return phys_open_sized(physics_st, max_bodies, threads, PH_PAIRS, PH_CONTACTS, PH_TEMP_KB) }
# the same with Jolt's other three sizes given: touching pairs, contact constraints, the temp heap in KB
export function phys_open_sized(physics_st: mut PhysicsState, max_bodies: int, threads: int, max_pairs: int, max_contacts: int, temp_kb: int) -> bool {
phys_close(physics_st)
ph_buffers()
physics_st.ph_world = jph_world_new(max_bodies, threads, max_pairs, max_contacts, temp_kb)
physics_st.ph_max = max_bodies
ph_own_reset(physics_st, max_bodies)
physics_st.ph_gen += 1
physics_st.ph_acc = 0.0
physics_st.ph_steps = 0
return physics_st.ph_world != null
}
export function phys_is_open(physics_st: PhysicsState) -> bool { return physics_st.ph_world != null }
# which world this is: a body, shape or walker id kept from an older one means nothing now
export function phys_generation(physics_st: PhysicsState) -> int { return physics_st.ph_gen }
# everything let go: the world, every body in it and every shape (a world swap, a quit); the lists
# that held them are emptied and kept, since nothing is given back
export function phys_close(physics_st: mut PhysicsState) -> void {
if physics_st.ph_walkers != null {
for i in 0 .. len(physics_st.ph_walkers) {
if physics_st.ph_walkers[i] != null { jph_char_free(physics_st.ph_walkers[i]) }
}
}
if physics_st.ph_walkers != null { List.clear(physics_st.ph_walkers) }
if physics_st.ph_world != null { jph_world_free(physics_st.ph_world) }
physics_st.ph_world = null
if physics_st.ph_shapes != null {
for i in 0 .. len(physics_st.ph_shapes) {
if physics_st.ph_shapes[i] != null { jph_shape_free(physics_st.ph_shapes[i]) }
}
}
if physics_st.ph_shapes != null { List.clear(physics_st.ph_shapes) }
List.clear(physics_st.ph_spare)
physics_st.ph_nf = 0
if physics_st.ph_facts != null { q_clear(physics_st.ph_facts) }
}

View file

@ -0,0 +1,75 @@
# baked_tex.ludic - textures a game baked at build time (`ludic bake`, assets/baked/), read before the PNG
# they came from: a boot decodes, pads and converts nothing it can take ready-made. The file is ludic.base's
# baked form (a 32-byte "LBAK" header, then the payload), read by hand here since render3d uses no package.
# Missing, or of another key or version, and the PNG is decoded as it always was.
# assets/baked/png/<path under assets/>.tex a PNG's samples: u32 w, h, channels, depth, then them
# assets/baked/cutouts/<path under assets/>.bc7 a cut-out atlas, padded as tex_dilate pads it: a .dds
const BAKED_PNG_KEY: string = "png_sheets" # the Bakes row (Maroon Lake's bakes.lres) and the key in its header
const BAKED_PNG_VERSION: int = 1
const BAKED_CUT_KEY: string = "cutouts"
const BAKED_CUT_VERSION: int = 1
function baked_u32(b: []byte, o: int) -> int { return b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] << 24) }
# where the bake of `path` (a PNG under assets/) lives, or "" for any other path
@alloc_ok("a load: the baked file's name")
function baked_tex_path(path: string, dir: string, ext: string) -> string {
let n = len(path)
if n < 12 or path[0 .. 7] != "assets/" or path[n - 4 .. n] != ".png" { return "" }
return `assets/baked/{dir}/{path[7 .. n - 4]}{ext}`
}
# the payload's offset in a baked file of this key and version, or -1 (not one, or another's)
function baked_payload(b: []byte, key: string, version: int) -> int {
if b == null or len(b) < 40 { return -1 }
if b[0] != 76 or b[1] != 66 or b[2] != 65 or b[3] != 75 { return -1 } # "LBAK"
if baked_u32(b, 4) != 1 or baked_u32(b, 8) != version { return -1 }
let off = baked_u32(b, 12)
if off < 32 + len(key) or off > len(b) { return -1 }
for i in 0 .. len(key) { if b[32 + i] != key[i] { return -1 } }
if b[32 + len(key)] != 0 { return -1 }
return off
}
# a PNG's samples as the bake left them (tex_w .. tex_depth set, the caller frees them), or null
@alloc_ok("a load: the baked file read, its samples kept by the caller")
function baked_png(render3d_st: mut Render3dState, path: pointer) -> pointer {
let bp = baked_tex_path(path, "png", ".tex")
if bp == "" or not Fs.exists(bp) { return null }
let b = Fs.read_bytes(bp)
let off = baked_payload(b, BAKED_PNG_KEY, BAKED_PNG_VERSION)
if off < 0 or off + 16 > len(b) {
if b != null { free(b) }
print(`r3d: {bp} is not this build's bake; {path} is decoded`)
return null
}
let w = baked_u32(b, off)
let h = baked_u32(b, off + 4)
let ch = baked_u32(b, off + 8)
let depth = baked_u32(b, off + 12)
let n = w * h * ch * (depth / 8)
if w < 1 or h < 1 or ch < 1 or ch > 4 or (depth != 8 and depth != 16) or off + 16 + n > len(b) {
free(b)
return null
}
let out = bytes(n + 8)
mem_copy(out, mem_off(data_of(b), off + 16), n)
free(b)
render3d_st.tex_w = w; render3d_st.tex_h = h; render3d_st.tex_channels = ch; render3d_st.tex_depth = depth
return out
}
# a cut-out atlas as the bake padded and compressed it (a texture id), or 0
@alloc_ok("a load: the baked file read and let go")
function baked_cutout(render3d_st: mut Render3dState, path: pointer, srgb: bool) -> int {
if not render3d_st.gvk_has_bc { return 0 }
let bp = baked_tex_path(path, "cutouts", ".bc7")
if bp == "" or not Fs.exists(bp) { return 0 }
let b = Fs.read_bytes(bp)
let off = baked_payload(b, BAKED_CUT_KEY, BAKED_CUT_VERSION)
var id = 0
if off >= 0 { id = tex_load_dds_at(render3d_st, b, off, srgb) }
if b != null { free(b) }
if id == 0 { print(`r3d: {bp} is not this build's bake; {path} is padded here`) }
return id
}

View file

@ -20,6 +20,7 @@ import "prof.ludic"
import "drawstats.ludic"
import "programs.ludic"
import "texture.ludic"
import "baked_tex.ludic"
import "mesh.ludic"
import "gpu_vk_draw.ludic"
import "camera.ludic"

View file

@ -39,3 +39,8 @@ function png_decode_bytes(render3d_st: mut Render3dState, path: string, reuse: [
function tex_upload_bytes(render3d_st: mut Render3dState, px: []byte, srgb: bool, mips: bool) -> int {
return tex_upload(render3d_st, data_of(px), srgb, mips)
}
# cut-out edge padding over samples laid out as the last decode left them (tex_dilate)
function tex_dilate_bytes(render3d_st: Render3dState, px: []byte, thresh: int, passes: int) -> void {
if px == null or len(px) < render3d_st.tex_w * render3d_st.tex_h * render3d_st.tex_channels { return }
tex_dilate(render3d_st, data_of(px), thresh, passes)
}

View file

@ -115,6 +115,8 @@ function png_unfilter(raw: pointer, cur: int, prev: int, stride: int, fbpp: int,
@alloc_ok("loading a model, a texture or a font: a load, not a frame (a guest loading a teammate's look is one)")
function png_decode(render3d_st: mut Render3dState, path: pointer) -> pointer {
let bk = baked_png(render3d_st, path) # a build that baked it (baked_tex.ludic)
if bk != null { return bk }
let d = r3d_read_file(render3d_st, path)
if d == null { print(`png: cannot read {path}`); return null }
let size = render3d_st.tex_file_len
@ -198,30 +200,51 @@ function tex_dilate(render3d_st: Render3dState, px: pointer, thresh: int, passes
var i = 0
while i < w * h { let o = i * c; if px[o] + px[o + 1] + px[o + 2] < thresh { mask[i] = 1 } else { mask[i] = 0 }; i += 1 }
let next = bytes(w * h)
let j = new DilateJob
j.px = px; j.mask = mask; j.next = next; j.w = w; j.h = h; j.c = c
let blocks = (h + TEX_DILATE_ROWS - 1) / TEX_DILATE_ROWS
for pass in 0 .. passes {
mem_copy(next, mask, w * h)
var y = 0
while y < h {
var x = 0
while x < w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var b = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if y < h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; b += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = b / n; next[k] = 0 }
}
x += 1
}
y += 1
}
Job.parallel_for(blocks, fn tex_dilate_rows, j)
mem_copy(mask, next, w * h)
}
free(mask); free(next)
}
# A pass's rows on every core. Within a pass a row writes only its own still-masked texels and reads
# only neighbours the mask already let go, which no row writes this pass - so rows are independent and
# the result is the one row-by-row order gave. The worker is handed plain buffers and makes nothing.
const TEX_DILATE_ROWS: int = 16
property DilateJob {
px: pointer = null
mask: pointer = null
next: pointer = null
w: int = 0
h: int = 0
c: int = 0
}
function tex_dilate_rows(b: int, j: DilateJob) -> void {
let w = j.w; let c = j.c; let px = j.px; let mask = j.mask; let next = j.next
var y = b * TEX_DILATE_ROWS
let y1 = min(j.h, y + TEX_DILATE_ROWS)
while y < y1 {
var x = 0
while x < w {
let k = y * w + x
if mask[k] == 1 {
var r = 0; var g = 0; var bl = 0; var n = 0
if x > 0 and mask[k - 1] == 0 { let o = (k - 1) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if x < w - 1 and mask[k + 1] == 0 { let o = (k + 1) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if y > 0 and mask[k - w] == 0 { let o = (k - w) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if y < j.h - 1 and mask[k + w] == 0 { let o = (k + w) * c; r += px[o]; g += px[o + 1]; bl += px[o + 2]; n += 1 }
if n > 0 { let o = k * c; px[o] = r / n; px[o + 1] = g / n; px[o + 2] = bl / n; next[k] = 0 }
}
x += 1
}
y += 1
}
}
# Upload the last-decoded samples as a 2D texture. srgb: colour data (8-bit only).
function tex_upload(render3d_st: mut Render3dState, px: pointer, srgb: bool, mips: bool) -> int {
let id = gpu_tex_new(render3d_st)
@ -289,6 +312,13 @@ function tex_load_ex__t(render3d_st: mut Render3dState, path: pointer, srgb: boo
return t
}
}
if dilate > 0 {
let cut = baked_cutout(render3d_st, path, srgb) # padded and compressed at build time
if cut != 0 {
tex_note_size(render3d_st, cut)
return cut
}
}
let px = png_decode(render3d_st, path)
if px == null { return 0 }
if dilate > 0 { tex_dilate(render3d_st, px, 60, dilate) }

View file

@ -44,14 +44,16 @@ function tex_load_dds_bytes(render3d_st: mut Render3dState, b: []byte, srgb: boo
render3d_st.gvk_tag = was
return r
}
function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb: bool) -> int {
if len(b) < 148 or b[0] != 68 or b[1] != 68 or b[2] != 83 or b[3] != 32 { return 0 }
if b[84] != 68 or b[85] != 88 or b[86] != 49 or b[87] != 48 { return 0 } # "DX10"
let h = dds_u32(b, 12)
let w = dds_u32(b, 16)
var levels = dds_u32(b, 28)
function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb: bool) -> int { return tex_load_dds_at(render3d_st, b, 0, srgb) }
# a .dds that starts `at` bytes into b (a baked file's payload, baked_tex.ludic)
function tex_load_dds_at(render3d_st: mut Render3dState, b: []byte, at0: int, srgb: bool) -> int {
if len(b) < at0 + 148 or b[at0] != 68 or b[at0 + 1] != 68 or b[at0 + 2] != 83 or b[at0 + 3] != 32 { return 0 }
if b[at0 + 84] != 68 or b[at0 + 85] != 88 or b[at0 + 86] != 49 or b[at0 + 87] != 48 { return 0 } # "DX10"
let h = dds_u32(b, at0 + 12)
let w = dds_u32(b, at0 + 16)
var levels = dds_u32(b, at0 + 28)
if levels < 1 { levels = 1 }
let dxgi = dds_u32(b, 128)
let dxgi = dds_u32(b, at0 + 128)
var ifmt = 0
var block = 16
if dxgi == DDS_BC7 or dxgi == DDS_BC7_SRGB { ifmt = R3D_BC7; if srgb { ifmt = R3D_BC7_SRGB } }
@ -65,13 +67,13 @@ function tex_load_dds_bytes__t(render3d_st: mut Render3dState, b: []byte, srgb:
at += max((max(w >> l, 1) + 3) / 4, 1) * max((max(h >> l, 1) + 3) / 4, 1) * block
}
offs[levels] = at
if 148 + at > len(b) {
if at0 + 148 + at > len(b) {
free(offs)
return 0
}
let id = gpu_tex_new(render3d_st)
gpu_tex_bind(render3d_st, GPU_TEX2D, id)
let ok = gpu_tex_compressed(render3d_st, ifmt, w, h, levels, mem_off(data_of(b), 148), offs)
let ok = gpu_tex_compressed(render3d_st, ifmt, w, h, levels, mem_off(data_of(b), at0 + 148), offs)
free(offs)
if not ok {
gpu_tex_free(render3d_st, id)

View file

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

View file

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

View file

@ -53,6 +53,8 @@ export state UiState {
nt_ev_at: int = 0
nt_nums: []Val = nt_nums_new() # fired.ludic: the numbers a scroll or a slider reports, a ring
nt_num_at: int = 0
ct_clamp_n: []UiNode = new []UiNode # controls_opts.ludic: selects whose value a build clamped
ct_clamp_i: []int = new []int
hd_ev: Val = hd_ev_new() # hold.ludic: the held control's event, filled in place
cv_texts: []string = new []string # units_pieces.ludic: a value's var() pieces, kept
tx_pieces: [][]string = new [][]string # eval_text.ludic: a text's pieces, a list a depth

View file

@ -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)
}
# <Card class="wide" style="margin: 4px" id="c"> 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 {

View file

@ -31,9 +31,9 @@ function ct_activate(ui_st: mut UiState, n: UiNode) -> void {
function ct_step(ui_st: mut UiState, n: UiNode, d: int) -> void {
let t = ct_type(n)
if t == "select" and n.opts != null and len(n.opts) > 0 {
let at = (ct_option_at(n, n.opts) + d + len(n.opts)) % len(n.opts)
let ov = ui_attr(n.opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
let from = ct_option_at(n, n.opts)
let at = ct_opt_step(n.opts, from, d) # past a disabled option
if at != from { ct_opt_pick(ui_st, n, n.opts, at) }
}
if t == "number" { ct_num_step(ui_st, n, d) }
if t == "range" {

View file

@ -67,21 +67,6 @@ function ct_build_range(ui_st: mut UiState, n: UiNode) -> void {
ui_kept(n.children, track)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", ct_value_shown(ui_st, n)))
}
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_option_at(n, opts)
if at >= 0 { shown = opts[at].text }
ui_kept(n.children, ct_part(ui_st, n, "ui-prev", "<"))
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, ct_part(ui_st, n, "ui-next", ">"))
}
# which option the select's value names: an option's value="...", else its place
function ct_option_at(n: UiNode, opts: []UiNode) -> int {
let nv = ui_attr(n, "value")

View file

@ -0,0 +1,76 @@
# controls_opts.ludic - <option disabled="{...}">: a select steps past a disabled option (arrows, a
# press, the pad), a value naming one is shown and set as the nearest enabled option, and the arrow
# whose next option is disabled is drawn disabled - the options that way are off (a host's cap)
function ct_opt_on(opts: []UiNode, i: int) -> bool { return i >= 0 and i < len(opts) and opts[i].enabled }
# from `at` d steps at a time, wrapping, to the first enabled option; `at` itself when none is
function ct_opt_step(opts: []UiNode, at: int, d: int) -> int {
let n = len(opts)
var i = at
for k in 0 .. n {
i = (i + d + n) % n
if opts[i].enabled { return i }
}
return at
}
# the enabled option nearest `at` (the lower of two as near); -1 when none is
function ct_opt_nearest(opts: []UiNode, at: int) -> int {
if ct_opt_on(opts, at) { return at }
for r in 1 .. len(opts) {
if ct_opt_on(opts, at - r) { return at - r }
if ct_opt_on(opts, at + r) { return at + r }
}
return -1
}
# a select's value on a disabled option: the nearest enabled one is what it shows, and is set
# (set once the build is done: an event fired while the tree is built is cleared with the frame's)
function ct_opt_clamp(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> int {
if at < 0 or ct_opt_on(opts, at) { return at }
let to = ct_opt_nearest(opts, at)
if to >= 0 {
ui_kept(ui_st.ct_clamp_n, n)
ui_kept(ui_st.ct_clamp_i, to)
}
return to
}
# the clamps a build found, set now that the frame's events are cleared; stale ones are dropped
function ct_opt_flush(ui_st: mut UiState) -> void {
for i in 0 .. len(ui_st.ct_clamp_n) {
let n = ui_st.ct_clamp_n[i]
if n.opts != null and ui_st.ct_clamp_i[i] < len(n.opts) { ct_opt_pick(ui_st, n, n.opts, ui_st.ct_clamp_i[i]) }
}
ct_opt_forget(ui_st)
}
function ct_opt_forget(ui_st: mut UiState) -> void {
List.clear(ui_st.ct_clamp_n)
List.clear(ui_st.ct_clamp_i)
}
function ct_opt_pick(ui_st: mut UiState, n: UiNode, opts: []UiNode, at: int) -> void {
let ov = ui_attr(opts[at], "value")
if Value.kind(ov) == 0 { ct_change(ui_st, n, Value.int(at)) } else { ct_change(ui_st, n, ov) }
}
# a select: its label, < and >, and the option its value names between them (<option>s kept aside)
function ct_build_select(ui_st: mut UiState, n: UiNode) -> void {
@alloc_ok("once per pooled node: its list is kept and cleared")
if n.opts == null { n.opts = new []UiNode } # its options moved to a list the node keeps, not a new one
let opts = n.opts
List.clear(opts)
for i in 0 .. len(n.children) { ui_kept(opts, n.children[i]) }
List.clear(n.children)
ct_label(ui_st, n)
var shown = ""
let at = ct_opt_clamp(ui_st, n, opts, ct_option_at(n, opts))
if at >= 0 { shown = opts[at].text }
let prev = ct_part(ui_st, n, "ui-prev", "<")
let next = ct_part(ui_st, n, "ui-next", ">")
if at >= 0 and len(opts) > 1 {
prev.enabled = ct_opt_on(opts, (at - 1 + len(opts)) % len(opts))
next.enabled = ct_opt_on(opts, (at + 1) % len(opts))
}
ui_kept(n.children, prev)
ui_kept(n.children, ct_part(ui_st, n, "ui-value", shown))
ui_kept(n.children, next)
}

View file

@ -5,6 +5,7 @@
export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: float, y: float, w: float, h: float) -> void {
in_read(ui_st)
ct_opt_forget(ui_st) # a clamp found by a build this frame did not show
dv_before(ui_st)
ui_st.an_frame += 1
ui_viewport(ui_st, w, h)
@ -12,6 +13,7 @@ export function ui_show(ui_st: mut UiState, screen: string, view: UiView, x: flo
if root == null { return }
ui_place(ui_st, root, x, y, w, h)
nt_fired_clear(ui_st)
ct_opt_flush(ui_st) # a select's value on a disabled option, set now
sc_undo(root)
sc_update(ui_st, root)
fr_pointer(ui_st, root)

View file

@ -29,6 +29,7 @@ import "fired.ludic"
import "native_room.ludic"
import "controls.ludic"
import "controls_build.ludic"
import "controls_opts.ludic"
import "controls_parts.ludic"
import "controls_key.ludic"
import "controls_key_mouse.ludic"

View file

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

View file

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

View file

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

View file

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

View file

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

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, never under 150 m nor over 900 m, and 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,41 @@
# 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; never past no fog's, so a far fog
# never keeps more of the world than none does
export function zones_radius(fog_dist: float) -> float {
if fog_dist <= 0.0 { return ZONES_NO_FOG }
return Math.min(Math.max(fog_dist + ZONES_PAST_FOG, ZONES_LEAST), ZONES_NO_FOG)
}
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,54 @@
# 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 nor over no fog's" (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)
expect(zones_radius(1000.0) == ZONES_NO_FOG) # a kilometre's fog keeps no more than none
expect(zones_radius(860.0) == ZONES_NO_FOG)
}
}