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 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-27 15:29:11 +03:00
parent e397e4eee9
commit 9b8606483d
17 changed files with 587 additions and 0 deletions

View file

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

View file

@ -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/<target>/` - the same script on the Mac and on
the PC (Git Bash, the LLVM installer's clang). `native/LICENSE-recastnavigation` ships with it.

View file

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

View file

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

BIN
packages/ludic.nav/lib/macos-arm64/libludicnav.dylib (Stored with Git LFS) Executable file

Binary file not shown.

BIN
packages/ludic.nav/lib/windows-x64/ludicnav.dll (Stored with Git LFS) Normal file

Binary file not shown.

BIN
packages/ludic.nav/lib/windows-x64/ludicnav.lib (Stored with Git LFS) Normal file

Binary file not shown.

View file

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

View file

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

View file

@ -0,0 +1,27 @@
#!/bin/sh
# builds lib/<target>/ 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

View file

@ -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<unsigned char> 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<Nav *>(h);
if (!buf) return n->size;
if (cap < n->size) return -1;
memcpy(buf, n->data, n->size);
return n->size;
}
// a navmesh from bytes nav_save wrote
NAV_SHIM void *nav_load(const unsigned char *buf, int size) {
unsigned char *data = static_cast<unsigned char *>(dtAlloc(size, DT_ALLOC_PERM));
if (!data) return nullptr;
memcpy(data, buf, size);
return nav_from(data, size);
}
NAV_SHIM void nav_free(void *h) {
Nav *n = static_cast<Nav *>(h);
if (!n) return;
dtFreeNavMeshQuery(n->query);
dtFreeNavMesh(n->mesh);
delete n;
}
NAV_SHIM int nav_polys(void *h) {
const dtMeshTile *t = static_cast<const dtNavMesh *>(static_cast<Nav *>(h)->mesh)->getTile(0);
return t && t->header ? t->header->polyCount : 0;
}

View file

@ -0,0 +1,53 @@
// nav_query.inl - what a navmesh answers: the nearest point on it, a path as corner points, and
// whether a straight line stays on it. Points go out through out[] (x, y, z each).
static dtPolyRef nav_poly_at(Nav *n, const float *p, float *on) {
dtPolyRef r = 0;
if (dtStatusFailed(n->query->findNearestPoly(p, n->ext, &n->filter, &r, on))) return 0;
return r;
}
// the nearest point on the mesh to (x, y, z) into out; 0 when none is within reach
NAV_SHIM int nav_nearest(void *h, float x, float y, float z, float *out) {
float p[3] = {x, y, z};
return nav_poly_at(static_cast<Nav *>(h), p, out) ? 1 : 0;
}
// a cost per kind of ground (area 1..62): the filter every query uses
NAV_SHIM void nav_area_cost(void *h, int area, float cost) {
Nav *n = static_cast<Nav *>(h);
if (area > 0 && area < DT_MAX_AREAS) n->filter.setAreaCost(area, cost);
}
// the corners of a path from one point to another, at most max of them, into out: the count, or
// -1 when either end is off the mesh. One that cannot reach the end stops as near it as the mesh
// allows, and nav_partial says so
NAV_SHIM int nav_path(void *h, float sx, float sy, float sz, float ex, float ey, float ez, float *out, int max) {
Nav *n = static_cast<Nav *>(h);
float s[3] = {sx, sy, sz}, e[3] = {ex, ey, ez}, so[3], eo[3];
dtPolyRef a = nav_poly_at(n, s, so), b = nav_poly_at(n, e, eo);
n->partial = 0;
if (!a || !b) return -1;
int np = 0;
dtStatus st = n->query->findPath(a, b, so, eo, &n->filter, n->polys, &np, NAV_MAX_POLYS);
if (dtStatusFailed(st) || np == 0) return -1;
if (dtStatusDetail(st, DT_PARTIAL_RESULT) || n->polys[np - 1] != b) n->partial = 1;
float end[3];
dtVcopy(end, eo);
if (n->polys[np - 1] != b) n->query->closestPointOnPoly(n->polys[np - 1], eo, end, nullptr);
int count = 0;
if (dtStatusFailed(n->query->findStraightPath(so, end, n->polys, np, out, nullptr, nullptr, &count, max, 0))) return -1;
return count;
}
NAV_SHIM int nav_partial(void *h) { return static_cast<Nav *>(h)->partial; }
// does the straight line from one point to another stay on the mesh? 1 yes, 0 no, -1 off it
NAV_SHIM int nav_raycast(void *h, float sx, float sy, float sz, float ex, float ez) {
Nav *n = static_cast<Nav *>(h);
float s[3] = {sx, sy, sz}, so[3], e[3] = {ex, sy, ez}, t = 0, nrm[3];
dtPolyRef a = nav_poly_at(n, s, so);
if (!a) return -1;
int np = 0;
if (dtStatusFailed(n->query->raycast(a, so, e, &n->filter, &t, nrm, n->polys, &np, NAV_MAX_POLYS))) return -1;
return t >= 1.0f ? 1 : 0;
}

View file

@ -0,0 +1,50 @@
// nav_shim.cpp - ludic.nav's door into Recast & Detour (phase 17). The shim rules of
// packages/README.md: int, float and opaque handles cross; no struct by value, no callback into
// Ludic; what a query finds is written into a buffer the package owns; errors are return codes.
#include <Recast.h>
#include <DetourNavMesh.h>
#include <DetourNavMeshBuilder.h>
#include <DetourNavMeshQuery.h>
#include <DetourCommon.h>
#include <cstring>
#include <cmath>
#include <vector>
#if defined(_WIN32)
#define NAV_SHIM extern "C" __declspec(dllexport)
#else
#define NAV_SHIM extern "C" __attribute__((visibility("default")))
#endif
namespace {
const int NAV_MAX_POLYS = 512; // polygons a path may cross; a longer one is cut there
const int NAV_MAX_NODES = 4096; // the search's open list
// a navmesh and the one query that walks it, with the scratch a path needs
struct Nav {
dtNavMesh *mesh = nullptr;
dtNavMeshQuery *query = nullptr;
dtQueryFilter filter;
float ext[3] = {2.0f, 4.0f, 2.0f}; // how far to look for the mesh around a point
dtPolyRef polys[NAV_MAX_POLYS];
unsigned char *data = nullptr; // the tile as built or loaded (the mesh frees it)
int size = 0;
int partial = 0; // the last path stopped short of its end
};
Nav *nav_from(unsigned char *data, int size) {
Nav *n = new Nav();
n->mesh = dtAllocNavMesh();
if (!n->mesh || dtStatusFailed(n->mesh->init(data, size, DT_TILE_FREE_DATA))) { dtFree(data); dtFreeNavMesh(n->mesh); delete n; return nullptr; }
n->data = data;
n->size = size;
n->query = dtAllocNavMeshQuery();
if (!n->query || dtStatusFailed(n->query->init(n->mesh, NAV_MAX_NODES))) { dtFreeNavMeshQuery(n->query); dtFreeNavMesh(n->mesh); delete n; return nullptr; }
n->filter.setIncludeFlags(0xffff);
n->filter.setExcludeFlags(0);
return n;
}
} // namespace
#include "nav_build.inl"
#include "nav_query.inl"

View file

@ -0,0 +1,7 @@
# ludic.nav - a walkable mesh of the world and the ways across it, over Recast & Detour (phase 17),
# built here from a pinned tag (native/build.sh). Uses ludic.base and nothing else.
package "ludic.nav"
version "0.1.0"
kind source
native "macos-arm64" "lib/macos-arm64/libludicnav.dylib"
native "windows-x64" "lib/windows-x64/ludicnav.dll"

View file

@ -0,0 +1,43 @@
# query.ludic - what a kind's mesh answers. A path is its corners, read back one at a time
# (nav_corner_x / _y / _z); the nearest point the same (nav_near_x / _y / _z)
# the nearest walkable point to (x, y, z) within a couple of metres; false when there is none
export function nav_nearest(nav_st: NavState, kind: int, x: float, y: float, z: float) -> bool {
let h = nv_mesh(nav_st, kind)
if h == null { return false }
return nvc_nearest(h, x, y, z, nav_st.nv_near) == 1
}
export function nav_near_x(nav_st: NavState) -> float { return nav_st.nv_near[0] }
export function nav_near_y(nav_st: NavState) -> float { return nav_st.nv_near[1] }
export function nav_near_z(nav_st: NavState) -> float { return nav_st.nv_near[2] }
# a way from one point to another: its corners, first the start and last the end (or as near the end
# as the mesh reaches - nav_partial says which); -1 when either end is off the mesh
export function nav_path(nav_st: mut NavState, kind: int, sx: float, sy: float, sz: float, ex: float, ey: float, ez: float) -> int {
let h = nv_mesh(nav_st, kind)
nav_st.nv_count = 0
if h == null { return -1 }
let n = nvc_path(h, sx, sy, sz, ex, ey, ez, nav_st.nv_out, NAV_CORNERS)
if n > 0 { nav_st.nv_count = n }
return n
}
export function nav_partial(nav_st: NavState, kind: int) -> bool {
let h = nv_mesh(nav_st, kind)
return h != null and nvc_partial(h) == 1
}
export function nav_corners(nav_st: NavState) -> int { return nav_st.nv_count }
export function nav_corner_x(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3] }
export function nav_corner_y(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3 + 1] }
export function nav_corner_z(nav_st: NavState, i: int) -> float { return nav_st.nv_out[i * 3 + 2] }
# does the straight line from (sx, sy, sz) to (ex, ez) stay walkable?
export function nav_straight(nav_st: NavState, kind: int, sx: float, sy: float, sz: float, ex: float, ez: float) -> bool {
let h = nv_mesh(nav_st, kind)
return h != null and nvc_raycast(h, sx, sy, sz, ex, ez) == 1
}
# how dear a kind of ground is to cross (1 is plain; a trail cheaper, scree dearer)
export function nav_area_cost(nav_st: NavState, kind: int, area: int, cost: float) -> void {
let h = nv_mesh(nav_st, kind)
if h != null { nvc_area_cost(h, area, cost) }
}

View file

@ -0,0 +1,46 @@
# state.ludic - the kinds of walker, what a mesh is built for, and what the package holds: a mesh
# per kind, and the last path's corners and nearest point, read back through the verbs
export const NAV_PERSON: int = 0 # 0.35 m wide, steps what a hiker steps (CHAR_STEP)
export const NAV_LARGE: int = 1 # an elk, a bear, a horse
export const NAV_SMALL: int = 2 # a hare, a marmot
export const NAV_KINDS: int = 3
export const NAV_CORNERS: int = 256 # a path's corners at most; a longer one is cut there
# what a mesh is built for: its voxels (cell across, cell high) and its walker (height, radius,
# the step it takes whatever its gradient, the steepest ground it walks, degrees)
export property NavConfig {
cell: float = 0.3
cell_h: float = 0.2
height: float = 1.8
radius: float = 0.35
climb: float = 0.55
slope: float = 45.0
}
export state NavState {
nv_meshes: []pointer = nv_none()
nv_out: []float = floats(NAV_CORNERS * 3)
nv_count: int = 0
nv_near: []float = floats(3)
}
function nv_none() -> []pointer {
let m = new []pointer
for k in 0 .. NAV_KINDS { push(m, null) }
return m
}
function nv_mesh(nav_st: NavState, kind: int) -> pointer {
if kind < 0 or kind >= NAV_KINDS { return null }
return nav_st.nv_meshes[kind]
}
function nv_set(nav_st: mut NavState, kind: int, h: pointer) -> bool {
if kind < 0 or kind >= NAV_KINDS { return false }
if nav_st.nv_meshes[kind] != null { nvc_free(nav_st.nv_meshes[kind]) }
nav_st.nv_meshes[kind] = h
return h != null
}
# every mesh let go: a new map, or the world closed
export function nav_reset(nav_st: mut NavState) -> void {
for k in 0 .. NAV_KINDS { nv_set(nav_st, k, null) }
nav_st.nv_count = 0
}

View file

@ -0,0 +1,110 @@
# nav_test.ludic - ludic.nav on ground built by hand: a 40 m meadow of metre squares, a post in its
# middle, a river across it (area 0) with a ford or without one. A path goes round the post and over
# the ford, stops at the bank when there is none, and a saved mesh answers as the built one did.
import "ludic.nav"
import "ludic.base"
program NavTest {
numbers float
# the meadow, flat at y = 0; river 1 has a ford at z 30..34, river 2 none, river 0 is no river
function meadow(nav_st: mut NavState, kind: int, river: int, post: bool) -> bool {
let n = 40
let v = floats((n + 1) * (n + 1) * 3)
for j in 0 .. n + 1 {
for i in 0 .. n + 1 {
let o = (j * (n + 1) + i) * 3
v[o] = float(i)
v[o + 1] = 0.0
v[o + 2] = float(j)
}
}
let t = new []int
let area = buffer(n * n * 2)
for j in 0 .. n {
for i in 0 .. n {
let a = j * (n + 1) + i
push(t, a)
push(t, a + n + 1)
push(t, a + 1)
push(t, a + 1)
push(t, a + n + 1)
push(t, a + n + 2)
var k = 1
if river > 0 and i >= 18 and i < 22 and not (river == 1 and j >= 30 and j < 34) { k = 0 }
area[(j * n + i) * 2] = k
area[(j * n + i) * 2 + 1] = k
}
}
let cyl = floats(5)
cyl[0] = 20.0
cyl[1] = -1.0
cyl[2] = 20.0
cyl[3] = 2.0
cyl[4] = 4.0
var nc = 0
if post { nc = 1 }
return nav_build(nav_st, kind, v, (n + 1) * (n + 1), t, n * n * 2, area, cyl, nc, new NavConfig)
}
function last_x(nav_st: NavState) -> float { return nav_corner_x(nav_st, nav_corners(nav_st) - 1) }
function last_z(nav_st: NavState) -> float { return nav_corner_z(nav_st, nav_corners(nav_st) - 1) }
test "a path goes round a post, and the straight line through it is not walkable" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 0, true))
expect(nav_polygons(nav_st, NAV_PERSON) > 0)
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 0.0, 20.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
expect(Math.abs(last_x(nav_st) - 35.0) < 0.1)
for i in 0 .. n {
let dx = nav_corner_x(nav_st, i) - 20.0
let dz = nav_corner_z(nav_st, i) - 20.0
expect(dx * dx + dz * dz > 2.0 * 2.0)
}
expect(not nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 20.0, 35.0, 20.0))
expect(nav_straight(nav_st, NAV_PERSON, 5.0, 0.0, 5.0, 35.0, 5.0))
}
test "a river is crossed at its ford" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 1, false))
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect(n >= 3)
expect(not nav_partial(nav_st, NAV_PERSON))
var ford = false
for i in 0 .. n { if nav_corner_z(nav_st, i) > 29.0 { ford = true } }
expect(ford)
}
test "without a ford the path stops at the near bank and says so" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 2, false))
let n = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect(n >= 1)
expect(nav_partial(nav_st, NAV_PERSON))
expect(last_x(nav_st) < 18.0)
}
test "the nearest walkable point, and nothing off the mesh" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 0, false))
expect(nav_nearest(nav_st, NAV_PERSON, 10.0, 1.0, 10.0))
expect(Math.abs(nav_near_y(nav_st)) < 0.3)
expect(Math.abs(nav_near_x(nav_st) - 10.0) < 0.1)
expect(not nav_nearest(nav_st, NAV_PERSON, -50.0, 0.0, -50.0))
expect_eq(nav_path(nav_st, NAV_PERSON, -50.0, 0.0, -50.0, 10.0, 0.0, 10.0), -1)
expect_eq(nav_path(nav_st, NAV_LARGE, 5.0, 0.0, 5.0, 10.0, 0.0, 10.0), -1)
}
test "a mesh saved and loaded answers as the built one did" (nav_st: mut NavState) {
expect(meadow(nav_st, NAV_PERSON, 1, true))
let path = Os.temp_dir() + "/ludic_nav_test.navmesh"
expect(nav_save_file(nav_st, NAV_PERSON, path))
expect(nav_load_file(nav_st, NAV_SMALL, path))
let a = nav_path(nav_st, NAV_PERSON, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
let ax = last_x(nav_st)
let az = last_z(nav_st)
let b = nav_path(nav_st, NAV_SMALL, 5.0, 0.0, 10.0, 35.0, 0.0, 10.0)
expect_eq(a, b)
expect_near(last_x(nav_st), ax, 0.0001)
expect_near(last_z(nav_st), az, 0.0001)
nav_reset(nav_st)
expect_eq(nav_polygons(nav_st, NAV_SMALL), 0)
}
}