feat(npc, nav): 17.5 - a person walks by the world's way where it has one (NpcWorld.way: the next corner toward the target, asked every half second, on reaching it or for a new target; the errand begins only at the target itself; the step round and the give-up stay the fallback), way_test round a wall; and ludic.nav reads a mesh file into the shim's own memory and frees it once parsed, so loading a map leaves nothing in Ludic's never-freed heap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 21:57:34 +03:00
parent 4add35b4ca
commit ee0768bf3e
12 changed files with 184 additions and 39 deletions

View file

@ -47,8 +47,8 @@ export function nav_polygons(nav_st: NavState, kind: int) -> int {
return nvc_polys(h)
}
# a kind's mesh written to a file, and read back. Reading holds the file's bytes for good (a slice
# is never given back), so a mesh is loaded once per map, never per frame
# a kind's mesh written to a file, and read back - through the asset pack when it is in one - into the
# shim's own memory, which it frees once the tiles are made, so a map swap leaves nothing behind
export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
@ -59,8 +59,14 @@ export function nav_save_file(nav_st: NavState, kind: int, path: string) -> bool
return Fs.write_bytes(path, buf, n)
}
export function nav_load_file(nav_st: mut NavState, kind: int, path: string) -> bool {
if not Fs.exists(path) { return false }
let buf = Fs.read_bytes(path)
if buf == null or len(buf) == 0 { return false }
return nv_set(nav_st, kind, nvc_load(buf, len(buf)))
let f = file_open(path, "rb")
if f == null { return false }
file_seek(f, 0, 2)
let n = file_tell(f)
file_seek(f, 0, 0)
let buf = nvc_stage(n)
var got = 0
if buf != null { got = file_read(f, buf, n) }
file_close(f)
return nv_set(nav_st, kind, nvc_load_staged(buf, got))
}

Binary file not shown.

View file

