diff --git a/packages/README.md b/packages/README.md index 444d193b..44237109 100644 --- a/packages/README.md +++ b/packages/README.md @@ -30,6 +30,7 @@ section. The rules are in [ludic.base](ludic.base/README.md). | [ludic.needs](ludic.needs/README.md) | a body's warmth, food, water and energy, and the countdown to a collapse | | [ludic.npc](ludic.npc/README.md) | the other people in a place: a routine by the hour, walking round what is in the way, facing a player who comes near, lines as data, a guest's copy | | [ludic.physics](ludic.physics/README.md) | rigid bodies, removable still shapes, queries and buoyancy over Jolt Physics (a native library, phase 16) | +| [ludic.nav](ludic.nav/README.md) | a navmesh per kind of walker and the ways across it, over Recast & Detour (a native library, phase 17) | | [ludic.photo](ludic.photo/README.md) | a camera's photographs: what is in the frame, a grade on size, framing, light and the moment, the roll, its worth, the best of each subject | | [ludic.save](ludic.save/README.md) | versioned save files: a migration chain the game declares, torn writes told apart, a backup, a newer file refused and read-only | | [ludic.settings](ludic.settings/README.md) | a game's settings as data: one store, a fact per change, ranges, a safe set | diff --git a/packages/ludic.jobs/README.md b/packages/ludic.jobs/README.md index 7561670a..809b3028 100644 --- a/packages/ludic.jobs/README.md +++ b/packages/ludic.jobs/README.md @@ -64,7 +64,8 @@ export port JobsPay { reward: fn(int, int, int) -> void } # (scope, money, rep | `jobs_morning()`, `jobs_roll(lo, hi)`, `jobs_post(b, s, kind, param, need, money, rep)`, `jobs_config_seed(seed)` | posting | | `jobs_slots(b)`, `jobs_posted(b)`, `jobs_post_kind / _param / _need / _have / _money / _rep / _state / _day(b, s)`, `jobs_posts_done(b)`, `jobs_total(b)` | the boards | | `jobs_post_hand_in(b, s)`, `jobs_post_set(b, s, ...)`, `jobs_set_total(b, n)` | a post's verbs | -| `jobs_facts() -> Queue` | `{ what: JOBS_F_OFFERED / _PROGRESSED / _COMPLETED / _HANDED_IN, job, board, slot, kind, param, have, need, money, rep, scope }` | +| `jobs_give_up(i)`, `jobs_back_on(i)`, `jobs_post_give_up(b, s)` | letting one go: a written job is offered again from the next day (the day back is saved with it), a post comes down and the morning replaces it; an auto board's posts are never taken, so never given up | +| `jobs_facts() -> Queue` | `{ what: JOBS_F_OFFERED / _PROGRESSED / _COMPLETED / _HANDED_IN / _GIVEN_UP, job, board, slot, kind, param, have, need, money, rep, scope }` | | `jobs_system()`, `jobs_party_system()` | `"jobs"` (yours, the tick) and `"jobs.party"` (the party's), each saving its scope's jobs and boards by key | ## Tests diff --git a/packages/ludic.jobs/defs.ludic b/packages/ludic.jobs/defs.ludic index 4da15821..657fc49a 100644 --- a/packages/ludic.jobs/defs.ludic +++ b/packages/ludic.jobs/defs.ludic @@ -49,6 +49,7 @@ export const JOBS_F_OFFERED: int = 0 export const JOBS_F_PROGRESSED: int = 1 export const JOBS_F_COMPLETED: int = 2 export const JOBS_F_HANDED_IN: int = 3 +export const JOBS_F_GIVEN_UP: int = 4 # taken and let go: what the game placed for it comes down # `job` a written job, or -1 and `board` / `slot` a post; what it counted rides along, since a # post handed in is replaced before anyone reads the fact diff --git a/packages/ludic.jobs/giveup.ludic b/packages/ludic.jobs/giveup.ludic new file mode 100644 index 00000000..95b9c972 --- /dev/null +++ b/packages/ludic.jobs/giveup.ludic @@ -0,0 +1,25 @@ +# giveup.ludic - letting a job go. A written job goes back on offer, but not until tomorrow, so +# giving up is never a reroll; a post comes down and the morning puts up another in its place. +# Either way the fact says so, and the game takes down what it had placed for it. +export function jobs_give_up(jobs_st: mut JobsState, i: int) -> bool { + if not jobs_ok(i) or not jobs_under_way(jobs_st, i) { return false } + jobs__fact(jobs_st, JOBS_F_GIVEN_UP, i, -1, -1, jobs_st.jobs__hv[i], Jobs[i].need) + jobs_st.jobs__st[i] = JOBS_OFFERED + jobs_st.jobs__hv[i] = 0 + jobs_st.jobs__back[i] = JobsWorld.day() + 1 + return true +} + +# the day a written job given up may be taken again, 0 never given up +export function jobs_back_on(jobs_st: JobsState, i: int) -> int { return jobs_st.jobs__back[i] } + +# a post taken and not yet paid; an auto board's posts are not taken, so they are not given up +export function jobs_post_give_up(jobs_st: mut JobsState, b: int, s: int) -> bool { + if not jobs_board_ok(b) or s < 0 or s >= JobBoards[b].slots or JobBoards[b].auto { return false } + let k = jobs_st.jobs__off[b] + s + let st = jobs_st.jobs__ps[k] + if jobs_st.jobs__pk[k] < 0 or (st != JOBS_ACTIVE and st != JOBS_READY) { return false } + jobs__fact(jobs_st, JOBS_F_GIVEN_UP, -1, b, s, jobs_st.jobs__ph[k], jobs_st.jobs__pn[k]) + jobs__clear(jobs_st, k) + return true +} diff --git a/packages/ludic.jobs/index.ludic b/packages/ludic.jobs/index.ludic index b29ea912..6ff8a86a 100644 --- a/packages/ludic.jobs/index.ludic +++ b/packages/ludic.jobs/index.ludic @@ -7,6 +7,7 @@ import "defs.ludic" import "ports.ludic" import "state.ludic" import "jobs.ludic" +import "giveup.ludic" import "count.ludic" import "lists.ludic" import "boards.ludic" diff --git a/packages/ludic.jobs/jobs.ludic b/packages/ludic.jobs/jobs.ludic index 2ec1c933..2be089f8 100644 --- a/packages/ludic.jobs/jobs.ludic +++ b/packages/ludic.jobs/jobs.ludic @@ -20,12 +20,12 @@ export function jobs_under_way(jobs_st: JobsState, i: int) -> bool { # on offer: not taken, the scope's standing and the story far enough, and the game allowing it export function jobs_offered(jobs_st: JobsState, i: int) -> bool { let d = Jobs[i] - if jobs_state(jobs_st, i) != JOBS_OFFERED { return false } + if jobs_state(jobs_st, i) != JOBS_OFFERED or JobsWorld.day() < jobs_st.jobs__back[i] { return false } return JobsWorld.rep(d.scope) >= d.minrep and JobsWorld.stage() >= d.minstage and JobsWorld.open(i) } export function jobs_take(jobs_st: mut JobsState, i: int) -> bool { - if not jobs_ok(i) or jobs_state(jobs_st, i) != JOBS_OFFERED { return false } + if not jobs_ok(i) or jobs_state(jobs_st, i) != JOBS_OFFERED or JobsWorld.day() < jobs_st.jobs__back[i] { return false } jobs_st.jobs__st[i] = JOBS_ACTIVE jobs_st.jobs__hv[i] = 0 jobs__recheck_one(jobs_st, i) diff --git a/packages/ludic.jobs/save.ludic b/packages/ludic.jobs/save.ludic index 46a15a0e..fedc10a3 100644 --- a/packages/ludic.jobs/save.ludic +++ b/packages/ludic.jobs/save.ludic @@ -5,6 +5,7 @@ function jobs__reset_scope(jobs_st: mut JobsState, sc: int) -> void { if Jobs[i].scope != sc { continue } jobs_st.jobs__st[i] = JOBS_OFFERED jobs_st.jobs__hv[i] = 0 + jobs_st.jobs__back[i] = 0 } for b in 0 .. BOARD_COUNT { if JobBoards[b].scope != sc { continue } @@ -19,8 +20,8 @@ function jobs__save_scope(jobs_st: JobsState, sc: int) -> Val { let v = Value.object() let js = Value.object() for i in 0 .. JOB_COUNT { - if Jobs[i].scope != sc or jobs_st.jobs__st[i] == JOBS_OFFERED { continue } - sv_put_ints(js, Jobs[i].key, [jobs_st.jobs__st[i], jobs_st.jobs__hv[i]]) + if Jobs[i].scope != sc or (jobs_st.jobs__st[i] == JOBS_OFFERED and jobs_st.jobs__back[i] == 0) { continue } + sv_put_ints(js, Jobs[i].key, [jobs_st.jobs__st[i], jobs_st.jobs__hv[i], jobs_st.jobs__back[i]]) } Value.put(v, "jobs", js) let bs = Value.object() @@ -48,7 +49,9 @@ function jobs__load_scope(jobs_st: mut JobsState, sc: int, v: Val) -> void { let js = Value.get(v, "jobs") for i in 0 .. JOB_COUNT { let p = sv_ints(js, Jobs[i].key) - if Jobs[i].scope == sc and len(p) == 2 { jobs_set(jobs_st, i, p[0], p[1]) } + if Jobs[i].scope != sc or len(p) < 2 { continue } + jobs_set(jobs_st, i, p[0], p[1]) + if len(p) > 2 { jobs_st.jobs__back[i] = p[2] } } } if Value.has(v, "boards") == 0 { return } diff --git a/packages/ludic.jobs/state.ludic b/packages/ludic.jobs/state.ludic index c3c2b7d8..370701f2 100644 --- a/packages/ludic.jobs/state.ludic +++ b/packages/ludic.jobs/state.ludic @@ -4,6 +4,7 @@ export state JobsState { jobs__st: []int = jobs__ints(JOB_COUNT, JOBS_OFFERED) jobs__hv: []int = jobs__ints(JOB_COUNT, 0) jobs__seen: []bool = jobs__bools(JOB_COUNT) # an offer already said + jobs__back: []int = jobs__ints(JOB_COUNT, 0) # given up: the first day it may be taken again jobs__primed: bool = false # the first tick after a reset learns the offers quietly jobs__done_n: []int = jobs__ints(JOBS_GROUPS, 0) # handed in, per group jobs__off: []int = jobs__offsets() diff --git a/packages/ludic.jobs/tests/giveup_test.ludic b/packages/ludic.jobs/tests/giveup_test.ludic new file mode 100644 index 00000000..96b83c66 --- /dev/null +++ b/packages/ludic.jobs/tests/giveup_test.ludic @@ -0,0 +1,132 @@ +# giveup_test.ludic - letting a job go: a written job back on offer tomorrow, not today; a post taken +# down and replaced in the morning; a daily never taken; the day back saved with the job +import "ludic.jobs" +import "ludic.base" +program JobsGiveUpTest { + numbers float + const K_FISH: int = 0 + const K_PHOTO: int = 1 + const K_WOOD: int = 2 + + def Jobs catch { group: 0, kind: K_FISH, need: 3, money: 30, rep: 5 } + def Jobs deer { group: 0, kind: K_PHOTO, param: 4, need: 1, money: 20, rep: 2, minrep: 10 } + def Jobs ten { group: 0, kind: K_FISH, need: 10, money: 70, rep: 12, scope: JOBS_PARTY } + def Jobs still { group: 1, need: 0, money: 60, rep: 12, minstage: 2 } + def JobBoards daily { slots: 3, keep: 1, auto: true, refill: false } + def JobBoards ada { slots: 2, keep: 7, scope: JOBS_PARTY } + + state JobsTestState { + rep: int = 0 + stage: int = 0 + day: int = 1 + guest: bool = false + fish: int = 0 # the state's count of fish + paid: int = 0 + paid_rep: int = 0 + paid_scope: int = -1 + } + + function world_rep(jobs_test_st: JobsTestState, sc: int) -> int { return jobs_test_st.rep } + function world_stage(jobs_test_st: JobsTestState) -> int { return jobs_test_st.stage } + function world_day(jobs_test_st: JobsTestState) -> int { return jobs_test_st.day } + function world_owns(jobs_test_st: JobsTestState, sc: int) -> bool { return sc == JOBS_SELF or not jobs_test_st.guest } + function world_evidence(jobs_test_st: JobsTestState, kind: int, param: int) -> int { + if kind == K_FISH { return jobs_test_st.fish } + return -1 + } + function world_roll(jobs_st: mut JobsState, b: int, s: int) -> void { + let need = jobs_roll(jobs_st, 2, 4) + if b == BOARD_DAILY { jobs_post(jobs_st, b, s, K_WOOD, -1, need, 7, 2) } else { jobs_post(jobs_st, b, s, K_FISH, -1, need, 9 * need, 3) } + } + function pay(jobs_test_st: mut JobsTestState, sc: int, money: int, r: int) -> void { + jobs_test_st.paid += money + jobs_test_st.paid_rep += r + jobs_test_st.paid_scope = sc + } + + bind JobsWorld { rep: fn world_rep, stage: fn world_stage, day: fn world_day, owns: fn world_owns, evidence: fn world_evidence, roll: fn world_roll } + bind JobsPay { reward: fn pay } + + function fresh(jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) -> void { + jobs_test_st.rep = 0 + jobs_test_st.stage = 0 + jobs_test_st.day = 1 + jobs_test_st.guest = false + jobs_test_st.fish = 0 + jobs_test_st.paid = 0 + jobs_test_st.paid_rep = 0 + jobs_reset(jobs_st) + jobs_party_reset(jobs_st) + } + + function count_of(jobs_st: JobsState, what: int) -> int { + let fs = q_drain(jobs_facts(jobs_st)) + var n = 0 + for i in 0 .. len(fs) { if fs[i].what == what { n += 1 } } + return n + } + + test "a job given up is offered again tomorrow, not today" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) { + fresh(jobs_st, jobs_test_st) + expect(jobs_take(jobs_st, JOB_CATCH)) + jobs_count(jobs_st, K_FISH, 0, 2) + count_of(jobs_st, JOBS_F_GIVEN_UP) + expect(jobs_give_up(jobs_st, JOB_CATCH)) + expect_eq(count_of(jobs_st, JOBS_F_GIVEN_UP), 1) + expect_eq(jobs_state(jobs_st, JOB_CATCH), JOBS_OFFERED) + expect_eq(jobs_have(jobs_st, JOB_CATCH), 0) + expect(not jobs_offered(jobs_st, JOB_CATCH)) + expect(not jobs_take(jobs_st, JOB_CATCH)) + expect(not jobs_give_up(jobs_st, JOB_CATCH)) + jobs_test_st.day = 2 + expect(jobs_offered(jobs_st, JOB_CATCH)) + expect(jobs_take(jobs_st, JOB_CATCH)) + expect_eq(jobs_test_st.paid, 0) + } + + test "a ready job can be let go too, and nothing is paid" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) { + fresh(jobs_st, jobs_test_st) + jobs_take(jobs_st, JOB_CATCH) + jobs_count(jobs_st, K_FISH, 0, 3) + expect_eq(jobs_state(jobs_st, JOB_CATCH), JOBS_READY) + expect(jobs_give_up(jobs_st, JOB_CATCH)) + expect(not jobs_hand_in(jobs_st, JOB_CATCH)) + expect_eq(jobs_test_st.paid, 0) + } + + test "a post given up comes down, and the morning puts up another" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) { + fresh(jobs_st, jobs_test_st) + jobs_morning(jobs_st) + count_of(jobs_st, JOBS_F_OFFERED) + expect(jobs_post_give_up(jobs_st, BOARD_ADA, 0)) + expect_eq(count_of(jobs_st, JOBS_F_GIVEN_UP), 1) + expect_eq(jobs_post_kind(jobs_st, BOARD_ADA, 0), -1) + expect(not jobs_post_give_up(jobs_st, BOARD_ADA, 0)) + expect(jobs_post_kind(jobs_st, BOARD_ADA, 1) >= 0) + jobs_test_st.day = 2 + jobs_morning(jobs_st) + expect(jobs_post_kind(jobs_st, BOARD_ADA, 0) >= 0) + expect_eq(jobs_post_day(jobs_st, BOARD_ADA, 0), 2) + } + + test "a daily is not taken, so it is not given up" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) { + fresh(jobs_st, jobs_test_st) + jobs_morning(jobs_st) + expect(not jobs_post_give_up(jobs_st, BOARD_DAILY, 0)) + expect(jobs_post_kind(jobs_st, BOARD_DAILY, 0) >= 0) + } + + test "the day back is saved with the job" (jobs_st: mut JobsState, jobs_test_st: mut JobsTestState) { + fresh(jobs_st, jobs_test_st) + jobs_take(jobs_st, JOB_CATCH) + jobs_give_up(jobs_st, JOB_CATCH) + let body = jobs_save(jobs_st) + fresh(jobs_st, jobs_test_st) + expect(jobs_offered(jobs_st, JOB_CATCH)) + jobs_load(jobs_st, body, 1) + expect_eq(jobs_back_on(jobs_st, JOB_CATCH), 2) + expect(not jobs_offered(jobs_st, JOB_CATCH)) + jobs_test_st.day = 2 + expect(jobs_offered(jobs_st, JOB_CATCH)) + } +} diff --git a/packages/ludic.nav/README.md b/packages/ludic.nav/README.md new file mode 100644 index 00000000..f209c10c --- /dev/null +++ b/packages/ludic.nav/README.md @@ -0,0 +1,67 @@ +# ludic.nav + +A walkable mesh of the world and the ways across it, over +[Recast & Detour](https://github.com/recastnavigation/recastnavigation) (zlib) built here from a +pinned tag (phase 17). Uses `ludic.base` and nothing else. + +```ludic +import "ludic.nav" +``` + +## The rules it keeps + +- **One mesh per kind of walker.** `NAV_PERSON` (0.35 m wide, the hiker's 0.55 m step), + `NAV_LARGE` (an elk, a bear, a horse) and `NAV_SMALL` (a hare, a marmot) each have their own, + because what a hare slips between a bear walks round. A `NavConfig` says what a mesh is built + for: its voxels and its walker's height, radius, step and steepest slope. +- **Built from ground, or loaded from a bake.** A `NavGround` is triangles - each with an area + byte, 0 not walkable and 1..62 a kind of ground - and what nothing stands in: cylinders (a trunk, + a post) and convex footprints (a boulder). Ground steeper than the slope is cleared whatever its + area. `nav_build` makes one mesh of it; a map is square tiles (`nav_tiled`, then `nav_tile_build` + for each tile from the ground under it and a few metres round), and a tile must be a whole number + of cells across or its seams never join - the shim refuses one that is not. A game bakes its maps + once and loads the bytes (`nav_save_file` / `nav_load_file`, one format for one tile or many): a + map is never built at start-up. Polygon refs are 64-bit, so a map may have 16384 tiles. +- **A path is corners.** `nav_path` answers the way from one point to another as the corners + where it turns, the first the start and the last the end. When the end cannot be reached the + path stops as near it as the mesh allows and `nav_partial` says so - a walker goes there and + gives up, never through a river. Either end off the mesh is `-1`. +- **A wander's goal is somewhere it can go.** `nav_random_near` answers a point about r away that a + walker standing at the start can reach - never across a river without a ford - from a seed the + caller draws from its own `Rng`, so the same seed is the same point on every machine. +- **A kind of ground has a cost** (`nav_area_cost`): a trail cheaper than a meadow, scree dearer. +- **The same question gets the same answer.** Detour is deterministic for the same mesh and the + same points, so co-op's order of dice is untouched by asking it. + +## API + +| | | +| --- | --- | +| `NAV_PERSON`, `NAV_LARGE`, `NAV_SMALL`, `NAV_CORNERS` | the kinds of walker, and a path's most corners (256) | +| `NavConfig { cell, cell_h, height, radius, climb, slope }` | what a mesh is built for (a person by default, 0.25 m cells) | +| `NavGround { v, nv, t, nt, area, cyl, nc, foot, nf }` | what it is built from: triangles, an area byte each, cylinders (`x, y, z, r, h`) and footprints (`n`, n points `x z`, `ymin`, `ymax`) | +| `nav_build(kind, ground, config) -> bool`, `nav_polygons(kind)` | one mesh over all of it | +| `nav_tiled(kind, ox, oz, tile, max_tiles, max_polys) -> bool`, `nav_tile_build(kind, tx, tz, ground, config) -> int` | a map of square tiles, a tile at a time (its polygons, 0 none, -1 failed) | +| `nav_save_file(kind, path)`, `nav_load_file(kind, path)`, `nav_reset()` | a baked mesh written and read; every mesh let go | +| `nav_nearest(kind, x, y, z) -> bool`, `nav_near_x/y/z()` | the nearest walkable point within a couple of metres | +| `nav_path(kind, sx, sy, sz, ex, ey, ez) -> int`, `nav_corners()`, `nav_corner_x/y/z(i)`, `nav_partial(kind)` | a way as corners | +| `nav_straight(kind, sx, sy, sz, ex, ez) -> bool` | does the straight line stay walkable | +| `nav_random_near(kind, x, y, z, r, seed) -> bool`, `nav_near_x/y/z()` | a reachable point about r away, from the caller's seed | +| `nav_area_cost(kind, area, cost)` | how dear a kind of ground is to cross | + +## Tests + +```bash +ludic test packages/ludic.nav +``` + +A 40 m meadow built by hand: a path round a post and a boulder's footprint, over a river's ford, +stopping at the bank of a river with none, the nearest point, random points that never cross the +river, and a saved mesh answering as the built one did. And a 128 m meadow as four tiles: a path +across the seams, a tile under water with no polygons, and the set saved and loaded. + +## The native library + +`native/build.sh` fetches Recast & Detour v1.6.0, checks its SHA-256, and builds Recast, Detour +and the shim (`native/shim/nav_shim.cpp`) into `lib//` - the same script on the Mac and on +the PC (Git Bash, the LLVM installer's clang). `native/LICENSE-recastnavigation` ships with it. diff --git a/packages/ludic.nav/build.ludic b/packages/ludic.nav/build.ludic new file mode 100644 index 00000000..15fc5233 --- /dev/null +++ b/packages/ludic.nav/build.ludic @@ -0,0 +1,66 @@ +# build.ludic - a kind's mesh: whole from its ground, or tile by tile (a map), or from a bake's file +function nv_ground_ok(g: NavGround) -> bool { + if g.v == null or g.t == null or g.area == null { return false } + if len(g.v) < g.nv * 3 or len(g.t) < g.nt * 3 or len(g.area) < g.nt { return false } + if g.nc > 0 and (g.cyl == null or len(g.cyl) < g.nc * 5) { return false } + return g.nf == 0 or (g.foot != null and len(g.foot) >= g.nf) +} +# the configuration in the state's own buffer: a map is thousands of tile builds, and nothing +# allocated per build is ever given back +function nv_cfg(nav_st: mut NavState, c: NavConfig) -> []float { + let cfg = nav_st.nv_cfg + cfg[0] = c.cell + cfg[1] = c.cell_h + cfg[2] = c.height + cfg[3] = c.radius + cfg[4] = c.climb + cfg[5] = c.slope + cfg[6] = Math.max(c.detail, 0.9) + return cfg +} +function nv_or_none(nav_st: NavState, xs: []float, n: int) -> []float { + if xs == null or n == 0 { return nav_st.nv_empty } + return xs +} + +# one mesh over the whole of g +export function nav_build(nav_st: mut NavState, kind: int, g: NavGround, c: NavConfig) -> bool { + if not nv_ground_ok(g) { return false } + return nv_set(nav_st, kind, nvc_build(g.v, g.nv, g.t, g.nt, g.area, nv_or_none(nav_st, g.cyl, g.nc), g.nc, nv_or_none(nav_st, g.foot, g.nf), g.nf, nv_cfg(nav_st, c))) +} + +# an empty mesh of square tiles `tile` metres across from (ox, oz), for a map built a tile at a time +export function nav_tiled(nav_st: mut NavState, kind: int, ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> bool { + return nv_set(nav_st, kind, nvc_tiled(ox, oz, tile, max_tiles, max_polys)) +} +# tile (tx, tz) from ground g covering it and a few metres round it: its polygons, 0 none, -1 failed +export function nav_tile_build(nav_st: mut NavState, kind: int, tx: int, tz: int, g: NavGround, c: NavConfig) -> int { + let h = nv_mesh(nav_st, kind) + if h == null or not nv_ground_ok(g) { return -1 } + return nvc_tile_build(h, tx, tz, g.v, g.nv, g.t, g.nt, g.area, nv_or_none(nav_st, g.cyl, g.nc), g.nc, nv_or_none(nav_st, g.foot, g.nf), g.nf, nv_cfg(nav_st, c)) +} + +# the polygons a kind's mesh has (0: none) +export function nav_polygons(nav_st: NavState, kind: int) -> int { + let h = nv_mesh(nav_st, kind) + if h == null { return 0 } + 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 +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 } + let n = nvc_save(h, null, 0) + if n <= 0 { return false } + let buf = buffer(n) + if nvc_save(h, buf, n) != n { return false } + 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))) +} diff --git a/packages/ludic.nav/index.ludic b/packages/ludic.nav/index.ludic new file mode 100644 index 00000000..3f380a8d --- /dev/null +++ b/packages/ludic.nav/index.ludic @@ -0,0 +1,10 @@ +# ludic.nav - a navmesh per kind of walker (a person, a large animal, a small one): built from +# triangles and the cylinders nothing stands in, or loaded from the bytes a bake saved; asked for +# the nearest walkable point, a path as corners, and whether a straight line stays walkable. +module ludic_nav uses ludic_base +numbers float +import "ludic.base" +import "native.ludic" +import "state.ludic" +import "build.ludic" +import "query.ludic" diff --git a/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib b/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib new file mode 100755 index 00000000..f88c1f04 --- /dev/null +++ b/packages/ludic.nav/lib/macos-arm64/libludicnav.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43832102310c1fa30e7efbca67b2665c018997d12efe784b71ebe98f171fde64 +size 176736 diff --git a/packages/ludic.nav/lib/windows-x64/ludicnav.dll b/packages/ludic.nav/lib/windows-x64/ludicnav.dll new file mode 100644 index 00000000..eebe1807 --- /dev/null +++ b/packages/ludic.nav/lib/windows-x64/ludicnav.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f72ae70374e436fd4af012c6f04abf6ab67a977e799d99c49cba9e2afe92bcaa +size 336384 diff --git a/packages/ludic.nav/lib/windows-x64/ludicnav.lib b/packages/ludic.nav/lib/windows-x64/ludicnav.lib new file mode 100644 index 00000000..46a507d1 --- /dev/null +++ b/packages/ludic.nav/lib/windows-x64/ludicnav.lib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48f55cfffe5d3e99ae24b68a34b272bfc8ea693ab1f9d849e3e99f17797fee2b +size 3492 diff --git a/packages/ludic.nav/native.ludic b/packages/ludic.nav/native.ludic new file mode 100644 index 00000000..61bb8f3c --- /dev/null +++ b/packages/ludic.nav/native.ludic @@ -0,0 +1,15 @@ +# native.ludic - the shim's symbols (native/shim/nav_shim.cpp). A navmesh is a handle the package +# keeps; points come back through a float buffer it owns. None of this is exported. +extern function nvc_build(v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> pointer = "nav_build" +extern function nvc_tiled(ox: float, oz: float, tile: float, max_tiles: int, max_polys: int) -> pointer = "nav_tiled" +extern function nvc_tile_build(h: pointer, tx: int, tz: int, v: pointer, nv: int, t: pointer, nt: int, area: pointer, cyl: pointer, nc: int, foot: pointer, nf: int, c: pointer) -> int = "nav_tile_build" +extern function nvc_save(h: pointer, buf: pointer, cap: int) -> int = "nav_save" +extern function nvc_load(buf: pointer, size: int) -> pointer = "nav_load" +extern function nvc_free(h: pointer) -> void = "nav_free" +extern function nvc_polys(h: pointer) -> int = "nav_polys" +extern function nvc_nearest(h: pointer, x: float, y: float, z: float, out: pointer) -> int = "nav_nearest" +extern function nvc_area_cost(h: pointer, area: int, cost: float) -> void = "nav_area_cost" +extern function nvc_path(h: pointer, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float, out: pointer, max: int) -> int = "nav_path" +extern function nvc_partial(h: pointer) -> int = "nav_partial" +extern function nvc_raycast(h: pointer, sx: float, sy: float, sz: float, ex: float, ez: float) -> int = "nav_raycast" +extern function nvc_random_near(h: pointer, x: float, y: float, z: float, r: float, seed: int, out: pointer) -> int = "nav_random_near" diff --git a/packages/ludic.nav/native/LICENSE-recastnavigation b/packages/ludic.nav/native/LICENSE-recastnavigation new file mode 100644 index 00000000..c259ddf6 --- /dev/null +++ b/packages/ludic.nav/native/LICENSE-recastnavigation @@ -0,0 +1,18 @@ +Copyright (c) 2009 Mikko Mononen memon@inside.org + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not +claim that you wrote the original software. If you use this software +in a product, an acknowledgment in the product documentation would be +appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be +misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + diff --git a/packages/ludic.nav/native/build.sh b/packages/ludic.nav/native/build.sh new file mode 100755 index 00000000..6bc4323a --- /dev/null +++ b/packages/ludic.nav/native/build.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# builds lib// for ludic.nav: Recast & Detour at a pinned tag, and the shim over them. +# Recast builds the mesh, Detour answers it; DetourTileCache and DetourCrowd come with phases 17.8 +# and 18. Polygon refs are 64-bit (DT_POLYREF64): an 8 km map in 64 m tiles is 16384 tiles, which +# 32-bit refs would leave 256 polygons each. Objects go to build/native; a few seconds on eight cores. +set -eu +PKG="$(cd "$(dirname "$0")/.." && pwd)" +. "$PKG/../../tools/native/lib.sh" +RC_TAG=v1.6.0 +RC_SHA=d48ca0121962fa0639502c0f56c4e3ae72f98e55d88727225444f500775c0074 +SRC="$PKG/build/src/recast-$RC_TAG" +native_fetch "$SRC" "https://github.com/recastnavigation/recastnavigation/archive/refs/tags/$RC_TAG.tar.gz" "$RC_SHA" + +OBJ="$PKG/build/native/$(native_target)" +mkdir -p "$OBJ" +CXX="$(native_cxx)" +INC="-I$SRC/Recast/Include -I$SRC/Detour/Include" +FLAGS="$(native_cflags) -std=c++17 -ffp-contract=off -fno-exceptions -fno-rtti -DNDEBUG -DDT_POLYREF64 $INC" +case "$(native_target)" in windows-*) FLAGS="$FLAGS -D_CRT_SECURE_NO_WARNINGS" ;; esac +JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 4)" + +( cd "$SRC" && ls Recast/Source/*.cpp Detour/Source/*.cpp ) | while read -r f; do + o="$OBJ/$(basename "$f" .cpp).o" + [ "$o" -nt "$SRC/$f" ] || echo "$f $o" +done | xargs -P "$JOBS" -n 2 sh -c '"$0" '"$FLAGS"' -c "'"$SRC"'/$1" -o "$2"' "$CXX" +"$CXX" $FLAGS -I"$PKG/native/shim" -c "$PKG/native/shim/nav_shim.cpp" -o "$OBJ/nav_shim.o" + +native_link "$PKG" ludicnav "$OBJ"/*.o diff --git a/packages/ludic.nav/native/shim/nav_build.inl b/packages/ludic.nav/native/shim/nav_build.inl new file mode 100644 index 00000000..39f22cfa --- /dev/null +++ b/packages/ludic.nav/native/shim/nav_build.inl @@ -0,0 +1,129 @@ +// nav_build.inl - Recast's pipeline, shared by a whole mesh and a tile: rasterize the triangles, +// filter, mark what nothing stands in, erode, regions, contours, polygons, detail, Detour's data. +// c[] is the configuration: cell size, cell height, agent height, radius, max climb, max slope, and +// the detail mesh's sample spacing (at least 0.9 m, Recast's own limit; 0 grew without bound) + +// cylinders (x, y, z, r, h each) no agent may stand in: trunks, posts, tents +static void nav_mark_cylinders(rcContext &ctx, rcCompactHeightfield &chf, const float *cyl, int nc) { + for (int i = 0; i < nc; ++i) { + float pos[3] = {cyl[i * 5], cyl[i * 5 + 1], cyl[i * 5 + 2]}; + rcMarkCylinderArea(&ctx, pos, cyl[i * 5 + 3], cyl[i * 5 + 4], RC_NULL_AREA, chf); + } +} + +// convex footprints no agent may stand in (a boulder): each is n, n points (x, z), ymin, ymax +static void nav_mark_footprints(rcContext &ctx, rcCompactHeightfield &chf, const float *f, int nf) { + float pts[3 * 64]; + int i = 0; + while (i < nf) { + int n = (int)f[i]; + if (n < 3 || n > 64 || i + 1 + n * 2 + 2 > nf) return; + float y0 = f[i + 1 + n * 2], y1 = f[i + 2 + n * 2]; + for (int k = 0; k < n; ++k) { pts[k * 3] = f[i + 1 + k * 2]; pts[k * 3 + 1] = y0; pts[k * 3 + 2] = f[i + 2 + k * 2]; } + rcMarkConvexPolyArea(&ctx, pts, n, y0, y1, RC_NULL_AREA, chf); + i += 3 + n * 2; + } +} + +static void nav_config(rcConfig &cfg, const float *c) { + memset(&cfg, 0, sizeof(cfg)); + cfg.cs = c[0]; + cfg.ch = c[1]; + cfg.walkableSlopeAngle = c[5]; + cfg.walkableHeight = (int)ceilf(c[2] / cfg.ch); + cfg.walkableClimb = (int)floorf(c[4] / cfg.ch); + cfg.walkableRadius = (int)ceilf(c[3] / cfg.cs); + cfg.maxEdgeLen = (int)(12.0f / cfg.cs); + cfg.maxSimplificationError = 1.3f; + cfg.minRegionArea = 8 * 8; + cfg.mergeRegionArea = 20 * 20; + cfg.maxVertsPerPoly = 6; + cfg.detailSampleDist = c[6] > 0.9f ? c[6] : 0.9f; + cfg.detailSampleMaxError = cfg.ch; +} + +struct NavIn { + const float *v; int nv; const int *t; int nt; const unsigned char *area; + const float *cyl; int nc; const float *foot; int nf; const float *c; +}; + +// the pipeline over cfg's box; the tile's Detour data into data / size. 0 polygons is not an error +static bool nav_pipeline(rcConfig &cfg, const NavIn &in, int tx, int tz, unsigned char **data, int *size, int *polys) { + rcContext ctx(false); + std::vector areas(in.area, in.area + in.nt); + rcClearUnwalkableTriangles(&ctx, cfg.walkableSlopeAngle, in.v, in.nv, in.t, in.nt, areas.data()); + rcHeightfield *hf = rcAllocHeightfield(); + rcCompactHeightfield *chf = rcAllocCompactHeightfield(); + rcContourSet *cs = rcAllocContourSet(); + rcPolyMesh *pm = rcAllocPolyMesh(); + rcPolyMeshDetail *dm = rcAllocPolyMeshDetail(); + bool ok = hf && chf && cs && pm && dm && + rcCreateHeightfield(&ctx, *hf, cfg.width, cfg.height, cfg.bmin, cfg.bmax, cfg.cs, cfg.ch) && + rcRasterizeTriangles(&ctx, in.v, in.nv, in.t, areas.data(), in.nt, *hf, cfg.walkableClimb); + if (ok) { + rcFilterLowHangingWalkableObstacles(&ctx, cfg.walkableClimb, *hf); + rcFilterLedgeSpans(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf); + rcFilterWalkableLowHeightSpans(&ctx, cfg.walkableHeight, *hf); + ok = rcBuildCompactHeightfield(&ctx, cfg.walkableHeight, cfg.walkableClimb, *hf, *chf); + } + if (ok) { + nav_mark_cylinders(ctx, *chf, in.cyl, in.nc); + nav_mark_footprints(ctx, *chf, in.foot, in.nf); + ok = rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf) && rcBuildDistanceField(&ctx, *chf) && + rcBuildRegions(&ctx, *chf, cfg.borderSize, cfg.minRegionArea, cfg.mergeRegionArea) && + rcBuildContours(&ctx, *chf, cfg.maxSimplificationError, cfg.maxEdgeLen, *cs) && + rcBuildPolyMesh(&ctx, *cs, cfg.maxVertsPerPoly, *pm) && + rcBuildPolyMeshDetail(&ctx, *pm, *chf, cfg.detailSampleDist, cfg.detailSampleMaxError, *dm); + } + *polys = ok ? pm->npolys : 0; + if (ok && pm->npolys > 0) { + for (int i = 0; i < pm->npolys; ++i) pm->flags[i] = pm->areas[i] ? 1 : 0; + dtNavMeshCreateParams p; + memset(&p, 0, sizeof(p)); + p.verts = pm->verts; p.vertCount = pm->nverts; p.polys = pm->polys; p.polyAreas = pm->areas; + p.polyFlags = pm->flags; p.polyCount = pm->npolys; p.nvp = pm->nvp; + p.detailMeshes = dm->meshes; p.detailVerts = dm->verts; p.detailVertsCount = dm->nverts; + p.detailTris = dm->tris; p.detailTriCount = dm->ntris; + p.walkableHeight = in.c[2]; p.walkableRadius = in.c[3]; p.walkableClimb = in.c[4]; + p.tileX = tx; p.tileY = tz; + rcVcopy(p.bmin, pm->bmin); rcVcopy(p.bmax, pm->bmax); + p.cs = cfg.cs; p.ch = cfg.ch; p.buildBvTree = true; + ok = dtCreateNavMeshData(&p, data, size); + } + rcFreeHeightField(hf); rcFreeCompactHeightfield(chf); rcFreeContourSet(cs); rcFreePolyMesh(pm); rcFreePolyMeshDetail(dm); + return ok; +} + +// one mesh over all the triangles given: v nv points (x, y, z); t nt triangles; area a byte each, +// 0 not walkable and 1..62 a kind of ground; the slope clears what is too steep whatever its area +NAV_SHIM void *nav_build(const float *v, int nv, const int *t, int nt, const unsigned char *area, + const float *cyl, int nc, const float *foot, int nf, const float *c) { + rcConfig cfg; + nav_config(cfg, c); + rcCalcBounds(v, nv, cfg.bmin, cfg.bmax); + rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height); + NavIn in = {v, nv, t, nt, area, cyl, nc, foot, nf, c}; + unsigned char *data = nullptr; + int size = 0, polys = 0; + if (!nav_pipeline(cfg, in, 0, 0, &data, &size, &polys) || polys == 0) return nullptr; + return nav_from(data, size); +} + +NAV_SHIM void nav_free(void *h) { + Nav *n = static_cast