@jgengine/core 0.15.0 → 0.17.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 +62 -0
- package/dist/game/playableGame.d.ts +36 -0
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +49 -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/multiplayer.d.ts +1 -0
- package/dist/multiplayer.js +1 -0
- package/dist/runtime/context/objectSlots.d.ts +60 -0
- package/dist/runtime/context/objectSlots.js +103 -0
- package/dist/runtime/context/player.d.ts +3 -1
- package/dist/runtime/context/player.js +1 -0
- package/dist/runtime/context/scene.d.ts +9 -0
- package/dist/runtime/context/scene.js +25 -0
- package/dist/runtime/descriptors/baseline.d.ts +3 -1
- package/dist/runtime/descriptors/baseline.js +11 -0
- package/dist/runtime/gameContext.js +10 -1
- package/dist/runtime/gameContextTypes.d.ts +13 -0
- package/dist/runtime/gameRuntime.d.ts +6 -0
- package/dist/runtime/gameRuntime.js +1 -0
- package/dist/runtime/hostPersistence.d.ts +27 -2
- package/dist/runtime/hostPersistence.js +68 -12
- package/dist/runtime/hostPolicy.d.ts +54 -0
- package/dist/runtime/hostPolicy.js +53 -0
- package/dist/runtime/objectRows.d.ts +17 -0
- package/dist/runtime/objectRows.js +35 -0
- package/dist/runtime/snapshot.d.ts +15 -0
- package/dist/runtime/snapshot.js +12 -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/objectStore.d.ts +26 -0
- package/dist/scene/objectStore.js +51 -9
- package/dist/scene/spatial.d.ts +16 -0
- package/dist/scene/spatial.js +7 -4
- package/dist/world/buildingKit.d.ts +110 -0
- package/dist/world/buildingKit.js +93 -0
- package/dist/world/buildings.d.ts +25 -3
- package/dist/world/buildings.js +39 -5
- package/dist/world/cityKind.d.ts +6 -0
- package/dist/world/cityKind.js +3 -0
- package/dist/world/environmentSummary.js +13 -3
- package/dist/world/features.d.ts +7 -1
- package/dist/world/features.js +1 -0
- package/dist/world.d.ts +1 -0
- package/dist/world.js +1 -0
- package/package.json +1 -1
- package/skills/jgengine/api.md +38 -5
- package/skills/jgengine/capabilities.md +24 -0
- package/skills/jgengine-gameplay/api.md +2 -1
- package/skills/jgengine-world/SKILL.md +5 -0
- package/skills/jgengine-world/api.md +27 -2
- package/skills/jgengine-world/capabilities.md +24 -0
- package/skills/jgengine-world/recipes/building-kit.md +78 -0
package/CHANGELOG.md
CHANGED
|
@@ -34,6 +34,68 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
34
34
|
|
|
35
35
|
### Fixed
|
|
36
36
|
|
|
37
|
+
## 0.17.0
|
|
38
|
+
|
|
39
|
+
### Migrate
|
|
40
|
+
|
|
41
|
+
- **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`.
|
|
42
|
+
- **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.
|
|
43
|
+
- **`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`.
|
|
44
|
+
- **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.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **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).
|
|
49
|
+
- **`@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.
|
|
50
|
+
- **`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.
|
|
51
|
+
- **`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.
|
|
52
|
+
- **`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.
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
|
|
56
|
+
- **`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.
|
|
57
|
+
- **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.
|
|
58
|
+
- **`@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.
|
|
59
|
+
- **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.
|
|
60
|
+
- **`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.
|
|
61
|
+
- **`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.
|
|
62
|
+
|
|
63
|
+
### Removed
|
|
64
|
+
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
67
|
+
## 0.16.0
|
|
68
|
+
|
|
69
|
+
### Migrate
|
|
70
|
+
|
|
71
|
+
- **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`.
|
|
72
|
+
- **Placed objects now replicate.** `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.
|
|
73
|
+
|
|
74
|
+
### Changed
|
|
75
|
+
|
|
76
|
+
- **`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`**; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.
|
|
77
|
+
|
|
78
|
+
### Added
|
|
79
|
+
|
|
80
|
+
- **`modelLoadFallbacks()` from `@jgengine/shell/render/modelLoad`** lists every model the shared loader diagnosed as broken and replaced with a magenta placeholder. A placeholder was only ever a console warning, so a shipped game could render one in its starting area and nobody found out until someone looked at a screenshot; a capture host or smoke test can now require the list to be empty. `clearModelLoadFallbacks()` resets it between runs.
|
|
81
|
+
- **`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop.** World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.
|
|
82
|
+
|
|
83
|
+
- **`DustField` + `WeatherLayer` mode `"dust"` (`@jgengine/shell/weather`) — wind-borne particulate.** `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.
|
|
84
|
+
|
|
85
|
+
- **Authored `city` volumes render their near-LOD facades with a `BuildingKit`.** `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive — not just the `building()` feature — on the kit path.
|
|
86
|
+
- **`BuildingKit` from `@jgengine/core/world/buildingKit`** binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: ["Wall_A.glb", …] } })` maps a part kind — or a `${facade}.${kind}` slot — to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.
|
|
87
|
+
- **`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API** (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.
|
|
88
|
+
- **`capture.views` on `defineGame`** — named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.
|
|
89
|
+
- **`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad`** reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.
|
|
90
|
+
- **Per-instance state on placed objects** — `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.
|
|
91
|
+
- **`slotInventory` is a working feature** — `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.
|
|
92
|
+
- **`loadServerSnapshot` and `persistServerSnapshot` are public** (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.
|
|
93
|
+
|
|
94
|
+
### Fixed
|
|
95
|
+
|
|
96
|
+
- **Open storefronts are no longer holes through the building.** `generateBuilding` emitted an open ground-floor store as a lone `storefront` panel with no wall behind it, while every upper-floor window was correctly backed by one. Storefront and window materials render at 0.56 opacity and a generated building is a hollow shell, so those bays let you see straight through the building to the world beyond — and open stores only land on the street-facing facade, so a row of shops along a road was the worst case. The bay now emits its wall first, exactly as an upper floor does.
|
|
97
|
+
- **`joinServer`, `flushSave`, `flushDirtyServers`, and the `auto` autosave can write player profiles again.** All four cleared the snapshot's dirty set before handing it to the persist plan, which gates profile writes on exactly that set — so the functions named "flush" flushed the server row and chunks only, and an `onNewPlayer` grant (starting money, starting items) never reached `jgPlayerProfiles`. `joinServer` now persists the snapshot it just marked; the flush paths mark every player dirty first, since hydrating from storage resets the dirty set. `planServerPersist` also clears `dirtyAt` once everything outstanding is written, so a successful flush stops autosave from re-firing forever, and a profile patch no longer clobbers a host-maintained `dirtyAt`.
|
|
98
|
+
|
|
37
99
|
## 0.15.0
|
|
38
100
|
|
|
39
101
|
### Migrate
|
|
@@ -340,6 +340,33 @@ export interface PresentationEffectsConfig {
|
|
|
340
340
|
shake?: boolean;
|
|
341
341
|
}
|
|
342
342
|
/** How a screenshot host reaches live gameplay in this game — the data behind `shoot --mode play`. */
|
|
343
|
+
/**
|
|
344
|
+
* One named framing in {@link GameCaptureConfig.views}: everything about a capture except the host-side
|
|
345
|
+
* viewport (`--device`, `--size`) and `--mode`, which the runner routes on before the game module loads.
|
|
346
|
+
* Every field mirrors the equivalent capture flag, and an explicit flag on the command line wins over the
|
|
347
|
+
* shot's value so a declared framing stays a starting point rather than a cage.
|
|
348
|
+
*/
|
|
349
|
+
export interface GameCaptureView {
|
|
350
|
+
/** One line naming what this shot is for — printed by `shoot <game> --list-views`. */
|
|
351
|
+
description?: string;
|
|
352
|
+
/** `--look`: `x,z`, `x,y,z`, `@marker:<id>`, or `@entity:<id>`. */
|
|
353
|
+
look?: string;
|
|
354
|
+
/** `--look-from`: `dist[,height[,angle]]`. */
|
|
355
|
+
lookFrom?: string;
|
|
356
|
+
/** `--spawn`: `x,y,z` or `x,y,z,yaw`, for a shot framed from the player's vantage instead of a detached camera. */
|
|
357
|
+
spawn?: string;
|
|
358
|
+
/** `--state`: a {@link GameCaptureConfig.states} entry to reach before capturing. */
|
|
359
|
+
state?: string;
|
|
360
|
+
/** `--run`: commands to dispatch before capturing, when the shot needs staging no declared state covers. */
|
|
361
|
+
run?: readonly string[];
|
|
362
|
+
/** `--settle`: milliseconds to wait before the frame, for a shot behind an animation. */
|
|
363
|
+
settleMs?: number;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* How a game makes itself screenshottable: the commands that reach live play or a named screen, the
|
|
367
|
+
* framings worth re-capturing, and the progress metrics a bot playtest samples. Declaring these once
|
|
368
|
+
* is what lets a capture host reproduce a view instead of re-deriving it by hand every run.
|
|
369
|
+
*/
|
|
343
370
|
export interface GameCaptureConfig {
|
|
344
371
|
/**
|
|
345
372
|
* Commands run (in order, via `ctx.game.commands.run`) right after boot when a capture host requests the
|
|
@@ -363,6 +390,13 @@ export interface GameCaptureConfig {
|
|
|
363
390
|
name: string;
|
|
364
391
|
input?: unknown;
|
|
365
392
|
})[]>;
|
|
393
|
+
/**
|
|
394
|
+
* Named framings worth re-capturing — the views a reviewer actually asks about (`street`, `keep-exterior`,
|
|
395
|
+
* `boss-arena`), each pinning the camera, the staging, and the settle for that shot. `shoot <game> --view <name>`
|
|
396
|
+
* reproduces one exactly, so a before/after pair is the same framing by construction instead of two hand-typed
|
|
397
|
+
* flag sets that drifted. An unknown name fails the capture listing the declared views.
|
|
398
|
+
*/
|
|
399
|
+
views?: Record<string, GameCaptureView>;
|
|
366
400
|
/** Extra milliseconds the capture host waits after `play` commands before taking the play-mode screenshot — cover an intro cinematic or spawn-in fade. Default 2500. */
|
|
367
401
|
settleMs?: number;
|
|
368
402
|
/** Bot-playtest read hook: maps live game state to a flat vector of numeric progress metrics the `drive --playtest` harness samples over time (e.g. `{ x, z, score, phase }`). Genre-agnostic — "progress" is whatever the game reports; the engine stays neutral. The harness watches these numbers move under scripted input: any metric changing beyond an epsilon counts as forward progress, all metrics flat past the softlock threshold under active input flags a softlock. Return every value that should count as advancing; omit to opt the game out of the playtest rung. */
|
|
@@ -422,6 +456,8 @@ export interface PlayableGame<TUi = unknown, TWorldOverlay = unknown, TRenderEnt
|
|
|
422
456
|
statId?: string;
|
|
423
457
|
roles?: readonly CatalogEntityRole[];
|
|
424
458
|
maxDistance?: number;
|
|
459
|
+
/** Hide a bar when world geometry stands between the player and the entity. These bars are a screen-space overlay outside the depth buffer, so by default they draw through walls. Default false. */
|
|
460
|
+
occlude?: boolean;
|
|
425
461
|
};
|
|
426
462
|
/**
|
|
427
463
|
* Opt in to billboarded nameplates (name + optional HP bar) floating over non-local entities — the
|
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.17.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,55 @@
|
|
|
1
1
|
/** Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating. */
|
|
2
|
-
export const VERSION = "0.
|
|
2
|
+
export const VERSION = "0.17.0";
|
|
3
3
|
/** Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`). */
|
|
4
4
|
export const CHANGELOG = {
|
|
5
|
+
"0.17.0": {
|
|
6
|
+
migrate: [
|
|
7
|
+
"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`.",
|
|
8
|
+
"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.",
|
|
9
|
+
"`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`.",
|
|
10
|
+
"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.",
|
|
11
|
+
],
|
|
12
|
+
added: [
|
|
13
|
+
"`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.",
|
|
14
|
+
"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.",
|
|
15
|
+
"`@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.",
|
|
16
|
+
"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.",
|
|
17
|
+
"`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.",
|
|
18
|
+
"`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.",
|
|
19
|
+
],
|
|
20
|
+
changed: [
|
|
21
|
+
"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).",
|
|
22
|
+
"`@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.",
|
|
23
|
+
"`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.",
|
|
24
|
+
"`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.",
|
|
25
|
+
"`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.",
|
|
26
|
+
],
|
|
27
|
+
removed: [
|
|
28
|
+
"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.",
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
"0.16.0": {
|
|
32
|
+
migrate: [
|
|
33
|
+
"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`.",
|
|
34
|
+
"Placed objects now replicate. `objects` joined the always-on baseline snapshot modules, so `ctx.hydrate` replaces the client's placed object set with the host's, the way `entities` already did. A game whose peers all run the same authored placement is unaffected; a game that places props on the client only, after hydrate, must move that placement to the host or keep it out of `ctx.scene.object`.",
|
|
35
|
+
],
|
|
36
|
+
added: [
|
|
37
|
+
"`worldHealthBars.occlude` / `WorldHealthBars`+`WorldNameplates` `occlude` prop. World bars and nameplates are a screen-space overlay drawn outside the WebGL depth buffer, so nothing in the scene can hide them and an enemy's health bar reads straight through the building it stands behind. Set `occlude: true` to drop a bar when world geometry blocks the line from the player to the entity. Default `false` preserves the always-visible behaviour every shipped game currently relies on.",
|
|
38
|
+
"`DustField` + `WeatherLayer` mode `\"dust\"` (`@jgengine/shell/weather`) \u2014 wind-borne particulate. `WeatherLayer` could only do `clear`/`rain`/`snow`/`mixed`, so an arid, volcanic, ashen, or blown-out world had no way to put anything in the air. Dust drifts on horizontal wind with a slow vertical bob rather than falling; `groundBias` packs density toward the surface, motes fade near the top of the volume so recycled ones do not pop against the sky, and faster motes ride higher. Set `dustAlways` to keep it in the air alongside rain or snow instead of replacing them. Defaults `DEFAULT_DUST_COUNT`/`DEFAULT_DUST_DENSITY` sit beside the rain and snow pair.",
|
|
39
|
+
"Authored `city` volumes render their near-LOD facades with a `BuildingKit`. `CityRules.buildingKit` names a kit; a game registers the bound art with `registerBuildingKit(kit)` from `@jgengine/shell/structures/buildingKitRegistry` and the scene document carries only the name, so a city stays portable and free of model paths. Unset or unknown names keep the palette blocks. This puts the whole `city` primitive \u2014 not just the `building()` feature \u2014 on the kit path.",
|
|
40
|
+
"`BuildingKit` from `@jgengine/core/world/buildingKit` binds a generated building's facade part slots to real models, so procedural buildings render as an instanced asset kit instead of untextured blocks. `defineBuildingKit({ parts: { wall: [\"Wall_A.glb\", \u2026] } })` maps a part kind \u2014 or a `${facade}.${kind}` slot \u2014 to ordered variants that `kit.variant` indexes, with `fit`/`offset`/`rotation`/`scale`/`tint`/`autoOrient` per variant. Kinds a kit leaves unbound keep rendering the palette block, so a partial kit is a valid kit, and `omit` drops a kind entirely. Pass one through `building({ kit })` or straight to `InstancedBuildings`; `buildingKitVariantCounts(kit)` feeds `generateBuilding` so its seeded picks span exactly the models the kit binds.",
|
|
41
|
+
"`generateBuilding`, `generateBuildingDistrict`, and `createBuildingConfig` are now documented public API (`@jgengine/core/world/buildings`) and appear in `capabilities.md` under `building-generator`.",
|
|
42
|
+
"`capture.views` on `defineGame` \u2014 named framings (`GameCaptureView`: `look`, `lookFrom`, `spawn`, `state`, `run`, `settleMs`) that `shoot <game> --view <name>` and `drive --view <name>` replay exactly, so a before/after pair is the same view by construction. `shoot <game> --list-views` prints what a game declares; an unknown name fails listing them, and any explicit capture flag still wins over the view's value.",
|
|
43
|
+
"`modelLoadIdleMs()` from `@jgengine/shell/render/modelLoad` reports how long the shared GLB loader has been idle (`0` while a model is in flight). Capture hosts wait on it instead of guessing a settle delay, so an establishing shot no longer races streaming models.",
|
|
44
|
+
"Per-instance state on placed objects \u2014 `SceneObject.state` (opaque `Record`), set through `ctx.scene.object.setState`/`patchState`/`place({ state })`. It survives move/rotate/setVisual, replicates and saves with the object, and round-trips as `RuntimeObjectRow.flags` via `toRuntimeObjectRow`/`fromRuntimeObjectRow` (`@jgengine/core/multiplayer`). A chest's locked flag or a machine's fill level no longer needs a game-owned map keyed by `instanceId`.",
|
|
45
|
+
"`slotInventory` is a working feature \u2014 `ctx.scene.object.slots` gives objects whose catalog entry declares it real per-instance container contents: `layout`/`state`/`count`, `put`/`take`, and `transferIn`/`transferOut` between a player inventory and the object, all validated against the declared slot count, `accepts` kind filter, and stack limits, with neither side mutated on a rejection. Contents live on `SceneObject.slots`, so they replicate and save with the placement.",
|
|
46
|
+
"`loadServerSnapshot` and `persistServerSnapshot` are public (`@jgengine/convex/server`), and `createGameServerFunctions` also returns `helpers` (`loadSnapshot`, `applyCommand`, `persistSnapshot`, `runCommand`) bound to the same runtimes and auth mode. A host mutation can now pair snapshot work with its own table writes in one transaction; the registered `runCommand` mutation delegates to `helpers.runCommand`, so there is one implementation.",
|
|
47
|
+
],
|
|
48
|
+
changed: [
|
|
49
|
+
"`GeneratedBuilding`'s `kit` prop is now the `BuildingKit`; the per-part React escape hatch it used to name moved to `partRenderer`, so `kit` means the same thing on `GeneratedBuilding` and `InstancedBuildings`. Rename the prop if you passed a `BuildingKitRenderer`.",
|
|
50
|
+
],
|
|
51
|
+
removed: [],
|
|
52
|
+
},
|
|
5
53
|
"0.15.0": {
|
|
6
54
|
migrate: [
|
|
7
55
|
"Bump lockstep SDK packages to ^0.15.0: @jgengine/{core,react,ws,node,sql,convex,shell,editor,assets}. CLI jgengine is 0.12.0; @jgengine/github is 0.2.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
|
+
};
|