From 9b8606483d8663f53f8d544449d9cc7f2cd5a332 Mon Sep 17 00:00:00 2001 From: Orkuncakilkaya Date: Sun, 27 Sep 2026 15:29:11 +0300 Subject: [PATCH 1/2] feat(nav): 17.1/17.3 - ludic.nav over Recast & Detour v1.6.0 (zlib), built here from the pinned tag on the Mac and the PC (the DLL imports KERNEL32 alone): a navmesh per kind of walker from triangles with an area byte each and cylinders nothing stands in, saved and loaded as bytes; the nearest point, a path as corners (partial when the end cannot be reached), whether a straight line stays walkable, a cost per kind of ground. Tests on a hand-built meadow: round a post, over a ford, stopped at a bank, a saved mesh answering alike - Mac and PC Co-Authored-By: Claude Opus 5.5 --- packages/README.md | 1 + packages/ludic.nav/README.md | 56 +++++++++ packages/ludic.nav/build.ludic | 37 ++++++ packages/ludic.nav/index.ludic | 10 ++ .../lib/macos-arm64/libludicnav.dylib | 3 + .../ludic.nav/lib/windows-x64/ludicnav.dll | 3 + .../ludic.nav/lib/windows-x64/ludicnav.lib | 3 + packages/ludic.nav/native.ludic | 12 ++ .../ludic.nav/native/LICENSE-recastnavigation | 18 +++ packages/ludic.nav/native/build.sh | 27 +++++ packages/ludic.nav/native/shim/nav_build.inl | 108 +++++++++++++++++ packages/ludic.nav/native/shim/nav_query.inl | 53 +++++++++ packages/ludic.nav/native/shim/nav_shim.cpp | 50 ++++++++ packages/ludic.nav/package.ludic | 7 ++ packages/ludic.nav/query.ludic | 43 +++++++ packages/ludic.nav/state.ludic | 46 ++++++++ packages/ludic.nav/tests/nav_test.ludic | 110 ++++++++++++++++++ 17 files changed, 587 insertions(+) create mode 100644 packages/ludic.nav/README.md create mode 100644 packages/ludic.nav/build.ludic create mode 100644 packages/ludic.nav/index.ludic create mode 100755 packages/ludic.nav/lib/macos-arm64/libludicnav.dylib create mode 100644 packages/ludic.nav/lib/windows-x64/ludicnav.dll create mode 100644 packages/ludic.nav/lib/windows-x64/ludicnav.lib create mode 100644 packages/ludic.nav/native.ludic create mode 100644 packages/ludic.nav/native/LICENSE-recastnavigation create mode 100755 packages/ludic.nav/native/build.sh create mode 100644 packages/ludic.nav/native/shim/nav_build.inl create mode 100644 packages/ludic.nav/native/shim/nav_query.inl create mode 100644 packages/ludic.nav/native/shim/nav_shim.cpp create mode 100644 packages/ludic.nav/package.ludic create mode 100644 packages/ludic.nav/query.ludic create mode 100644 packages/ludic.nav/state.ludic create mode 100644 packages/ludic.nav/tests/nav_test.ludic 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.nav/README.md b/packages/ludic.nav/README.md new file mode 100644 index 00000000..73528849 --- /dev/null +++ b/packages/ludic.nav/README.md @@ -0,0 +1,56 @@ +# 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 triangles, or loaded from a bake.** `nav_build` rasterizes triangles - each with an + area byte, 0 not walkable and 1..62 a kind of ground - and marks every cylinder given (a trunk, a + post) as somewhere nothing stands; ground steeper than the slope is cleared whatever its area. A + game bakes its maps once and loads the bytes (`nav_save_file` / `nav_load_file`): a map is never + built at start-up. +- **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 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) | +| `nav_build(kind, v, nv, t, nt, area, cyl, nc, config) -> bool`, `nav_polygons(kind)` | a mesh from triangles and cylinders (`x, y, z, r, h` each) | +| `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_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, over a river's ford, stopping at the bank of a +river with none, the nearest point, and a saved mesh answering as the built one did. + +## 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..b92ef905 --- /dev/null +++ b/packages/ludic.nav/build.ludic @@ -0,0 +1,37 @@ +# build.ludic - a kind's mesh from triangles, or from a file a bake wrote +# v: nv points (x, y, z); t: nt triangles (three indices each); area: one byte per triangle, 0 not +# walkable, 1..62 a kind of ground; cyl: nc cylinders (x, y, z, r, h) nothing stands in +export function nav_build(nav_st: mut NavState, kind: int, v: []float, nv: int, t: []int, nt: int, area: []byte, cyl: []float, nc: int, c: NavConfig) -> bool { + if len(v) < nv * 3 or len(t) < nt * 3 or len(area) < nt or len(cyl) < nc * 5 { return false } + let cfg = floats(6) + 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 + return nv_set(nav_st, kind, nvc_build(v, nv, t, nt, area, cyl, nc, cfg)) +} + +# 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 +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) + 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..9d6ee7d3 --- /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:5dd50af00e11ecd47c3c284818f153932b51ed4042be11447f17f44ed9a8e737 +size 159424 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..95d31837 --- /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:ef9b8a4cb718f3c47b0d7e042f9307b605288646f523df84777d6c53db00cf37 +size 333824 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..e8a20a66 --- /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:14ea139aeddc712a0c580947ec3d4a90339aa92b54d80b1a8039bb0e88093405 +size 2922 diff --git a/packages/ludic.nav/native.ludic b/packages/ludic.nav/native.ludic new file mode 100644 index 00000000..c53a9a8f --- /dev/null +++ b/packages/ludic.nav/native.ludic @@ -0,0 +1,12 @@ +# 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, c: pointer) -> pointer = "nav_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" 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..c63bed78 --- /dev/null +++ b/packages/ludic.nav/native/build.sh @@ -0,0 +1,27 @@ +#!/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. 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 $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..efda5577 --- /dev/null +++ b/packages/ludic.nav/native/shim/nav_build.inl @@ -0,0 +1,108 @@ +// nav_build.inl - a navmesh from triangles: Recast's solo pipeline (rasterize, filter, erode, +// regions, contours, polygons, detail) and one Detour tile. c[] is the configuration: +// cell size, cell height, agent height, agent radius, max climb, max slope (degrees). + +// 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); + } +} + +// v: nv points (x, y, z); t: nt triangles; area: one per triangle, 0 not walkable, 1..62 a kind of +// ground (its cost is the filter's); 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 *c) { + rcContext ctx(false); + rcConfig cfg; + 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 = cfg.cs * 6.0f; + cfg.detailSampleMaxError = cfg.ch; + rcCalcBounds(v, nv, cfg.bmin, cfg.bmax); + rcCalcGridSize(cfg.bmin, cfg.bmax, cfg.cs, &cfg.width, &cfg.height); + + std::vector areas(area, area + nt); + rcClearUnwalkableTriangles(&ctx, cfg.walkableSlopeAngle, v, nv, t, 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, v, nv, t, areas.data(), 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, cyl, nc); + ok = rcErodeWalkableArea(&ctx, cfg.walkableRadius, *chf) && rcBuildDistanceField(&ctx, *chf) && + rcBuildRegions(&ctx, *chf, 0, 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); + } + unsigned char *data = nullptr; + int size = 0; + if (ok) { + 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 = c[2]; p.walkableRadius = c[3]; p.walkableClimb = c[4]; + 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); + if (!ok) return nullptr; + return nav_from(data, size); +} + +// the tile's bytes, to write to a file: first the size (buf null), then the copy +NAV_SHIM int nav_save(void *h, unsigned char *buf, int cap) { + Nav *n = static_cast