@ -13,3 +13,5 @@ extern function nvc_path(h: pointer, f: int, sx: float, sy: float, sz: float, ex
extern function nvc_partial(h: pointer) -> int = "nav_partial"
extern function nvc_raycast(h: pointer, f: int, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast"
extern function nvc_random_near(h: pointer, f: int, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near"
extern function nvc_stage(size: int) -> pointer = "nav_stage"
extern function nvc_load_staged(buf: pointer, size: int) -> pointer = "nav_load_staged"

View file

@ -7,6 +7,7 @@
#include <DetourNavMeshQuery.h>
#include <DetourCommon.h>
#include <cstring>
#include <cstdlib>
#include <cmath>
#include <vector>

View file

@ -100,3 +100,12 @@ NAV_SHIM void *nav_load(const unsigned char *buf, int size) {
}
return nav_open(mesh);
}
// a file read straight into the shim's own memory rather than a Ludic slice (which is never given
// back): nav_stage hands out the room, nav_load_staged parses it and frees it, whatever the outcome
NAV_SHIM unsigned char *nav_stage(int size) { return size > 0 ? static_cast<unsigned char *>(malloc(size)) : nullptr; }
NAV_SHIM void *nav_load_staged(unsigned char *buf, int size) {
void *h = buf ? nav_load(buf, size) : nullptr;
free(buf);
return h;
}

View file

@ -87,10 +87,18 @@ export port NpcWorld { # every member has a default: a flat dry field at n
next_id: fn() -> int, body_of: fn(int) -> int # an id across machines; a look seed's body
taken: fn(string) -> bool # a name the place keeps
born / gone: fn(NpcPerson) -> void # draw it, stop drawing it
way: fn(NpcPerson, x, z) -> bool, way_x, way_z # the next corner of its way toward (x, z)
}
export port NpcTalk { say: fn(NpcPerson, int) -> string, greeting: fn(NpcPerson) -> string }
```
**A person walks by the world's way where the world has one.** `way` is asked for the next corner
toward where it is going - every half second, on reaching the corner, and at once for a new target -
and the person walks corner to corner, but begins its errand only at the target itself. With no
answer (the default) it walks straight and steps round what is in the way, as before; the step
round and the twenty seconds' give-up stay as the fallback. Maroon Lake binds `way` to its navmesh
by a person's taste.
## API
| | |

View file

@ -47,6 +47,11 @@ export property NpcPerson {
via_on: bool = false # a step round whatever is in the way
vx: float = 0.0
vz: float = 0.0
wx: float = 0.0 # the corner of its way it walks to now (NpcWorld.way)
wz: float = 0.0
wtx: float = 0.0 # the target that corner was asked for: a new target asks again
wtz: float = 0.0
way_t: float = 0.0 # seconds until it asks the way again
detours: int = 0
fails: int = 0
stall: float = 0.0 # seconds walking without moving

View file

@ -10,6 +10,7 @@ import "ground.ludic"
import "spots.ludic"
import "routine.ludic"
import "walk.ludic"
import "way.ludic"
import "act.ludic"
import "names.ludic"
import "census.ludic"

View file

@ -25,6 +25,11 @@ export port NpcWorld {
taken: fn(string) -> bool = fn nw_free # a name the place already uses
born: fn(NpcPerson) -> void = fn nw_nothing # a new one (or a slot reused): draw it
gone: fn(NpcPerson) -> void = fn nw_nothing # left: stop drawing it
# (p, x, z): the next corner of this person's way toward (x, z) into way_x / _z; false, the
# straight line with its steps round (a navmesh's path by a person's taste, in Maroon Lake)
way: fn(NpcPerson, float, float) -> bool = fn nw_no_way
way_x: fn() -> float = fn nw_asked_x
way_z: fn() -> float = fn nw_asked_z
}
# what the story says through them: the words of a line whose `fill` is set ("" when it has nothing
@ -76,3 +81,4 @@ export function npc_near(npc_st: mut NpcState, x: float, z: float) -> float {
}
export function npc_near_i(npc_st: NpcState) -> int { return npc_st.np_near_i }
function nw_no_way(p: NpcPerson, x: float, z: float) -> bool { return false }

View file

@ -0,0 +1,82 @@
# way_test.ludic - a person walks by the world's way where the world has one: round a wall the
# straight line runs into, by the corners NpcWorld.way gives, and arrives only where it was going -
# never at a corner
import "ludic.npc"
import "ludic.base"
import "fake"
program NpcWayTest {
numbers float
bind NpcWorld {
ground: fn fk_ground
water: fn fk_water
allowed: fn fk_allowed
push: fn fk_push
pushed_x: fn fk_pushed_x
pushed_z: fn fk_pushed_z
clear: fn fk_clear
hour: fn fk_hour_now
day: fn fk_day_now
players: fn fk_players
player_on: fn fk_player_on
player_x: fn fk_x
player_z: fn fk_z
want: fn fk_wanted
extra: fn fk_extra_kind
taken: fn fk_taken
born: fn fk_on_born
gone: fn fk_on_gone
way: fn wt_way
way_x: fn wt_x
way_z: fn wt_z
}
bind NpcTalk { say: fn tk_say, greeting: fn tk_greeting }
def NpcActs sit { }
def NpcActs fish { turns: false }
def NpcKinds from "kinds.lres"
def NpcNames from "names.lres"
def NpcLines from "lines.lres"
function tk_say(p: NpcPerson, line: int) -> string { return "" }
function tk_greeting(p: NpcPerson) -> string { return "" }
function tk_start(npc_st: mut NpcState, p: NpcPerson) -> void { npc_start_near(npc_st, p, 0.0, 0.0, 0.0, 10.0) }
state WayTestState {
wx: float = 0.0
wz: float = 0.0
asked: int = 0
}
# a wall across x = 20 for |z| < 15 (a straight walk to (38, -25) meets it): the way goes round its
# north end, then on to the target - which is clear of where the fake place forbids stopping
function wt_way(way_test_st: mut WayTestState, p: NpcPerson, x: float, z: float) -> bool {
way_test_st.asked += 1
way_test_st.wx = x
way_test_st.wz = z
if p.x < 19.0 and p.z < 16.0 {
way_test_st.wx = 20.0
way_test_st.wz = 17.0
}
return true
}
function wt_x(way_test_st: WayTestState) -> float { return way_test_st.wx }
function wt_z(way_test_st: WayTestState) -> float { return way_test_st.wz }
test "it walks round the wall by the corners it is given and begins only where it was going" (npc_st: mut NpcState, npc_tests_fake_st: mut NpcTestsFakeState, way_test_st: WayTestState) {
fk_setup(npc_st, npc_tests_fake_st)
npc_tests_fake_st.fk_want = 1
let p = npc_spawn(npc_st, NPCK_HIKER)
npc_set_at(p, 0.0, 0.0)
npc_go(npc_st, p, 38.0, -25.0, NPCA_SIT, 30.0)
var north = -100.0
var k = 0
while p.act == NPCA_WALK and k < 2000 {
npc_tick(npc_st, 0.05)
if p.x > 19.0 and p.x < 21.0 { north = Math.max(north, p.z) }
k += 1
}
expect(north > 14.0)
expect_eq(p.act, NPCA_SIT)
expect(npc_d(p.x, p.z, 38.0, -25.0) < 1.3)
expect(way_test_st.asked > 5)
}
}

View file

@ -1,6 +1,6 @@
# walk.ludic - walking to the target: turn toward it, step, slid round a trunk or a tent, never into
# the water and never up a scramble - step round it instead - and give up on a target that gets no
# closer for twenty seconds
# walk.ludic - walking to the target by the world's way where it has one (way.ludic): turn toward the
# next corner, step, slid round a trunk or a tent, never into the water and never up a scramble - step
# round it instead - and give up on a target that gets no closer for twenty seconds
const NP_BODY_R: float = 0.35 # a person's width, for the still things
export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void {
@ -10,12 +10,17 @@ export function npc_walk(npc_st: mut NpcState, p: NpcPerson, dt: float) -> void
gx = p.vx
gz = p.vz
}
let dx = gx - p.x
let dz = gz - p.z
if Math.sqrt(dx * dx + dz * dz) < 1.2 {
if npc_d(p.x, p.z, gx, gz) < 1.2 {
np_arrive(npc_st, p)
return
}
if not p.via_on {
np_way(p, dt)
gx = p.wx
gz = p.wz
}
let dx = gx - p.x
let dz = gz - p.z
let dg = npc_d(p.x, p.z, p.tx, p.tz)
if dg < p.best - 1.0 {
p.best = dg
@ -79,28 +84,3 @@ function np_arrive(npc_st: mut NpcState, p: NpcPerson) -> void {
p.stall = 0.0
if p.act != NPCA_WALK { np_fact(npc_st, NPC_F_ARRIVED, p, -1) }
}
function np_detour(p: NpcPerson, want: float) -> bool {
for k in 0 .. 8 {
let ang = want + float((k / 2 + 1) * (1 - 2 * (k % 2))) * 0.6
let x = p.x - Math.sin(ang) * 6.0
let z = p.z - Math.cos(ang) * 6.0
if npc_line_ok(p.x, p.z, x, z) {
p.via_on = true
p.vx = x
p.vz = z
p.yaw = ang
return true
}
}
return false
}
function np_stuck(npc_st: mut NpcState, p: NpcPerson) -> void {
p.fails += 1
p.via_on = false
if p.fails >= 3 {
p.fails = 0
npc_wander(npc_st, p)
} else { npc_plan(npc_st, p) }
}

View file

@ -0,0 +1,45 @@
# way.ludic - where a person walks when the straight line will not do: the world's way (NpcWorld.way,
# a navmesh by a person's taste in Maroon Lake), and without one a step round what is in the way,
# and giving the target up after three tries
# the next corner toward the target, asked again every half second, on reaching it, or for a new target
const NP_WAY_EVERY: float = 0.5
function np_way(p: NpcPerson, dt: float) -> void {
p.way_t = p.way_t - dt
let fresh = p.wtx != p.tx or p.wtz != p.tz
if p.way_t > 0.0 and not fresh and npc_d(p.x, p.z, p.wx, p.wz) >= 1.0 { return }
p.way_t = NP_WAY_EVERY
p.wtx = p.tx
p.wtz = p.tz
p.wx = p.tx
p.wz = p.tz
if NpcWorld.way(p, p.tx, p.tz) {
p.wx = NpcWorld.way_x()
p.wz = NpcWorld.way_z()
}
}
function np_detour(p: NpcPerson, want: float) -> bool {
for k in 0 .. 8 {
let ang = want + float((k / 2 + 1) * (1 - 2 * (k % 2))) * 0.6
let x = p.x - Math.sin(ang) * 6.0
let z = p.z - Math.cos(ang) * 6.0
if npc_line_ok(p.x, p.z, x, z) {
p.via_on = true
p.vx = x
p.vz = z
p.yaw = ang
return true
}
}
return false
}
function np_stuck(npc_st: mut NpcState, p: NpcPerson) -> void {
p.fails += 1
p.via_on = false
if p.fails >= 3 {
p.fails = 0
npc_wander(npc_st, p)
} else { npc_plan(npc_st, p) }
}