// NavMesh navigation — a Meep example.
//
// Sections:
// §1 Tunables agent + level dimensions, seeds
// §2 Engine bootstrap systems, plugins, camera, lights
// §3 Geometry helpers quad/triangle soup builders
// §4 Level generator seeded platforms + ramp + obstacles
// §5 NavMesh build BinaryTopology source -> NavigationMesh
// §6 NavMesh overlay transparent-blue fill + black wireframe
// §7 Agent + path visualisation capsule, Path, PathFollower, PathDisplay
// §8 Click-to-navigate screen ray -> NavigationMesh.find_path
// §9 HUD, buttons, frame loop
//
// Two platforms at different heights are joined by a ramp. A few obstacles are
// scattered on top; the engine carves them (and the platform edges) out of a
// navigation mesh that respects the agent's radius and height. Click anywhere
// on the ground and the capsule walks the routed path. "Randomize" rebuilds the
// whole level from a fresh seed; the first load is always the same seed.
// ─── Imports ────────────────────────────────────────────────────────────────
import {
BoxGeometry,
BufferGeometry,
DoubleSide,
Float32BufferAttribute,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Raycaster,
Vector2,
} from "three";
import Vector3 from "@woosh/meep-engine/src/core/geom/Vector3.js";
import { randomFloatBetween } from "@woosh/meep-engine/src/core/math/random/randomFloatBetween.js";
import { seededRandom } from "@woosh/meep-engine/src/core/math/random/seededRandom.js";
import { EngineHarness } from "@woosh/meep-engine/src/engine/EngineHarness.js";
import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { ParentEntity } from "@woosh/meep-engine/src/engine/ecs/parent/ParentEntity.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";
import { CapsuleGeometry } from "@woosh/meep-engine/src/engine/graphics/geometry/CapsuleGeometry.js";
import { ShadedGeometry } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometry.js";
import { ShadedGeometryFlags } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometryFlags.js";
import { ShadedGeometrySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/mesh-v2/ShadedGeometrySystem.js";
import {
AmbientOcclusionPostProcessEffect
} from "@woosh/meep-engine/src/engine/graphics/render/buffer/simple-fx/ao/AmbientOcclusionPostProcessEffect.js";
import Path from "@woosh/meep-engine/src/engine/navigation/ecs/components/Path.js";
import { PathEvents } from "@woosh/meep-engine/src/engine/navigation/ecs/components/PathEvents.js";
import PathFollower from "@woosh/meep-engine/src/engine/navigation/ecs/path_following/PathFollower.js";
import { PathFollowerFlags } from "@woosh/meep-engine/src/engine/navigation/ecs/path_following/PathFollowerFlags.js";
import PathFollowingSystem from "@woosh/meep-engine/src/engine/navigation/ecs/path_following/PathFollowingSystem.js";
import { PathDisplay } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplay.js";
import { PathDisplaySpec } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplaySpec.js";
import { PathDisplaySystem } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplaySystem.js";
import { PathDisplayType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/PathDisplayType.js";
import { TubePathStyle } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/TubePathStyle.js";
import { TubeMaterialType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/TubeMaterialType.js";
import { BasicMaterialDefinition } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/BasicMaterialDefinition.js";
import { CapType } from "@woosh/meep-engine/src/engine/graphics/ecs/path/tube/CapType.js";
import { NavigationMesh } from "@woosh/meep-engine/src/engine/navigation/mesh/NavigationMesh.js";
import { BinaryTopology } from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/BinaryTopology.js";
import {
bt_mesh_from_unindexed_geometry
} from "@woosh/meep-engine/src/core/geom/3d/topology/struct/binary/io/bt_mesh_from_unindexed_geometry.js";
// ─── §1 Tunables ─────────────────────────────────────────────────────────────
const AGENT_RADIUS = 0.4; // capsule radius — also the navmesh erosion distance
const AGENT_CYL_HEIGHT = 0.8; // cylindrical mid-section of the capsule
const AGENT_TOTAL_HEIGHT = AGENT_CYL_HEIGHT + 2 * AGENT_RADIUS; // 1.6 — navmesh clearance
const AGENT_SPEED = 5.0; // world units / second
const AGENT_CLIMB_ANGLE = 0.9; // radians (~51°) — the ramp must be gentler than this
const BLOCKER_HEIGHT = 0.5; // obstacle "ceiling" height fed to the navmesh (< AGENT_TOTAL_HEIGHT)
const PILLAR_HEIGHT = 1.3; // visible obstacle height
const PLATFORM_THICKNESS = 1.2; // visible slab depth below the walk surface
const RAMP_THICKNESS = 0.5;
const NAV_FILL_LIFT = 0.04; // lift the overlay off the surface to avoid z-fighting
const NAV_WIRE_LIFT = 0.06; // wireframe slightly higher so it sorts above the fill
const PATH_LIFT = 0.12; // float the path tube just above the ground
// First load is deterministic; "Randomize" reseeds from the clock-ish source the
// brief asks for. seededRandom makes every level fully reproducible from its seed.
const INITIAL_SEED = 0x1A2B3C;
// find_path writes packed XYZ triples; the mesh A* is capped at ~1k faces, so a
// few thousand points is a safe upper bound for the funnelled output.
const pathOut = new Float32Array(4096 * 3);
// ─── §2 Engine bootstrap ─────────────────────────────────────────────────────
const engine = await EngineHarness.bootstrap({
configuration: (config, engine) => {
// Draws every entity carrying a ShadedGeometry (platforms, ramp,
// obstacles, the capsule, and the navmesh overlay).
config.addSystem(new ShadedGeometrySystem(engine));
// Navigation: PathFollowingSystem drives Transform from Path + PathFollower.
config.addSystem(new PathFollowingSystem());
// PathDisplaySystem turns a Path + PathDisplay into renderable geometry
// (here, a tube). It self-acquires the RibbonX plugin it needs on startup.
config.addSystem(new PathDisplaySystem(engine));
config.addPlugin(AmbientOcclusionPostProcessEffect);
},
});
await EngineHarness.buildBasics({
engine,
enableTerrain: false,
enableWater: false,
enableLights: true,
enableShadows: true,
shadowmapResolution: 1024,
focus: new Vector3(0, 1.0, 0),
distance: 58,
pitch: 0.92,
yaw: 0.6,
cameraFieldOfView: 36,
cameraFarDistance: 600,
cameraController: true, // drag to orbit, scroll to zoom
showFps: false,
});
const ecd = engine.entityManager.dataset;
// Register every component type we attach directly. registerComponentType is
// idempotent, so this is safe even where a system already registered one.
for (const Type of [Transform, ShadedGeometry, Path, PathFollower, PathDisplay, ParentEntity]) {
ecd.registerComponentType(Type);
}
// ─── Shared materials (created once; geometry is per-level) ──────────────────
const platformMaterial = new MeshStandardMaterial({ color: 0x8a909c, roughness: 0.95, metalness: 0.0 });
const rampMaterial = new MeshStandardMaterial({ color: 0xa7adb8, roughness: 0.95, metalness: 0.0 });
const obstacleMaterial = new MeshStandardMaterial({ color: 0x3b424e, roughness: 0.9, metalness: 0.05 });
const agentMaterial = new MeshStandardMaterial({ color: 0xff8a3d, roughness: 0.5, metalness: 0.1 });
// NavMesh overlay. depthWrite:false + depthTest:false routes these into the
// engine's transparent pass, so they composite on top of the scene as a
// see-through debug overlay (transparent blue fill, near-black wireframe).
const navFillMaterial = new MeshBasicMaterial({
color: 0x2e7bff, transparent: true, opacity: 0.2, side: DoubleSide,
depthWrite: false, depthTest: true,
});
const navWireMaterial = new MeshBasicMaterial({
color: 0x05070b, wireframe: true, transparent: true, opacity: 0.5,
depthWrite: false, depthTest: true,
});
// Agent geometry is fixed; shift it up so the capsule's base sits at the entity
// origin (which the PathFollower parks on the ground surface).
const capsuleGeometry = new CapsuleGeometry(AGENT_RADIUS, AGENT_CYL_HEIGHT, 8, 16);
capsuleGeometry.translate(0, AGENT_CYL_HEIGHT / 2 + AGENT_RADIUS, 0);
// ─── §3 Geometry helpers ────────────────────────────────────────────────────
// A quad given as four corners P(u,v): p00=(u0,v0), p10=(u1,v0), p01=(u0,v1),
// p11=(u1,v1). The default winding makes the face point "up" (its normal has a
// positive Y component); pass flip=true for a downward-facing quad. Each point
// is [x, y, z].
function pushQuad(out, p00, p10, p01, p11, flip = false) {
if (!flip) {
// up: (p00, p01, p10) and (p10, p01, p11)
out.push(p00[0], p00[1], p00[2], p01[0], p01[1], p01[2], p10[0], p10[1], p10[2]);
out.push(p10[0], p10[1], p10[2], p01[0], p01[1], p01[2], p11[0], p11[1], p11[2]);
} else {
// down: reverse winding
out.push(p00[0], p00[1], p00[2], p10[0], p10[1], p10[2], p01[0], p01[1], p01[2]);
out.push(p10[0], p10[1], p10[2], p11[0], p11[1], p11[2], p01[0], p01[1], p01[2]);
}
}
// A flat axis-aligned rectangle on the y-plane, in the XZ range [x0,x1]×[z0,z1].
function pushFloorRect(out, x0, x1, z0, z1, y, flip = false) {
pushQuad(out,
[x0, y, z0], [x1, y, z0],
[x0, y, z1], [x1, y, z1],
flip
);
}
// ─── §4 Level generator ─────────────────────────────────────────────────────
function clamp(v, lo, hi) {
return v < lo ? lo : (v > hi ? hi : v);
}
// Pick a fully-reproducible level layout from the seeded RNG. The whole thing is
// centred on the X axis so a fixed camera frames any randomised variant.
function generateLayout(rng) {
const aw = randomFloatBetween(rng, 12, 16);
const ad = randomFloatBetween(rng, 12, 16);
const bw = randomFloatBetween(rng, 12, 16);
const bd = randomFloatBetween(rng, 12, 16);
const yB = randomFloatBetween(rng, 2.6, 3.8); // height of the upper platform
const gap = randomFloatBetween(rng, 5.5, 7.5); // ramp horizontal run
const total = aw + gap + bw;
const Ax0 = -total / 2, Ax1 = Ax0 + aw;
const Bx0 = Ax1 + gap, Bx1 = Bx0 + bw;
const Az0 = -ad / 2, Az1 = ad / 2;
const Bz0 = -bd / 2, Bz1 = bd / 2;
// The ramp lives in the Z-overlap of the two platforms, set back a little
// from both edges so the platforms keep a strip on either side of it.
const overlapMin = Math.max(Az0, Bz0);
const overlapMax = Math.min(Az1, Bz1);
const overlapSize = overlapMax - overlapMin;
const margin = 1.2;
const rampW = clamp(randomFloatBetween(rng, 3.0, 4.5), 2.0, overlapSize - 2 * margin);
const rampZc = randomFloatBetween(rng, overlapMin + margin + rampW / 2, overlapMax - margin - rampW / 2);
const rampZ0 = rampZc - rampW / 2;
const rampZ1 = rampZc + rampW / 2;
const layout = {
Ax0, Ax1, Az0, Az1,
Bx0, Bx1, Bz0, Bz1,
yB, rampZ0, rampZ1, rampZc, rampW,
agentStart: new Vector3((Ax0 + Ax1) / 2, 0, (Az0 + Az1) / 2),
obstacles: [],
};
// Obstacles: a couple per platform, inset from the edges and clear of the
// ramp mouth and the agent's start tile.
const aAvoid = [
{ x: layout.agentStart.x, z: layout.agentStart.z, r: 3.5 },
{ x: Ax1, z: rampZc, r: rampW / 2 + 2.5 },
];
const bAvoid = [
{ x: Bx0, z: rampZc, r: rampW / 2 + 2.5 },
];
placeObstacles(rng, layout, { x0: Ax0, x1: Ax1, z0: Az0, z1: Az1, floorY: 0 }, 2, aAvoid);
placeObstacles(rng, layout, { x0: Bx0, x1: Bx1, z0: Bz0, z1: Bz1, floorY: yB }, 2, bAvoid);
return layout;
}
function placeObstacles(rng, layout, rect, count, avoid) {
const edge = 1.8;
let attempts = 0;
// rect.floorY is unique per platform (0 vs yB), so this counts only the
// obstacles already placed on THIS platform.
while (countTotalOn(layout, rect.floorY) < count && attempts < 100) {
attempts++;
const ow = randomFloatBetween(rng, 1.6, 2.6);
const od = randomFloatBetween(rng, 1.6, 2.6);
const xLo = rect.x0 + edge + ow / 2, xHi = rect.x1 - edge - ow / 2;
const zLo = rect.z0 + edge + od / 2, zHi = rect.z1 - edge - od / 2;
if (xHi <= xLo || zHi <= zLo) break;
const x = randomFloatBetween(rng, xLo, xHi);
const z = randomFloatBetween(rng, zLo, zHi);
let ok = true;
for (const a of avoid) {
if (Math.hypot(x - a.x, z - a.z) < a.r + Math.max(ow, od) / 2) { ok = false; break; }
}
if (ok) {
for (const o of layout.obstacles) {
if (Math.abs(x - o.x) < (ow + o.w) / 2 + 0.8 && Math.abs(z - o.z) < (od + o.d) / 2 + 0.8) {
ok = false;
break;
}
}
}
if (!ok) continue;
layout.obstacles.push({ x, z, w: ow, d: od, floorY: rect.floorY });
}
}
function countTotalOn(layout, floorY) {
let n = 0;
for (const o of layout.obstacles) if (o.floorY === floorY) n++;
return n;
}
// ─── §5 NavMesh build ───────────────────────────────────────────────────────
//
// We assemble two triangle soups:
// walkable — the platform tops (split into Z strips so the ramp's edge shares
// vertices with them) and the ramp itself, all facing up.
// blockers — a downward-facing "ceiling" quad floating just above each
// obstacle footprint. These are too steep to be walkable, so they
// are discarded from the floor, but they stay in the source geometry
// the builder ray-casts against for head clearance — which is what
// knocks the obstacle footprints out of the navmesh.
//
// NavigationMesh.build then merges shared vertices, erodes every island by the
// agent radius, and hands back a clean walkable topology.
function buildWalkableSoup(L) {
const t = [];
// Upper platform A (y = 0), split at the ramp band so strip A2's far edge
// coincides with the ramp's low edge.
pushFloorRect(t, L.Ax0, L.Ax1, L.Az0, L.rampZ0, 0);
pushFloorRect(t, L.Ax0, L.Ax1, L.rampZ0, L.rampZ1, 0);
pushFloorRect(t, L.Ax0, L.Ax1, L.rampZ1, L.Az1, 0);
// Lower-or-higher platform B (y = yB), split the same way.
pushFloorRect(t, L.Bx0, L.Bx1, L.Bz0, L.rampZ0, L.yB);
pushFloorRect(t, L.Bx0, L.Bx1, L.rampZ0, L.rampZ1, L.yB);
pushFloorRect(t, L.Bx0, L.Bx1, L.rampZ1, L.Bz1, L.yB);
// Ramp: low edge at (Ax1, 0) shares vertices with A2; high edge at (Bx0, yB)
// shares with B2 — so the three islands fuse into one connected mesh.
pushQuad(t,
[L.Ax1, 0, L.rampZ0], [L.Bx0, L.yB, L.rampZ0],
[L.Ax1, 0, L.rampZ1], [L.Bx0, L.yB, L.rampZ1]
);
return t;
}
function buildBlockerSoup(L) {
const t = [];
for (const o of L.obstacles) {
const y = o.floorY + BLOCKER_HEIGHT;
pushFloorRect(t, o.x - o.w / 2, o.x + o.w / 2, o.z - o.d / 2, o.z + o.d / 2, y, /* flip = */ true);
}
return t;
}
function buildNavMesh(walkable, blockers) {
const sourceTris = walkable.concat(blockers);
const source = new BinaryTopology();
bt_mesh_from_unindexed_geometry(source, sourceTris);
const navmesh = new NavigationMesh();
navmesh.build({
source,
agent_radius: AGENT_RADIUS,
agent_height: AGENT_TOTAL_HEIGHT,
agent_max_climb_angle: AGENT_CLIMB_ANGLE,
});
return navmesh;
}
// ─── §6 NavMesh overlay ─────────────────────────────────────────────────────
// Pull every triangle out of the built navmesh topology as a flat XYZ soup, lift
// it slightly so it doesn't z-fight, and return a three.js geometry. Also reports
// the triangle count for the HUD.
function buildNavOverlayGeometry(topology, lift) {
const faces = topology.faces;
const faceCount = faces.size;
const positions = [];
const v = [0, 0, 0];
let tris = 0;
for (let faceId = 0; faceId < faceCount; faceId++) {
if (!faces.is_allocated(faceId)) continue;
let loop = topology.face_read_loop(faceId);
for (let i = 0; i < 3; i++) {
const vertexId = topology.loop_read_vertex(loop);
topology.vertex_read_coordinate(v, 0, vertexId);
positions.push(v[0], v[1] + lift, v[2]);
loop = topology.loop_read_next(loop);
}
tris++;
}
const geometry = new BufferGeometry();
geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
geometry.computeVertexNormals();
return { geometry, tris };
}
// ─── §7 Tube style for the path display ─────────────────────────────────────
function makeTubeStyle() {
const style = new TubePathStyle();
style.material_type = TubeMaterialType.Basic;
style.material = new BasicMaterialDefinition();
style.color.setRGB(0.31, 0.94, 0.66); // matches the legend's green swatch
style.width = 0.07;
style.cap_type = CapType.Round;
style.path_mask = [0, 1];
style.cast_shadow = false;
style.receive_shadow = false;
return style;
}
// ─── §8 Level lifecycle + navigation ────────────────────────────────────────
let levelEntities = []; // everything we spawn for a level, for teardown
let agentEntity = -1;
let pathVizEntity = -1;
let navmesh = null;
let pickMesh = null; // invisible three.js mesh used only for click raycasts
let navTriCount = 0;
function clearLevel() {
if (levelEntities.length > 0) {
ecd.removeEntities(levelEntities);
levelEntities = [];
}
agentEntity = -1;
pathVizEntity = -1;
navmesh = null;
pickMesh = null;
}
function spawnMesh(geometry, material, position, rotation) {
const sg = ShadedGeometry.from(geometry, material);
sg.setFlag(ShadedGeometryFlags.CastShadow);
sg.setFlag(ShadedGeometryFlags.ReceiveShadow);
const transform = new Transform();
transform.position.copy(position);
if (rotation !== undefined) transform.rotation.copy(rotation);
const id = new Entity()
.add(transform)
.add(sg)
.build(ecd);
levelEntities.push(id);
return id;
}
function spawnOverlay(geometry, material) {
const sg = ShadedGeometry.from(geometry, material);
sg.clearFlag(ShadedGeometryFlags.CastShadow);
sg.clearFlag(ShadedGeometryFlags.ReceiveShadow);
const id = new Entity()
.add(new Transform())
.add(sg)
.build(ecd);
levelEntities.push(id);
return id;
}
function buildLevel(seed) {
clearLevel();
const rng = seededRandom(seed >>> 0);
const L = generateLayout(rng);
// --- NavMesh ---
const walkable = buildWalkableSoup(L);
const blockers = buildBlockerSoup(L);
navmesh = buildNavMesh(walkable, blockers);
// --- Visible platforms ---
spawnMesh(
new BoxGeometry(L.Ax1 - L.Ax0, PLATFORM_THICKNESS, L.Az1 - L.Az0),
platformMaterial,
new Vector3((L.Ax0 + L.Ax1) / 2, -PLATFORM_THICKNESS / 2, (L.Az0 + L.Az1) / 2)
);
spawnMesh(
new BoxGeometry(L.Bx1 - L.Bx0, PLATFORM_THICKNESS, L.Bz1 - L.Bz0),
platformMaterial,
new Vector3((L.Bx0 + L.Bx1) / 2, L.yB - PLATFORM_THICKNESS / 2, (L.Bz0 + L.Bz1) / 2)
);
// --- Visible ramp (a thin slab rotated to match the slope) ---
{
const run = L.Bx0 - L.Ax1;
const rise = L.yB;
const len = Math.hypot(run, rise);
const theta = Math.atan2(rise, run);
const half = theta / 2;
const rotation = { x: 0, y: 0, z: Math.sin(half), w: Math.cos(half) };
// local "up" of the slanted box = (-rise/len, run/len, 0); drop the box
// by half its thickness along it so the top face lands on the walk surface.
const center = new Vector3(
(L.Ax1 + L.Bx0) / 2 + (rise / len) * (RAMP_THICKNESS / 2),
L.yB / 2 - (run / len) * (RAMP_THICKNESS / 2),
L.rampZc
);
spawnMesh(new BoxGeometry(len, RAMP_THICKNESS, L.rampW), rampMaterial, center, rotation);
}
// --- Visible obstacles ---
for (const o of L.obstacles) {
spawnMesh(
new BoxGeometry(o.w, PILLAR_HEIGHT, o.d),
obstacleMaterial,
new Vector3(o.x, o.floorY + PILLAR_HEIGHT / 2, o.z)
);
}
// --- NavMesh overlay (fill + wireframe) ---
const fill = buildNavOverlayGeometry(navmesh.topology, NAV_FILL_LIFT);
const wire = buildNavOverlayGeometry(navmesh.topology, NAV_WIRE_LIFT);
navTriCount = fill.tris;
spawnOverlay(fill.geometry, navFillMaterial);
spawnOverlay(wire.geometry, navWireMaterial);
// --- Pick mesh: the full walkable surface, used only for click raycasts ---
const pickGeometry = new BufferGeometry();
pickGeometry.setAttribute("position", new Float32BufferAttribute(new Float32Array(walkable), 3));
pickGeometry.computeBoundingSphere();
pickMesh = new Mesh(pickGeometry, navFillMaterial); // material is irrelevant; never rendered
pickMesh.updateMatrixWorld(true);
// --- Agent capsule ---
const agentSG = ShadedGeometry.from(capsuleGeometry, agentMaterial);
agentSG.setFlag(ShadedGeometryFlags.CastShadow);
agentSG.setFlag(ShadedGeometryFlags.ReceiveShadow);
const agentTransform = new Transform();
agentTransform.position.copy(L.agentStart);
const follower = new PathFollower();
follower.speed.set(AGENT_SPEED);
// Keep the capsule upright — let it yaw to face travel, but never pitch/roll.
follower.clearFlag(PathFollowerFlags.WriteRotationX);
follower.clearFlag(PathFollowerFlags.WriteRotationZ);
follower.clearFlag(PathFollowerFlags.Active); // nothing to follow until the first click
agentEntity = new Entity()
.add(agentTransform)
.add(agentSG)
.add(new Path())
.add(follower)
.build(ecd);
levelEntities.push(agentEntity);
// --- Path visualisation: a separate entity carrying a lifted copy of the
// route so the tube floats just above the ground instead of being
// buried in it (the agent's own Path sits exactly on the surface). ---
const viz = new PathDisplay();
viz.specs.push(PathDisplaySpec.from(PathDisplayType.Tube, makeTubeStyle()));
pathVizEntity = new Entity()
.add(new Path())
.add(viz)
.build(ecd);
levelEntities.push(pathVizEntity);
refreshLevelHud(seed);
setPathHud(0, 0);
}
// Compute a route from the agent to a clicked world point and start walking it.
function navigateTo(target) {
if (agentEntity < 0 || navmesh === null) return;
const xf = ecd.getComponent(agentEntity, Transform);
const start = xf.position;
const count = navmesh.find_path(
pathOut,
start.x, start.y, start.z,
target.x, target.y, target.z
);
if (count < 2) {
setPathHud(0, 0);
return;
}
// Drive the capsule along the ground-level route.
const path = ecd.getComponent(agentEntity, Path);
path.clear();
path.setPointCount(count);
for (let i = 0; i < count; i++) {
path.setPosition(i, pathOut[i * 3], pathOut[i * 3 + 1], pathOut[i * 3 + 2]);
}
const follower = ecd.getComponent(agentEntity, PathFollower);
follower.position = 0;
follower.clearFlag(PathFollowerFlags.Finished);
follower.setFlag(PathFollowerFlags.Active);
// Feed a lifted copy to the path-display entity and ask it to rebuild.
const viz = ecd.getComponent(pathVizEntity, Path);
viz.clear();
viz.setPointCount(count);
for (let i = 0; i < count; i++) {
viz.setPosition(i, pathOut[i * 3], pathOut[i * 3 + 1] + PATH_LIFT, pathOut[i * 3 + 2]);
}
ecd.sendEvent(pathVizEntity, PathEvents.Changed, null);
setPathHud(count, path.length);
}
// ─── Click-to-navigate plumbing ──────────────────────────────────────────────
const canvas = engine.graphics.domElement;
const raycaster = new Raycaster();
const ndc = new Vector2();
function pickGround(clientX, clientY) {
if (pickMesh === null) return;
const camera = engine.graphics.camera;
if (!camera) return;
const rect = canvas.getBoundingClientRect();
ndc.x = ((clientX - rect.left) / rect.width) * 2 - 1;
ndc.y = -(((clientY - rect.top) / rect.height) * 2 - 1);
raycaster.setFromCamera(ndc, camera);
const hits = raycaster.intersectObject(pickMesh, false);
if (hits.length === 0) return;
navigateTo(hits[0].point);
}
// Distinguish a click from an orbit-drag: only navigate when the pointer barely
// moved between press and release.
let pressed = null;
canvas.addEventListener("pointerdown", (e) => {
if (e.button !== 0) { pressed = null; return; }
pressed = { x: e.clientX, y: e.clientY, t: performance.now() };
});
canvas.addEventListener("pointerup", (e) => {
if (e.button !== 0 || pressed === null) return;
const moved = Math.hypot(e.clientX - pressed.x, e.clientY - pressed.y);
const elapsed = performance.now() - pressed.t;
pressed = null;
if (moved < 6 && elapsed < 500) pickGround(e.clientX, e.clientY);
});
// ─── §9 HUD, buttons, frame loop ────────────────────────────────────────────
const fpsEl = document.getElementById("fps");
const seedEl = document.getElementById("seed");
const triCountEl = document.getElementById("tri-count");
const pathPointsEl = document.getElementById("path-points");
const pathLengthEl = document.getElementById("path-length");
function refreshLevelHud(seed) {
seedEl.textContent = "0x" + (seed >>> 0).toString(16).toUpperCase();
triCountEl.textContent = navTriCount.toString();
}
function setPathHud(points, length) {
pathPointsEl.textContent = points > 0 ? points.toString() : "—";
pathLengthEl.textContent = points > 0 ? length.toFixed(1) : "—";
}
document.getElementById("randomize-btn").addEventListener("click", () => {
buildLevel((Math.random() * 0xFFFFFF) >>> 0);
});
// First load: always the same seed.
buildLevel(INITIAL_SEED);
let fpsWindow = 0;
let fpsFrames = 0;
let lastFrameMs = performance.now();
engine.graphics.on.postRender.add(() => {
const nowMs = performance.now();
const dt = (nowMs - lastFrameMs) / 1000;
lastFrameMs = nowMs;
fpsWindow += dt;
fpsFrames++;
if (fpsWindow >= 0.5) {
fpsEl.textContent = (fpsFrames / fpsWindow).toFixed(0);
fpsWindow = 0;
fpsFrames = 0;
}
});