@jgengine/core 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +45 -1
- package/dist/movement/playerMovement.js +6 -172
- package/dist/movement/solidObstacles.d.ts +87 -0
- package/dist/movement/solidObstacles.js +208 -0
- package/dist/runtime/commandRunner.d.ts +36 -0
- package/dist/runtime/commandRunner.js +36 -0
- package/dist/runtime/context/scene.js +11 -0
- package/dist/runtime/gameContext.js +4 -1
- package/dist/runtime/gameRuntime.d.ts +20 -1
- package/dist/runtime/gameRuntime.js +10 -1
- package/dist/runtime/hostPersistence.d.ts +27 -2
- package/dist/runtime/hostPersistence.js +59 -7
- package/dist/runtime/hostPolicy.d.ts +54 -0
- package/dist/runtime/hostPolicy.js +53 -0
- package/dist/runtime/transport.d.ts +6 -0
- package/dist/runtime/worldChunks.d.ts +39 -0
- package/dist/runtime/worldChunks.js +83 -0
- package/dist/runtime/worldProjection.d.ts +16 -2
- package/dist/runtime/worldProjection.js +20 -0
- package/dist/scene/behaviorRuntime.js +25 -3
- package/dist/scene/spatial.d.ts +16 -0
- package/dist/scene/spatial.js +7 -4
- package/package.json +1 -1
- package/skills/jgengine/api.md +29 -7
- package/skills/jgengine/capabilities.md +12 -0
- package/skills/jgengine-world/api.md +6 -0
- package/skills/jgengine-world/capabilities.md +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -26,14 +26,60 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.18.0
|
|
30
|
+
|
|
29
31
|
### Migrate
|
|
30
32
|
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.18.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.
|
|
34
|
+
- **Register the presence reaper and redeploy your schema.** `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval("jg presence reap", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`"runtime"` / `"presence"`) it is exported from.
|
|
35
|
+
- **`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`,** and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.
|
|
36
|
+
|
|
31
37
|
### Changed
|
|
32
38
|
|
|
39
|
+
- **`presence.sync` accepts a call with no `pose` at all,** which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.
|
|
40
|
+
- **`PresencePoseRow` carries optional `sessionId` / `kind` / `label`,** so a nameplate reads off the presence row instead of joining against users per frame.
|
|
41
|
+
|
|
33
42
|
### Added
|
|
34
43
|
|
|
44
|
+
- **`createPresenceFunctions` gains the policy hooks the pose lane was missing** — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.
|
|
45
|
+
|
|
46
|
+
- **A command declares what it touches, so `runCommand` hydrates that and nothing else.** `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.
|
|
47
|
+
- **`GameRuntime.commandScope` / `GameRuntime.joinScope`** expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).
|
|
48
|
+
|
|
35
49
|
### Fixed
|
|
36
50
|
|
|
51
|
+
- **`runCommand` no longer reads the whole world on every command.** `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.
|
|
52
|
+
|
|
53
|
+
## 0.17.0
|
|
54
|
+
|
|
55
|
+
### Migrate
|
|
56
|
+
|
|
57
|
+
- **Bump lockstep SDK packages to `^0.17.0`:** `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.
|
|
58
|
+
- **A deleted player profile stays deleted.** When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row — that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`"none"`, `"chunks"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.
|
|
59
|
+
- **`slotsPerServer` is capped at 256** (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.
|
|
60
|
+
- **The tick cron only sweeps games that declare `loop.onTick`.** A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.
|
|
61
|
+
|
|
62
|
+
### Changed
|
|
63
|
+
|
|
64
|
+
- **Walking NPCs now stop at the geometry that stops the player.** `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders — the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).
|
|
65
|
+
- **`@jgengine/assets` verify fails a model source that contributes no index entries.** A model pack that has never been pulled is a pin nothing has ever checked — that is how a Street Pack archive sat in the catalogue titled "Downtown City MegaKit". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.
|
|
66
|
+
- **`persistServerSnapshot` returns `boolean` and skips a write that would change nothing.** Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.
|
|
67
|
+
- **`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot**, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.
|
|
68
|
+
- **`tickActiveServers` is bounded** to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
|
|
72
|
+
- **`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`)** — the engine's single answer to "what is solid here", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.
|
|
73
|
+
- **Bounded hydration and a client chunk read** — `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.
|
|
74
|
+
- **`@jgengine/core/runtime/worldChunks`** — `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.
|
|
75
|
+
- **Placed objects cull to `replication.aoiRadius`.** `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.
|
|
76
|
+
- **`createGameServerFunctions({ matchmaking: "singleton" })`** — one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.
|
|
77
|
+
- **`GameRuntime.hasTick`** reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.
|
|
78
|
+
|
|
79
|
+
### Removed
|
|
80
|
+
|
|
81
|
+
- **Asset source `quaternius-downtown-city`.** It was titled "Downtown City MegaKit" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only — a pull found no models. Nothing referenced it.
|
|
82
|
+
|
|
37
83
|
## 0.16.0
|
|
38
84
|
|
|
39
85
|
### Migrate
|
package/dist/meta/changelog.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.18.0";
|
|
3
3
|
/** One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md). */
|
|
4
4
|
export interface ChangelogEntry {
|
|
5
5
|
migrate: readonly string[];
|
package/dist/meta/changelog.js
CHANGED
|
@@ -1,7 +1,51 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.18.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.18.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"Bump lockstep SDK packages to `^0.18.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.15.0`; `@jgengine/github` is `0.5.0`.",
|
|
8
|
+
"Register the presence reaper and redeploy your schema. `jgPoses` gains `sessionId` / `kind` / `label` / `revokedAt` (all optional) and a `by_updated` index, and `createPresenceFunctions` now also returns `reapIdlePresence`. Re-export it (`export const { list, sync, leave, reapIdlePresence } = createPresenceFunctions(...)`) and add `crons.interval(\"jg presence reap\", { seconds: 60 }, internal.presence.reapIdlePresence, {})`. Without it nothing deletes the row of a player who closed the tab — `list` filtered stale rows at read time but the table grew forever. `jgengineCronSpecs()` carries the new entry, and every spec now names the `module` (`\"runtime\"` / `\"presence\"`) it is exported from.",
|
|
9
|
+
"`presence.sync` returns `{ pose, lastSeenAt, displaced }` instead of `null`, and `presence.leave` marks the row revoked instead of deleting it (the reaper collects it once idle). A caller that ignored the return value is unaffected.",
|
|
10
|
+
],
|
|
11
|
+
added: [
|
|
12
|
+
"`createPresenceFunctions` gains the policy hooks the pose lane was missing — `resolveSpawn(ctx, { serverId, userId, kind })` for where a session with no row starts (async and ctx-bearing, so a spawn can read the world, replacing a hardcoded origin), `poseRules` as a function of the actor `kind` so an agent need not obey a human's speed cap, `idleCutoffMs` / `maxReapPerRun` for the reaper, and optional `sessionId` on `sync` / `leave`: a second tab is a second row, syncing revokes the others, and the displaced session learns it on its next call through `displaced: true`.",
|
|
13
|
+
"A command declares what it touches, so `runCommand` hydrates that and nothing else. `CommandDef` gains an optional `scope(input, actorUserId)` returning `{ players?, chunkKeys? }`, evaluated before hydration; the registered `runCommand` mutation also accepts an explicit `scope` argument, which wins over the declared one. A game that never sets either keeps the whole-world default. `ServerLoopHooks.joinScope(userId, isNew)` does the same for `onNewPlayer`.",
|
|
14
|
+
"`GameRuntime.commandScope` / `GameRuntime.joinScope` expose those declarations to any host, alongside the pure helpers `resolveCommandScope` / `scopeWithActor` / `commandScopeEscape` (`@jgengine/core/runtime/commandRunner`).",
|
|
15
|
+
],
|
|
16
|
+
changed: [
|
|
17
|
+
"`presence.sync` accepts a call with no `pose` at all, which holds the stored position and moves only the liveness stamp. `createConvexPresenceSync` sends one itself when the pose gate has suppressed writes for 10s, so a standing player keeps their row alive without a pose write — and without the reaper collecting a live session.",
|
|
18
|
+
"`PresencePoseRow` carries optional `sessionId` / `kind` / `label`, so a nameplate reads off the presence row instead of joining against users per frame.",
|
|
19
|
+
"`runCommand` no longer reads the whole world on every command. `LoadSnapshotScope` landed in 0.17 but only `helpers.loadSnapshot` accepted one, so the supported way to run a command — the registered `runCommand` mutation, a one-line delegation to `helpers.runCommand` — hydrated every member profile and every chunk however narrow the command was, and a game could only get bounded hydration by reimplementing the mutation. `runCommand`, `joinServer`, and `leaveServer` now hydrate a scope; a command whose `apply` dirties a player or chunk its scope never named is refused rather than persisted, since writing an unhydrated chunk overwrites the stored one with a blank. `flushSave` / `flushServerIfDue` / `tickActiveServers` / `flushDirtyServers` still hydrate everything, which is correct for what they write.",
|
|
20
|
+
],
|
|
21
|
+
removed: [],
|
|
22
|
+
},
|
|
23
|
+
"0.17.0": {
|
|
24
|
+
migrate: [
|
|
25
|
+
"Bump lockstep SDK packages to `^0.17.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.14.0`; `@jgengine/github` is `0.4.0`.",
|
|
26
|
+
"A deleted player profile stays deleted. When a game persists players (`save.scope` includes `player`), hydrating a member with no `jgPlayerProfiles` row no longer falls back to the copy of their state kept on the server row \u2014 that copy was resurrected and then written back as authoritative, so a world reset that cleared profiles but not servers silently restored every player's old balance. A game whose save scope excludes players (`\"none\"`, `\"chunks\"`) is unchanged: the server-row copy is still their state. If you seed members onto a server row without going through `joinServer`, write their profile row too.",
|
|
27
|
+
"`slotsPerServer` is capped at 256 (`JG_MAX_MEMBERS_PER_SERVER`). `createGameServerFunctions` now throws at wiring time on a larger value instead of accepting it and failing at the write that overflows the document. One server is one Convex document holding the world row, the roster, and every member's session state; a bigger number was never a bigger world. Shard across servers, or move world state into `jgWorldChunks`.",
|
|
28
|
+
"The tick cron only sweeps games that declare `loop.onTick`. A game with no tick hook used to pay a full hydrate plus a full persist every second per running server, for a snapshot `runtime.tick` returned unchanged; it now costs nothing while idle. If you relied on the tick cron to autosave a hookless game, the 60s `flushDirtyServers` cron and the `flushSave` mutation still do it.",
|
|
29
|
+
],
|
|
30
|
+
added: [
|
|
31
|
+
"`solidObstaclesNear` / `resolveWalkerStep` (`@jgengine/core/movement/solidObstacles`) \u2014 the engine's single answer to \"what is solid here\", now shared rather than re-derived per consumer. `solidObstaclesNear(ctx, position, reachX, reachZ)` returns the blocking physical colliders near a point through a bounded `inBox` broadphase; `resolveWalkerStep(ctx, position, stepX, stepZ)` slides a horizontal step against them with the player's axis-separated sliding. Reach for these instead of deriving obstacles from a game catalog's own solidity flags.",
|
|
32
|
+
"Bounded hydration and a client chunk read \u2014 `loadServerSnapshot(ctx, server, runtime, { players, chunkKeys })` hydrates only the members and chunks a mutation will touch instead of a document read per member plus one per chunk, and the generated `getChunks` query lets a client read chunk state by key (capped at `MAX_CHUNKS_PER_QUERY`). Chunks were the documented escape hatch for world state too large for one row, but reads were unbounded and clients could not see them at all, so moving geometry there made things worse. `getServerMeta` returns lobby state (status, member count, revision) without the world payload.",
|
|
33
|
+
"`@jgengine/core/runtime/worldChunks` \u2014 `chunkKeyAt` / `chunkCoordAt` / `chunkKeyOf` / `parseChunkKey` / `chunkKeysInRadius` at a declared cell size, plus `setChunk` / `updateChunk` / `deleteChunk`, which mark the key dirty so the host actually persists it. `chunkKey` was an opaque game-chosen string and the engine never wrote `dirty.chunks`, so every game reimplemented the coordinate mapping and a game that forgot to push a key silently never saved that chunk.",
|
|
34
|
+
"Placed objects cull to `replication.aoiRadius`. `objects` is the one large spatial replicated set and had no projector, so a world with a few thousand placements sent all of them to every viewer. It now joins the same area-of-interest policy as `entities` and `stats`. Unset `aoiRadius` still replicates everything.",
|
|
35
|
+
"`createGameServerFunctions({ matchmaking: \"singleton\" })` \u2014 one shared world per game: auto-match always targets the existing world and a join past capacity fails loudly, instead of quietly creating a second world the player would be alone in. `joinServer` also reconciles a stored `slotsPerServer`/`save` that has drifted from the host options, which previously made raising the option a no-op on worlds that already existed. `selectJoinTarget` (`@jgengine/core/runtime/hostPolicy`) is the pure decision behind it.",
|
|
36
|
+
"`GameRuntime.hasTick` reports whether a runtime declares `loop.onTick`, so any host's scheduler can skip a server whose tick is a no-op by construction.",
|
|
37
|
+
],
|
|
38
|
+
changed: [
|
|
39
|
+
"Walking NPCs now stop at the geometry that stops the player. `wander`, `patrol`, and `ctx.scene.entity.moveToward` consulted no obstacles at all, so a pedestrian walked through the building the player cannot enter. All three now slide their step against the scene's blocking physical colliders \u2014 the same query `stepPlayerMovement` reads. A patrol's path state still advances on its own clock (an authored route is not rewound by contact), but the entity can no longer end a tick inside a solid. Pass `avoidSolids: false` to `moveToward` for a mover meant to ignore the world (a flier, a camera rig).",
|
|
40
|
+
"`@jgengine/assets` verify fails a model source that contributes no index entries. A model pack that has never been pulled is a pin nothing has ever checked \u2014 that is how a Street Pack archive sat in the catalogue titled \"Downtown City MegaKit\". Record why a pack is not pulled in the new `AssetSource.unpulled` string; materials and sprites pull on demand and are exempt.",
|
|
41
|
+
"`persistServerSnapshot` returns `boolean` and skips a write that would change nothing. Every subscriber of a query that read a server row re-receives the whole result when that document changes, so an unconditional write on the 1 Hz tick re-pushed the entire world to every client once a second whether or not anything moved. `ServerPersistPlan` gains `changed` (`serverState` / `sessionPlayers` / `revision` / `any`) for hosts that write field-by-field.",
|
|
42
|
+
"`sessionPlayers` is merged over the stored map, not rebuilt from the snapshot, and entries for non-members are pruned. This is what makes a partially hydrated snapshot safe to persist.",
|
|
43
|
+
"`tickActiveServers` is bounded to `maxServersPerTick` (default 32) servers per transaction, oldest tick anchor first, instead of pulling every running server of every game into one transaction. `flushDirtyServers` is bounded the same way.",
|
|
44
|
+
],
|
|
45
|
+
removed: [
|
|
46
|
+
"Asset source `quaternius-downtown-city`. It was titled \"Downtown City MegaKit\" but pinned at the Street Pack archive, which ships Blends/FBX/OBJ only \u2014 a pull found no models. Nothing referenced it.",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
5
49
|
"0.16.0": {
|
|
6
50
|
migrate: [
|
|
7
51
|
"Bump lockstep SDK packages to `^0.16.0`: `@jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}`. CLI `jgengine` is `0.13.0`; `@jgengine/github` is `0.3.0`.",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { applyHorizontalImpulses, applyMotionImpulses } from "../runtime/motionIntents.js";
|
|
2
2
|
import { groundFieldFor, hasEnvironmentTerrain, sampleSlope } from "../world/terrain.js";
|
|
3
|
-
import { resolveColliders } from "../scene/colliders.js";
|
|
4
3
|
import { advancePlayerMotion, constrainStepToAxis, createEmptyMovementKeys, createPlayerMotionState, DEFAULT_OBSTACLE_PLAYER_RADIUS, obstacleSupportHeight, resolveMovementIntent, resolveObstacleStep, snapPositionToGrid, } from "./movementModel.js";
|
|
4
|
+
import { solidObstaclesNear } from "./solidObstacles.js";
|
|
5
5
|
import { approachYaw, steerYaw } from "./steering.js";
|
|
6
6
|
import { advanceVoxelPlayer, createVoxelPlayerBody, } from "./voxelController.js";
|
|
7
7
|
const DEFAULT_TURN_SPEED = 2.4;
|
|
@@ -50,10 +50,6 @@ function storeFor(ctx) {
|
|
|
50
50
|
store = {
|
|
51
51
|
players: new Map(),
|
|
52
52
|
solids: { count: -1, set: new Set() },
|
|
53
|
-
obstacleReach: {
|
|
54
|
-
count: -1,
|
|
55
|
-
value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR },
|
|
56
|
-
},
|
|
57
53
|
};
|
|
58
54
|
stores.set(ctx, store);
|
|
59
55
|
}
|
|
@@ -209,7 +205,7 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
209
205
|
const stepHeight = tuning.movement?.stepHeight ?? DEFAULT_PLAYER_STEP_HEIGHT;
|
|
210
206
|
let obstacles = null;
|
|
211
207
|
if (tuning.movement?.collideObjects !== false) {
|
|
212
|
-
obstacles = gatherMovementObstacles(ctx,
|
|
208
|
+
obstacles = gatherMovementObstacles(ctx, player.position, stepX, stepZ);
|
|
213
209
|
// While grounded, a box the player could simply step onto is a ledge, not a wall.
|
|
214
210
|
const resolved = resolveObstacleStep(player.position, stepX, stepZ, obstacles, DEFAULT_OBSTACLE_PLAYER_RADIUS, airborne ? 0 : stepHeight);
|
|
215
211
|
stepX = resolved.stepX;
|
|
@@ -301,172 +297,10 @@ export function stepPlayerMovement(ctx, userId, input, dt, tuning, heading) {
|
|
|
301
297
|
dt,
|
|
302
298
|
});
|
|
303
299
|
}
|
|
304
|
-
/** Feet-to-head span the obstruction test uses; matches `movementModel`'s `OBSTACLE_PLAYER_HEIGHT`. */
|
|
305
|
-
const OBSTACLE_PLAYER_HEIGHT = 1.8;
|
|
306
300
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* is missed. Floored so default unit boxes stay covered.
|
|
301
|
+
* The player's blocking obstacles for this step, from the shared solid seam. A walking NPC calls
|
|
302
|
+
* `resolveWalkerStep` against the same query, so "solid" means one thing engine-wide.
|
|
310
303
|
*/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
* Hard cap on XZ broadphase reach. City lots ~12–17 m footprint need ~17–20 m after yaw expansion;
|
|
314
|
-
* beyond that we refuse to grow the query into a city-wide box (and rely on the outer AABB of each
|
|
315
|
-
* solid still blocking once its *center* is in range).
|
|
316
|
-
*/
|
|
317
|
-
const OBSTACLE_HORIZONTAL_CAP = 22;
|
|
318
|
-
/**
|
|
319
|
-
* Vertical broadphase floor/cap. Player only needs nearby ground props / step-ups — never full tower
|
|
320
|
-
* height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
|
|
321
|
-
* a 100³ empty cell walk every frame.
|
|
322
|
-
*/
|
|
323
|
-
const OBSTACLE_VERTICAL_FLOOR = 3;
|
|
324
|
-
const OBSTACLE_VERTICAL_CAP = 8;
|
|
325
|
-
/**
|
|
326
|
-
* Mesh compound `boxes` under this count are treated as intentional openings (archways). Above it we
|
|
327
|
-
* walk against the outer fitted AABB only — solid city buildings often ship dense box soups that
|
|
328
|
-
* multiply narrowphase cost without changing the blocked footprint.
|
|
329
|
-
*/
|
|
330
|
-
const MOVEMENT_MESH_BOX_BUDGET = 12;
|
|
331
|
-
/**
|
|
332
|
-
* Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
|
|
333
|
-
* ctx and recomputed when the object count changes.
|
|
334
|
-
*/
|
|
335
|
-
function obstacleReachFor(ctx, store) {
|
|
336
|
-
const objects = ctx.scene.object.list();
|
|
337
|
-
const cache = store.obstacleReach;
|
|
338
|
-
if (cache.count === objects.length)
|
|
339
|
-
return cache.value;
|
|
340
|
-
let horizontal = OBSTACLE_MAX_HALF_EXTENT;
|
|
341
|
-
let vertical = OBSTACLE_VERTICAL_FLOOR;
|
|
342
|
-
for (const object of objects) {
|
|
343
|
-
const set = ctx.scene.object.collidersOf(object.instanceId);
|
|
344
|
-
if (set === null)
|
|
345
|
-
continue;
|
|
346
|
-
for (const collider of resolveColliders(set)) {
|
|
347
|
-
if (!collider.blocks || collider.purpose !== "physical")
|
|
348
|
-
continue;
|
|
349
|
-
const shape = collider.shape;
|
|
350
|
-
const offset = shape.offset;
|
|
351
|
-
const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
|
|
352
|
-
const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
|
|
353
|
-
const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
|
|
354
|
-
if (shape.kind === "sphere") {
|
|
355
|
-
horizontal = Math.max(horizontal, ox + oz + shape.radius);
|
|
356
|
-
vertical = Math.max(vertical, oy + shape.radius);
|
|
357
|
-
}
|
|
358
|
-
else {
|
|
359
|
-
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
360
|
-
const h = shape.halfExtents;
|
|
361
|
-
horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
|
|
362
|
-
vertical = Math.max(vertical, oy + h[1]);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
const value = {
|
|
367
|
-
horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
|
|
368
|
-
vertical: Math.min(vertical, OBSTACLE_VERTICAL_CAP),
|
|
369
|
-
};
|
|
370
|
-
cache.count = objects.length;
|
|
371
|
-
cache.value = value;
|
|
372
|
-
return value;
|
|
373
|
-
}
|
|
374
|
-
/**
|
|
375
|
-
* Gather the player's nearby blocking obstacles from resolved colliders — the mesh-accurate replacement
|
|
376
|
-
* for the old `nearbyObstacles(list(), …)` which emitted a default 1×1×1 box per object. Bounded by an
|
|
377
|
-
* `inBox` broadphase (reach ≥ player radius + the scene's max blocking extent so wide walls can't be
|
|
378
|
-
* missed), then, per candidate, keeps only blocking physical colliders and emits their real
|
|
379
|
-
* half-extents/offset (or, for a mesh collider carrying a small `boxes` set, the compound sub-boxes),
|
|
380
|
-
* conservatively yaw-rotated by the object's `rotationY`. Objects with no resolved collider fall back
|
|
381
|
-
* to the default box; objects whose colliders don't block stop obstructing. Runs for both the shell's
|
|
382
|
-
* local player and a host, so parity is automatic.
|
|
383
|
-
*/
|
|
384
|
-
function gatherMovementObstacles(ctx, store, position, stepX, stepZ) {
|
|
385
|
-
const reach = obstacleReachFor(ctx, store);
|
|
386
|
-
const reachX = Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS + reach.horizontal;
|
|
387
|
-
const reachZ = Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS + reach.horizontal;
|
|
388
|
-
const min = [
|
|
389
|
-
position[0] - reachX,
|
|
390
|
-
position[1] - reach.vertical,
|
|
391
|
-
position[2] - reachZ,
|
|
392
|
-
];
|
|
393
|
-
const max = [
|
|
394
|
-
position[0] + reachX,
|
|
395
|
-
position[1] + OBSTACLE_PLAYER_HEIGHT + reach.vertical,
|
|
396
|
-
position[2] + reachZ,
|
|
397
|
-
];
|
|
398
|
-
const obstacles = [];
|
|
399
|
-
for (const object of ctx.scene.object.inBox(min, max)) {
|
|
400
|
-
const set = ctx.scene.object.collidersOf(object.instanceId);
|
|
401
|
-
if (set === null) {
|
|
402
|
-
// No resolved collider: preserve today's default 1×1×1 box.
|
|
403
|
-
obstacles.push({ position: object.position });
|
|
404
|
-
continue;
|
|
405
|
-
}
|
|
406
|
-
for (const collider of resolveColliders(set)) {
|
|
407
|
-
if (!collider.blocks || collider.purpose !== "physical")
|
|
408
|
-
continue;
|
|
409
|
-
obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
return obstacles;
|
|
413
|
-
}
|
|
414
|
-
/** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
|
|
415
|
-
* carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
|
|
416
|
-
* conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
417
|
-
* never under-covers. */
|
|
418
|
-
function obstacleFromCollider(collider, position, rotationY) {
|
|
419
|
-
const cos = Math.cos(rotationY);
|
|
420
|
-
const sin = Math.sin(rotationY);
|
|
421
|
-
const shape = collider.shape;
|
|
422
|
-
// Dense compound soups (city buildings) collapse to the outer fitted AABB for movement — openings
|
|
423
|
-
// only stay mesh-box accurate when the box count is small (arches, doorways).
|
|
424
|
-
if (shape.kind === "mesh" &&
|
|
425
|
-
shape.boxes !== undefined &&
|
|
426
|
-
shape.boxes.length > 0 &&
|
|
427
|
-
shape.boxes.length <= MOVEMENT_MESH_BOX_BUDGET) {
|
|
428
|
-
return {
|
|
429
|
-
position,
|
|
430
|
-
boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
let hx;
|
|
434
|
-
let hy;
|
|
435
|
-
let hz;
|
|
436
|
-
if (shape.kind === "sphere") {
|
|
437
|
-
hx = hy = hz = shape.radius;
|
|
438
|
-
}
|
|
439
|
-
else {
|
|
440
|
-
hx = shape.halfExtents[0];
|
|
441
|
-
hy = shape.halfExtents[1];
|
|
442
|
-
hz = shape.halfExtents[2];
|
|
443
|
-
}
|
|
444
|
-
const offset = shape.offset;
|
|
445
|
-
const ox = offset !== undefined ? offset[0] : 0;
|
|
446
|
-
const oy = offset !== undefined ? offset[1] : 0;
|
|
447
|
-
const oz = offset !== undefined ? offset[2] : 0;
|
|
448
|
-
return {
|
|
449
|
-
position,
|
|
450
|
-
// Rotate the offset by yaw (mirror of colliders.worldOffset), expand the extents to the rotated AABB.
|
|
451
|
-
offset: [ox * cos + oz * sin, oy, -ox * sin + oz * cos],
|
|
452
|
-
halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
/** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
|
|
456
|
-
* as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
|
|
457
|
-
function yawExpandLocalBox(boxMin, boxMax, cos, sin) {
|
|
458
|
-
const centerX = (boxMin[0] + boxMax[0]) / 2;
|
|
459
|
-
const centerY = (boxMin[1] + boxMax[1]) / 2;
|
|
460
|
-
const centerZ = (boxMin[2] + boxMax[2]) / 2;
|
|
461
|
-
const halfX = (boxMax[0] - boxMin[0]) / 2;
|
|
462
|
-
const halfY = (boxMax[1] - boxMin[1]) / 2;
|
|
463
|
-
const halfZ = (boxMax[2] - boxMin[2]) / 2;
|
|
464
|
-
const rcX = centerX * cos + centerZ * sin;
|
|
465
|
-
const rcZ = -centerX * sin + centerZ * cos;
|
|
466
|
-
const rhX = Math.abs(halfX * cos) + Math.abs(halfZ * sin);
|
|
467
|
-
const rhZ = Math.abs(halfX * sin) + Math.abs(halfZ * cos);
|
|
468
|
-
return {
|
|
469
|
-
min: [rcX - rhX, centerY - halfY, rcZ - rhZ],
|
|
470
|
-
max: [rcX + rhX, centerY + halfY, rcZ + rhZ],
|
|
471
|
-
};
|
|
304
|
+
function gatherMovementObstacles(ctx, position, stepX, stepZ) {
|
|
305
|
+
return solidObstaclesNear(ctx, position, Math.abs(stepX) + DEFAULT_OBSTACLE_PLAYER_RADIUS, Math.abs(stepZ) + DEFAULT_OBSTACLE_PLAYER_RADIUS);
|
|
472
306
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { GameContext } from "../runtime/gameContext.js";
|
|
2
|
+
import { resolveColliders, type ResolvedCollider } from "../scene/colliders.js";
|
|
3
|
+
import type { EntityPosition } from "../scene/entityStore.js";
|
|
4
|
+
import { type CollisionObstacle } from "./movementModel.js";
|
|
5
|
+
/** Largest blocking-collider extent in the scene, split so tower height cannot inflate the XZ query. */
|
|
6
|
+
export interface ObstacleReach {
|
|
7
|
+
/** Max XZ center-to-face distance of any blocking collider (capped). */
|
|
8
|
+
horizontal: number;
|
|
9
|
+
/** Max vertical center-to-face distance of any blocking collider (capped, much tighter than H). */
|
|
10
|
+
vertical: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The slice of the scene's object store this query needs. Narrow on purpose: the scene context wires
|
|
14
|
+
* its own store here while it is still being constructed, and `ctx.scene.object` satisfies it as-is.
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export interface SolidObstacleSource {
|
|
18
|
+
list(): readonly {
|
|
19
|
+
instanceId: string;
|
|
20
|
+
position: EntityPosition;
|
|
21
|
+
rotationY: number;
|
|
22
|
+
}[];
|
|
23
|
+
inBox(min: EntityPosition, max: EntityPosition): readonly {
|
|
24
|
+
instanceId: string;
|
|
25
|
+
position: EntityPosition;
|
|
26
|
+
rotationY: number;
|
|
27
|
+
}[];
|
|
28
|
+
collidersOf(instanceId: string): Parameters<typeof resolveColliders>[0] | null;
|
|
29
|
+
}
|
|
30
|
+
/** Mutable reach cache; one per source, invalidated by object count. @internal */
|
|
31
|
+
export interface ObstacleReachCache {
|
|
32
|
+
count: number;
|
|
33
|
+
value: ObstacleReach;
|
|
34
|
+
}
|
|
35
|
+
/** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
|
|
36
|
+
export declare function createObstacleReachCache(): ObstacleReachCache;
|
|
37
|
+
/**
|
|
38
|
+
* Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
|
|
39
|
+
* ctx and recomputed when the object count changes — the broadphase box every consumer inflates by, so
|
|
40
|
+
* a wall wider than the query box can never be missed.
|
|
41
|
+
* @internal
|
|
42
|
+
*/
|
|
43
|
+
export declare function solidObstacleReach(ctx: GameContext): ObstacleReach;
|
|
44
|
+
/** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
|
|
45
|
+
export declare function sourceObstacleReach(source: SolidObstacleSource, cache: ObstacleReachCache): ObstacleReach;
|
|
46
|
+
/** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
|
|
47
|
+
* carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
|
|
48
|
+
* conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
49
|
+
* never under-covers.
|
|
50
|
+
* @internal */
|
|
51
|
+
export declare function obstacleFromCollider(collider: ResolvedCollider, position: EntityPosition, rotationY: number): CollisionObstacle;
|
|
52
|
+
/**
|
|
53
|
+
* Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
|
|
54
|
+
* scene's resolved colliders.
|
|
55
|
+
*
|
|
56
|
+
* This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
|
|
57
|
+
* anything else that has to stop at a wall read it rather than deriving their own set — three
|
|
58
|
+
* divergent notions of solid (engine colliders / game catalog flags / nothing at all) is what let a
|
|
59
|
+
* pedestrian stroll through the building the player cannot enter.
|
|
60
|
+
*
|
|
61
|
+
* @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
|
|
62
|
+
*/
|
|
63
|
+
export declare function solidObstaclesNear(ctx: GameContext, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
|
|
64
|
+
/** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
|
|
65
|
+
export declare function sourceObstaclesNear(source: SolidObstacleSource, reach: ObstacleReach, position: EntityPosition, reachX: number, reachZ: number, height?: number): CollisionObstacle[];
|
|
66
|
+
/**
|
|
67
|
+
* Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
|
|
68
|
+
* X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
|
|
69
|
+
* other, exactly as {@link stepPlayerMovement} resolves the player's.
|
|
70
|
+
*
|
|
71
|
+
* Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
|
|
72
|
+
* full-world scan, so it stays affordable per NPC per tick.
|
|
73
|
+
*
|
|
74
|
+
* @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
|
|
75
|
+
*/
|
|
76
|
+
export declare function resolveWalkerStep(ctx: GameContext, position: EntityPosition, stepX: number, stepZ: number, options?: {
|
|
77
|
+
radius?: number;
|
|
78
|
+
stepUpHeight?: number;
|
|
79
|
+
}): {
|
|
80
|
+
stepX: number;
|
|
81
|
+
stepZ: number;
|
|
82
|
+
};
|
|
83
|
+
/** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
|
|
84
|
+
export declare function slideStep(position: EntityPosition, stepX: number, stepZ: number, obstacles: readonly CollisionObstacle[], radius?: number, stepUpHeight?: number): {
|
|
85
|
+
stepX: number;
|
|
86
|
+
stepZ: number;
|
|
87
|
+
};
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { perContext } from "../runtime/perContext.js";
|
|
2
|
+
import { resolveColliders } from "../scene/colliders.js";
|
|
3
|
+
import { DEFAULT_OBSTACLE_PLAYER_RADIUS, resolveObstacleStep, } from "./movementModel.js";
|
|
4
|
+
/**
|
|
5
|
+
* Broadphase reach floor (XZ): the `inBox` query indexes objects by their center *point*, so the query
|
|
6
|
+
* must extend past the walker by at least the largest blocking-collider extent or a wide object's edge
|
|
7
|
+
* is missed. Floored so default unit boxes stay covered.
|
|
8
|
+
*/
|
|
9
|
+
const OBSTACLE_MAX_HALF_EXTENT = 4;
|
|
10
|
+
/**
|
|
11
|
+
* Hard cap on XZ broadphase reach. City lots ~12-17 m footprint need ~17-20 m after yaw expansion;
|
|
12
|
+
* beyond that we refuse to grow the query into a city-wide box (and rely on the outer AABB of each
|
|
13
|
+
* solid still blocking once its *center* is in range).
|
|
14
|
+
*/
|
|
15
|
+
const OBSTACLE_HORIZONTAL_CAP = 22;
|
|
16
|
+
/**
|
|
17
|
+
* Vertical broadphase floor/cap. A walker only needs nearby ground props / step-ups — never full tower
|
|
18
|
+
* height. Using building height for Y was the Vice Isle pose killer: a 50 m tower turned `inBox` into
|
|
19
|
+
* a 100^3 empty cell walk every frame.
|
|
20
|
+
*/
|
|
21
|
+
const OBSTACLE_VERTICAL_FLOOR = 3;
|
|
22
|
+
const OBSTACLE_VERTICAL_CAP = 8;
|
|
23
|
+
/**
|
|
24
|
+
* Mesh compound `boxes` under this count are treated as intentional openings (archways). Above it we
|
|
25
|
+
* walk against the outer fitted AABB only — solid city buildings often ship dense box soups that
|
|
26
|
+
* multiply narrowphase cost without changing the blocked footprint.
|
|
27
|
+
*/
|
|
28
|
+
const MOVEMENT_MESH_BOX_BUDGET = 12;
|
|
29
|
+
/** Feet-to-head span a walker is tested over — matches the player's, so both agree on what a wall is. */
|
|
30
|
+
const WALKER_HEIGHT = 1.8;
|
|
31
|
+
/** A fresh {@link ObstacleReachCache} for a caller that owns its own source. @internal */
|
|
32
|
+
export function createObstacleReachCache() {
|
|
33
|
+
return { count: -1, value: { horizontal: OBSTACLE_MAX_HALF_EXTENT, vertical: OBSTACLE_VERTICAL_FLOOR } };
|
|
34
|
+
}
|
|
35
|
+
const reachCache = perContext(() => createObstacleReachCache());
|
|
36
|
+
/**
|
|
37
|
+
* Largest blocking reach in the scene, split into horizontal vs vertical and hard-capped. Cached per
|
|
38
|
+
* ctx and recomputed when the object count changes — the broadphase box every consumer inflates by, so
|
|
39
|
+
* a wall wider than the query box can never be missed.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function solidObstacleReach(ctx) {
|
|
43
|
+
return sourceObstacleReach(ctx.scene.object, reachCache(ctx));
|
|
44
|
+
}
|
|
45
|
+
/** {@link solidObstacleReach} against a bare source and caller-owned cache. @internal */
|
|
46
|
+
export function sourceObstacleReach(source, cache) {
|
|
47
|
+
const objects = source.list();
|
|
48
|
+
if (cache.count === objects.length)
|
|
49
|
+
return cache.value;
|
|
50
|
+
let horizontal = OBSTACLE_MAX_HALF_EXTENT;
|
|
51
|
+
let vertical = OBSTACLE_VERTICAL_FLOOR;
|
|
52
|
+
for (const object of objects) {
|
|
53
|
+
const set = source.collidersOf(object.instanceId);
|
|
54
|
+
if (set === null)
|
|
55
|
+
continue;
|
|
56
|
+
for (const collider of resolveColliders(set)) {
|
|
57
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
58
|
+
continue;
|
|
59
|
+
const shape = collider.shape;
|
|
60
|
+
const offset = shape.offset;
|
|
61
|
+
const ox = offset !== undefined ? Math.abs(offset[0]) : 0;
|
|
62
|
+
const oy = offset !== undefined ? Math.abs(offset[1]) : 0;
|
|
63
|
+
const oz = offset !== undefined ? Math.abs(offset[2]) : 0;
|
|
64
|
+
if (shape.kind === "sphere") {
|
|
65
|
+
horizontal = Math.max(horizontal, ox + oz + shape.radius);
|
|
66
|
+
vertical = Math.max(vertical, oy + shape.radius);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// Horizontal bound sums both axes so any yaw of the box stays covered.
|
|
70
|
+
const h = shape.halfExtents;
|
|
71
|
+
horizontal = Math.max(horizontal, ox + oz + h[0] + h[2]);
|
|
72
|
+
vertical = Math.max(vertical, oy + h[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const value = {
|
|
77
|
+
horizontal: Math.min(horizontal, OBSTACLE_HORIZONTAL_CAP),
|
|
78
|
+
vertical: Math.min(vertical, OBSTACLE_VERTICAL_CAP),
|
|
79
|
+
};
|
|
80
|
+
cache.count = objects.length;
|
|
81
|
+
cache.value = value;
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
/** Yaw-rotate an entity-local AABB about the object origin and re-fit it to an axis-aligned box, expressed
|
|
85
|
+
* as `min`/`max` relative to `position`. Conservative: the rotated box is grown to its enclosing AABB. */
|
|
86
|
+
function yawExpandLocalBox(boxMin, boxMax, cos, sin) {
|
|
87
|
+
const centerX = (boxMin[0] + boxMax[0]) / 2;
|
|
88
|
+
const centerY = (boxMin[1] + boxMax[1]) / 2;
|
|
89
|
+
const centerZ = (boxMin[2] + boxMax[2]) / 2;
|
|
90
|
+
const halfX = (boxMax[0] - boxMin[0]) / 2;
|
|
91
|
+
const halfY = (boxMax[1] - boxMin[1]) / 2;
|
|
92
|
+
const halfZ = (boxMax[2] - boxMin[2]) / 2;
|
|
93
|
+
const rcX = centerX * cos + centerZ * sin;
|
|
94
|
+
const rcZ = -centerX * sin + centerZ * cos;
|
|
95
|
+
const rhX = Math.abs(halfX * cos) + Math.abs(halfZ * sin);
|
|
96
|
+
const rhZ = Math.abs(halfX * sin) + Math.abs(halfZ * cos);
|
|
97
|
+
return {
|
|
98
|
+
min: [rcX - rhX, centerY - halfY, rcZ - rhZ],
|
|
99
|
+
max: [rcX + rhX, centerY + halfY, rcZ + rhZ],
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** One blocking collider as a {@link CollisionObstacle}: a compound `boxes` obstacle for a mesh collider
|
|
103
|
+
* carrying a small sub-box set (openings), else a single half-extents/offset AABB. Yaw is applied by
|
|
104
|
+
* conservatively expanding each AABB to its rotated extent (mirroring `worldOffset`), so the obstruction
|
|
105
|
+
* never under-covers.
|
|
106
|
+
* @internal */
|
|
107
|
+
export function obstacleFromCollider(collider, position, rotationY) {
|
|
108
|
+
const cos = Math.cos(rotationY);
|
|
109
|
+
const sin = Math.sin(rotationY);
|
|
110
|
+
const shape = collider.shape;
|
|
111
|
+
// Dense compound soups (city buildings) collapse to the outer fitted AABB for movement — openings
|
|
112
|
+
// only stay mesh-box accurate when the box count is small (arches, doorways).
|
|
113
|
+
if (shape.kind === "mesh" &&
|
|
114
|
+
shape.boxes !== undefined &&
|
|
115
|
+
shape.boxes.length > 0 &&
|
|
116
|
+
shape.boxes.length <= MOVEMENT_MESH_BOX_BUDGET) {
|
|
117
|
+
return {
|
|
118
|
+
position,
|
|
119
|
+
boxes: shape.boxes.map((b) => yawExpandLocalBox(b.min, b.max, cos, sin)),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
let hx;
|
|
123
|
+
let hy;
|
|
124
|
+
let hz;
|
|
125
|
+
if (shape.kind === "sphere") {
|
|
126
|
+
hx = hy = hz = shape.radius;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
hx = shape.halfExtents[0];
|
|
130
|
+
hy = shape.halfExtents[1];
|
|
131
|
+
hz = shape.halfExtents[2];
|
|
132
|
+
}
|
|
133
|
+
const offset = shape.offset;
|
|
134
|
+
const ox = offset !== undefined ? offset[0] : 0;
|
|
135
|
+
const oy = offset !== undefined ? offset[1] : 0;
|
|
136
|
+
const oz = offset !== undefined ? offset[2] : 0;
|
|
137
|
+
return {
|
|
138
|
+
position,
|
|
139
|
+
// Rotate the offset by yaw (mirror of colliders.worldOffset), expand the extents to the rotated AABB.
|
|
140
|
+
offset: [ox * cos + oz * sin, oy, -ox * sin + oz * cos],
|
|
141
|
+
halfExtents: [Math.abs(hx * cos) + Math.abs(hz * sin), hy, Math.abs(hx * sin) + Math.abs(hz * cos)],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Every blocking physical obstacle overlapping the box `position` ± `reachX`/`reachZ`, read from the
|
|
146
|
+
* scene's resolved colliders.
|
|
147
|
+
*
|
|
148
|
+
* This is the engine's single answer to "what is solid here". The player resolver, a walking NPC, and
|
|
149
|
+
* anything else that has to stop at a wall read it rather than deriving their own set — three
|
|
150
|
+
* divergent notions of solid (engine colliders / game catalog flags / nothing at all) is what let a
|
|
151
|
+
* pedestrian stroll through the building the player cannot enter.
|
|
152
|
+
*
|
|
153
|
+
* @capability solid-obstacles the one blocking-geometry query the player resolver and NPC movers share
|
|
154
|
+
*/
|
|
155
|
+
export function solidObstaclesNear(ctx, position, reachX, reachZ, height = WALKER_HEIGHT) {
|
|
156
|
+
return sourceObstaclesNear(ctx.scene.object, solidObstacleReach(ctx), position, reachX, reachZ, height);
|
|
157
|
+
}
|
|
158
|
+
/** {@link solidObstaclesNear} against a bare source and an already-resolved reach. @internal */
|
|
159
|
+
export function sourceObstaclesNear(source, reach, position, reachX, reachZ, height = WALKER_HEIGHT) {
|
|
160
|
+
const min = [
|
|
161
|
+
position[0] - reachX - reach.horizontal,
|
|
162
|
+
position[1] - reach.vertical,
|
|
163
|
+
position[2] - reachZ - reach.horizontal,
|
|
164
|
+
];
|
|
165
|
+
const max = [
|
|
166
|
+
position[0] + reachX + reach.horizontal,
|
|
167
|
+
position[1] + height + reach.vertical,
|
|
168
|
+
position[2] + reachZ + reach.horizontal,
|
|
169
|
+
];
|
|
170
|
+
const obstacles = [];
|
|
171
|
+
for (const object of source.inBox(min, max)) {
|
|
172
|
+
const set = source.collidersOf(object.instanceId);
|
|
173
|
+
if (set === null) {
|
|
174
|
+
// No resolved collider: preserve the default 1×1×1 box.
|
|
175
|
+
obstacles.push({ position: object.position });
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const collider of resolveColliders(set)) {
|
|
179
|
+
if (!collider.blocks || collider.purpose !== "physical")
|
|
180
|
+
continue;
|
|
181
|
+
obstacles.push(obstacleFromCollider(collider, object.position, object.rotationY));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return obstacles;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Slide a walker's horizontal step against the same solid geometry that stops the player, returning the
|
|
188
|
+
* X/Z it may actually take this tick. A step into a wall is cut on the blocked axis and preserved on the
|
|
189
|
+
* other, exactly as {@link stepPlayerMovement} resolves the player's.
|
|
190
|
+
*
|
|
191
|
+
* Bounded by the same broadphase as the player's gather: one `inBox` query sized to this step, never a
|
|
192
|
+
* full-world scan, so it stays affordable per NPC per tick.
|
|
193
|
+
*
|
|
194
|
+
* @capability walker-solid-step slide an NPC's step against the geometry that blocks the player
|
|
195
|
+
*/
|
|
196
|
+
export function resolveWalkerStep(ctx, position, stepX, stepZ, options = {}) {
|
|
197
|
+
if (stepX === 0 && stepZ === 0)
|
|
198
|
+
return { stepX, stepZ };
|
|
199
|
+
const radius = options.radius ?? DEFAULT_OBSTACLE_PLAYER_RADIUS;
|
|
200
|
+
const obstacles = solidObstaclesNear(ctx, position, Math.abs(stepX) + radius, Math.abs(stepZ) + radius);
|
|
201
|
+
return slideStep(position, stepX, stepZ, obstacles, radius, options.stepUpHeight ?? 0);
|
|
202
|
+
}
|
|
203
|
+
/** {@link resolveWalkerStep} against an already-gathered obstacle set. @internal */
|
|
204
|
+
export function slideStep(position, stepX, stepZ, obstacles, radius = DEFAULT_OBSTACLE_PLAYER_RADIUS, stepUpHeight = 0) {
|
|
205
|
+
if (obstacles.length === 0)
|
|
206
|
+
return { stepX, stepZ };
|
|
207
|
+
return resolveObstacleStep(position, stepX, stepZ, obstacles, radius, stepUpHeight);
|
|
208
|
+
}
|