@jgengine/core 0.12.0 → 0.13.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 +26 -0
- package/dist/combat/effects.d.ts +11 -4
- package/dist/combat/effects.js +24 -17
- package/dist/combat/magazine.d.ts +13 -0
- package/dist/combat/magazine.js +27 -0
- package/dist/combat/vfxPresets.d.ts +176 -0
- package/dist/combat/vfxPresets.js +74 -0
- package/dist/combat/weaponFire.d.ts +114 -0
- package/dist/combat/weaponFire.js +96 -0
- package/dist/combat.d.ts +4 -2
- package/dist/combat.js +3 -1
- package/dist/devtools/urlFlags.d.ts +22 -0
- package/dist/devtools/urlFlags.js +62 -0
- package/dist/game/clipRoles.d.ts +51 -0
- package/dist/game/clipRoles.js +142 -0
- package/dist/game/defineGame.d.ts +2 -2
- package/dist/game/partAnimation.d.ts +78 -0
- package/dist/game/partAnimation.js +147 -0
- package/dist/game/playableGame.d.ts +38 -5
- package/dist/game/progression.d.ts +2 -2
- package/dist/game/progression.js +4 -2
- package/dist/inventory/inventoryModel.d.ts +24 -8
- package/dist/inventory/inventoryModel.js +47 -8
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +25 -1
- package/dist/runtime/context/combat.js +2 -1
- package/dist/runtime/context/combatFx.js +15 -5
- package/dist/runtime/context/scene.d.ts +2 -1
- package/dist/runtime/context/scene.js +39 -1
- package/dist/runtime/gameContext.js +50 -4
- package/dist/runtime/gameContextTypes.d.ts +37 -4
- package/dist/runtime/headlessRunner.d.ts +13 -0
- package/dist/runtime/headlessRunner.js +14 -0
- package/dist/scene/assetCatalog.d.ts +2 -0
- package/dist/scene/colliders.d.ts +21 -0
- package/dist/scene/colliders.js +62 -0
- package/dist/scene/entityStats.js +9 -1
- package/dist/stats/statModifiers.d.ts +16 -0
- package/dist/stats/statModifiers.js +30 -1
- package/dist/stats/statPool.js +5 -2
- package/dist/ui/panelModel.d.ts +120 -0
- package/dist/ui/panelModel.js +168 -0
- package/dist/ui.d.ts +1 -0
- package/dist/ui.js +1 -0
- package/dist/world/buildingLots.d.ts +84 -0
- package/dist/world/buildingLots.js +119 -0
- package/dist/world/buildings.d.ts +6 -0
- package/dist/world/cityGenerator.d.ts +41 -0
- package/dist/world/cityGenerator.js +58 -0
- package/dist/world/cityKind.d.ts +2 -2
- package/dist/world/cityKind.js +4 -4
- package/dist/world/environmentSummary.js +57 -1
- package/dist/world/features.d.ts +35 -2
- package/dist/world/features.js +5 -2
- package/dist/world/roads.d.ts +19 -2
- package/dist/world/roads.js +60 -3
- package/dist/world/{pathNetwork.d.ts → streetGenerator.d.ts} +37 -37
- package/dist/world/{pathNetwork.js → streetGenerator.js} +9 -9
- package/dist/world/streets.d.ts +74 -1
- package/dist/world/streets.js +218 -0
- package/dist/world/terrain.d.ts +20 -1
- package/dist/world/terrain.js +41 -2
- package/package.json +1 -1
- package/skills/jgengine/SKILL.md +1 -0
- package/skills/jgengine/api.md +13 -5
- package/skills/jgengine-combat/SKILL.md +15 -1
- package/skills/jgengine-combat/api.md +39 -2
- package/skills/jgengine-combat/capabilities.md +17 -1
- package/skills/jgengine-combat/recipes/portable-damage-effects.md +197 -0
- package/skills/jgengine-combat/recipes/portable-runtime-state.md +154 -0
- package/skills/jgengine-combat/recipes/portable-weapon-plumbing.md +156 -0
- package/skills/jgengine-combat/recipes/visible-attacks.md +81 -0
- package/skills/jgengine-combat/reference.md +22 -3
- package/skills/jgengine-gameplay/SKILL.md +8 -0
- package/skills/jgengine-gameplay/api.md +35 -5
- package/skills/jgengine-gameplay/capabilities.md +18 -0
- package/skills/jgengine-gameplay/recipes/portable-xp-leveling.md +168 -0
- package/skills/jgengine-gameplay/reference-systems.md +9 -0
- package/skills/jgengine-world/api.md +52 -24
- package/skills/jgengine-world/capabilities.md +8 -4
- package/skills/jgengine-world/reference.md +1 -1
|
@@ -6,6 +6,7 @@ import { createGameFeed } from "../game/feed.js";
|
|
|
6
6
|
import { setGamePhase } from "../game/gamePhase.js";
|
|
7
7
|
import { createLootRegistry, grantDrops } from "../game/lootTable.js";
|
|
8
8
|
import { createSocial } from "../game/social.js";
|
|
9
|
+
import { splitStack } from "../inventory/inventoryModel.js";
|
|
9
10
|
import { createItemUse } from "../item/use.js";
|
|
10
11
|
import { createWeaponStats } from "../item/weapon.js";
|
|
11
12
|
import {} from "../scene/entityStats.js";
|
|
@@ -55,7 +56,7 @@ export function createGameContext(options) {
|
|
|
55
56
|
...(occluder !== undefined ? { occluder } : {}),
|
|
56
57
|
...(options.models !== undefined ? { models: options.models } : {}),
|
|
57
58
|
});
|
|
58
|
-
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
59
|
+
const { entities, objects, statsByInstance, entityStats, ensureInstanceStats, catalogEntry, spatial, combatSpatial, sceneRaycast, targeting, entityColliders, entityCollidersOf, objectCollidersOf, reportEntityBounds, entityVisualScaleOf, forms, paintLayer, spawnEntity, despawnEntity, moveEntityTowardCommit, resetAllToSpawn, bind, sceneObjects, } = scene;
|
|
59
60
|
// --- Shared game services (weapon, feed, features bag, commands, item use) ---
|
|
60
61
|
const weapon = createWeaponStats((itemId) => content.itemById?.(itemId));
|
|
61
62
|
const feed = createGameFeed(definition.feed);
|
|
@@ -114,6 +115,45 @@ export function createGameContext(options) {
|
|
|
114
115
|
});
|
|
115
116
|
const { inventoryIds, inventoryByUser, inventoryFor, layouts, wallets, walletOf, economy, putIntoAnyInventory, playerStatsFor, pose, possession, motionByUser, motionFor, loadouts, seedUserPool, } = playerSys;
|
|
116
117
|
scene.setOnAfterDespawn((instanceId) => pose.clear(instanceId));
|
|
118
|
+
// Built-in inventory mutation commands so a HUD grid drives moves/splits through the notifying
|
|
119
|
+
// command path (a raw InventorySet mutation would not re-render React). Each resolves the acting
|
|
120
|
+
// user's own inventory, so it respects `privatePerUser` replication.
|
|
121
|
+
const knownInventory = (inventoryId) => layouts[inventoryId] !== undefined;
|
|
122
|
+
const slotInRange = (inventoryId, slot) => typeof slot === "number" && Number.isInteger(slot) && slot >= 0 && slot < layouts[inventoryId].slots;
|
|
123
|
+
commandRegistry.define("inventory.move", {
|
|
124
|
+
validate(_state, input) {
|
|
125
|
+
if (!knownInventory(input.inventoryId))
|
|
126
|
+
return { reason: `unknown inventory "${input.inventoryId}"` };
|
|
127
|
+
if (!slotInRange(input.inventoryId, input.from) || !slotInRange(input.inventoryId, input.to)) {
|
|
128
|
+
return { reason: "invalid-slot" };
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
},
|
|
132
|
+
apply(_state, input) {
|
|
133
|
+
const inventory = inventoryFor(activeUserId());
|
|
134
|
+
// A rejected move (empty source, wrong-kind, no-space) is a harmless no-op: the item bounces back.
|
|
135
|
+
inventory.move(input.inventoryId, input.from, input.inventoryId, input.to);
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
commandRegistry.define("inventory.split", {
|
|
139
|
+
validate(_state, input) {
|
|
140
|
+
if (!knownInventory(input.inventoryId))
|
|
141
|
+
return { reason: `unknown inventory "${input.inventoryId}"` };
|
|
142
|
+
if (!slotInRange(input.inventoryId, input.slot))
|
|
143
|
+
return { reason: "invalid-slot" };
|
|
144
|
+
if (input.toSlot !== undefined && !slotInRange(input.inventoryId, input.toSlot)) {
|
|
145
|
+
return { reason: "invalid-slot" };
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
},
|
|
149
|
+
apply(_state, input) {
|
|
150
|
+
const inventory = inventoryFor(activeUserId());
|
|
151
|
+
const result = splitStack(inventory.state(input.inventoryId), input.slot, input.amount, input.toSlot);
|
|
152
|
+
if (result.status === "rejected")
|
|
153
|
+
return;
|
|
154
|
+
inventory.replaceState(input.inventoryId, result.state);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
117
157
|
const loot = {
|
|
118
158
|
register: lootRegistry.register,
|
|
119
159
|
has: lootRegistry.has,
|
|
@@ -328,6 +368,7 @@ export function createGameContext(options) {
|
|
|
328
368
|
signal.notify();
|
|
329
369
|
},
|
|
330
370
|
collidersOf: entityCollidersOf,
|
|
371
|
+
reportBounds: reportEntityBounds,
|
|
331
372
|
visualScaleOf: entityVisualScaleOf,
|
|
332
373
|
form: forms,
|
|
333
374
|
paint: paintLayer,
|
|
@@ -472,16 +513,21 @@ function saveKey(name) {
|
|
|
472
513
|
const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
|
|
473
514
|
return `jgengine:save:${slug === "" ? "game" : slug}`;
|
|
474
515
|
}
|
|
475
|
-
/**
|
|
516
|
+
/**
|
|
517
|
+
* Explicit `options.save` wins (the shell can inject any backend, e.g. cloud); otherwise an offline /
|
|
518
|
+
* single-player world auto-wires a `localStorage` whole-world save **by default** — a game gets `ctx.game.save`
|
|
519
|
+
* and autosave without touching `persist`. Only an explicit `persist: false` opts out; a host-authoritative
|
|
520
|
+
* multiplayer world never persists to `localStorage` (its host owns persistence) regardless of `persist`.
|
|
521
|
+
*/
|
|
476
522
|
function resolveSaveOptions(definition, options) {
|
|
477
523
|
if (options.save !== undefined)
|
|
478
524
|
return options.save;
|
|
479
525
|
const persist = definition.persist;
|
|
480
|
-
if (persist ===
|
|
526
|
+
if (persist === false)
|
|
481
527
|
return undefined;
|
|
482
528
|
if (!isOffline(definition.multiplayer))
|
|
483
529
|
return undefined;
|
|
484
|
-
const config = persist === true ? {} : persist;
|
|
530
|
+
const config = persist === undefined || persist === true ? {} : persist;
|
|
485
531
|
return {
|
|
486
532
|
backend: config.storage === "memory" ? memorySaveBackend() : localSaveBackend(),
|
|
487
533
|
mode: config.mode,
|
|
@@ -2,6 +2,7 @@ import type { CardPile, CardPileConfig } from "../cards/cardPile.js";
|
|
|
2
2
|
import type { EffectInput, EffectResult, EffectVia, ReceiveMap, SingleTargetEffectInput } from "../combat/effects.js";
|
|
3
3
|
import type { ProjectileSystem } from "../combat/projectiles.js";
|
|
4
4
|
import type { HitReaction, HitReactionConfig, ImpactPresetName } from "../combat/hitReaction.js";
|
|
5
|
+
import type { VfxPresetName } from "../combat/vfxPresets.js";
|
|
5
6
|
import type { TelegraphShape } from "../combat/telegraph.js";
|
|
6
7
|
import type { CommandDefinition, CommandResult } from "../commands/commandRegistry.js";
|
|
7
8
|
import type { ChargeOptions as WalletChargeOptions } from "../economy/wallet.js";
|
|
@@ -33,7 +34,7 @@ import type { PaintLayer } from "../scene/paintLayer.js";
|
|
|
33
34
|
import type { EntityStatsApi, StatCatalog } from "../scene/entityStats.js";
|
|
34
35
|
import type { EntityBlackboard, EntityPose, EntityPosition, EntityStore, SceneEntity, SpawnOptions, SpawnPose } from "../scene/entityStore.js";
|
|
35
36
|
import type { Forms } from "../scene/form.js";
|
|
36
|
-
import type { EntityColliderSet, ModelBodySource } from "../scene/colliders.js";
|
|
37
|
+
import type { EntityColliderSet, MeasuredBounds, ModelBodySource } from "../scene/colliders.js";
|
|
37
38
|
import type { ObjectRaycastHit, ObjectRaycastInput } from "../scene/objectQuery.js";
|
|
38
39
|
import type { ObjectStore } from "../scene/objectStore.js";
|
|
39
40
|
import type { Roster } from "../scene/roster.js";
|
|
@@ -135,6 +136,15 @@ export interface SceneObjectContext extends ObjectStore {
|
|
|
135
136
|
raycastAll(input: ObjectRaycastInput): readonly ObjectRaycastHit[];
|
|
136
137
|
setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
|
|
137
138
|
collidersOf(instanceId: string): EntityColliderSet | null;
|
|
139
|
+
/**
|
|
140
|
+
* Report the entity-local AABB the renderer actually mounted for `catalogId` — stored per catalog
|
|
141
|
+
* id as a blocking physical body and consulted after authored `colliders` and index-`dims` fitted
|
|
142
|
+
* models, before the visual-scale fallback. The shell reports custom `renderObject` content and
|
|
143
|
+
* dims-less models automatically, so a placed prop's body wraps its real rendered shape instead of
|
|
144
|
+
* a unit-cube guess. Pass `null` to clear. Returns `false` when the bounds are degenerate.
|
|
145
|
+
* @capability measured-colliders hitboxes and blocking bodies wrap what the renderer actually mounted — custom render props and dims-less models stop falling back to fixed-size boxes.
|
|
146
|
+
*/
|
|
147
|
+
reportBounds(catalogId: string, bounds: MeasuredBounds | null): boolean;
|
|
138
148
|
/**
|
|
139
149
|
* Per-instance selection/highlight state for placed objects — the reactive counterpart to
|
|
140
150
|
* `worldHealthBars`/`nameplates` (entities), so a build-mode or RTS selection ring reads
|
|
@@ -156,10 +166,22 @@ export interface FloatTextInput {
|
|
|
156
166
|
crit?: boolean;
|
|
157
167
|
scale?: number;
|
|
158
168
|
}
|
|
159
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Request a transient spell/ability VFX burst. The easy path is a named `preset` —
|
|
171
|
+
* `vfx({ preset: "arrow", from: caster, to: enemy })` renders a visible bolt with no color
|
|
172
|
+
* or archetype tuning; `"lightning"`, `"web"`, `"slash"`, `"shield"`, `"heal"`, `"explosion"`
|
|
173
|
+
* and the rest of {@link vfxPresets} likewise just work. `from`/`to` accept an instance id
|
|
174
|
+
* (the shell follows its live pose) or a fixed world point. Anything you also pass — `kind`,
|
|
175
|
+
* `color` (`0xRRGGBB`), `radius`, `durationMs` — overrides the preset; supply `kind` + `color`
|
|
176
|
+
* yourself for a fully custom burst with no preset. `durationMs` defaults per `kind`.
|
|
177
|
+
*/
|
|
160
178
|
export interface VfxInput {
|
|
161
|
-
kind
|
|
162
|
-
|
|
179
|
+
/** A named visual flavor from {@link vfxPresets}. Fills in `kind`/`color`/defaults; explicit fields below still win. */
|
|
180
|
+
preset?: VfxPresetName | (string & {});
|
|
181
|
+
/** Visual archetype. Optional when `preset` is given; required otherwise. */
|
|
182
|
+
kind?: VfxKind;
|
|
183
|
+
/** `0xRRGGBB` tint. Optional when `preset` is given; required otherwise. */
|
|
184
|
+
color?: number;
|
|
163
185
|
from?: string | readonly [number, number, number];
|
|
164
186
|
to?: string | readonly [number, number, number];
|
|
165
187
|
radius?: number;
|
|
@@ -242,6 +264,17 @@ export interface SceneEntityContext {
|
|
|
242
264
|
invalidateSpatial: SpatialApi["invalidate"];
|
|
243
265
|
setColliders(instanceId: string, colliders: EntityColliderSet | null): void;
|
|
244
266
|
collidersOf(instanceId: string): EntityColliderSet | null;
|
|
267
|
+
/**
|
|
268
|
+
* Report the entity-local AABB the renderer actually mounted for `kind` — stored per kind as a
|
|
269
|
+
* tight damage hitbox and consulted after authored `colliders` and index-`dims` fitted models,
|
|
270
|
+
* before the scale/humanoid-default fallbacks. The shell reports custom `renderEntity` content and
|
|
271
|
+
* dims-less models automatically, so hitboxes wrap the rendered mesh instead of the fixed
|
|
272
|
+
* 0.7×1.8×0.7 box. Runtime-measured on the client; authoritative hosts that never render should
|
|
273
|
+
* author `colliders` or model `dims` instead. Pass `null` to clear. Returns `false` when the
|
|
274
|
+
* bounds are degenerate.
|
|
275
|
+
* @capability measured-hitboxes an entity rendered with a custom mesh takes hits in a box matching its rendered bounds, not the fixed humanoid rectangle.
|
|
276
|
+
*/
|
|
277
|
+
reportBounds(kind: string, bounds: MeasuredBounds | null): boolean;
|
|
245
278
|
/** Uniform visual scale from the entity's catalog entry (1 when unscaled). */
|
|
246
279
|
visualScaleOf(instanceId: string): number;
|
|
247
280
|
form: Forms;
|
|
@@ -36,6 +36,17 @@ export interface HeadlessRunnerOptions<TAssetRef extends ModelAssetRef, TMultipl
|
|
|
36
36
|
/** Render-model lookup for collider auto-fit — pass what the shell would derive from `entityModels`/`objectModels` so headless raycasts hit the same boxes as play. */
|
|
37
37
|
models?: GameContextModels;
|
|
38
38
|
}
|
|
39
|
+
/** Canvas-free command/intent surface for {@link HeadlessRunner} — invoke a game's registered UI commands and read the resulting reactive state off `ctx`. */
|
|
40
|
+
export interface HeadlessCommandInterface {
|
|
41
|
+
/** Invoke a registered command/intent by name against the live context. Throws on `unknown-command` or a validation rejection so a headless UI-flow test fails loudly instead of silently no-op'ing. */
|
|
42
|
+
invoke(name: string, input?: unknown): void;
|
|
43
|
+
/** Non-throwing {@link invoke} returning the raw command result (inspect `status`). */
|
|
44
|
+
tryInvoke(name: string, input?: unknown): ReturnType<GameContext["game"]["commands"]["run"]>;
|
|
45
|
+
/** Registered command names. */
|
|
46
|
+
names(): string[];
|
|
47
|
+
/** Is a command registered? */
|
|
48
|
+
has(name: string): boolean;
|
|
49
|
+
}
|
|
39
50
|
/**
|
|
40
51
|
* A renderer-free driver for a game loop: builds a {@link GameContext} from a {@link GameDefinition}, runs the init
|
|
41
52
|
* hooks, then advances the simulation one step at a time from injected input. No React, R3F, or three.js — the whole
|
|
@@ -58,5 +69,7 @@ export interface HeadlessRunner {
|
|
|
58
69
|
step(dtSeconds: number, input?: HeadlessInput): number;
|
|
59
70
|
/** Publish input without advancing — for pre-seeding the held set before the first {@link step}. */
|
|
60
71
|
publishInput(input: HeadlessInput): void;
|
|
72
|
+
/** Drive the game's own UI commands / intents headlessly — the same registry `ctx.game.commands` and the shell's command sink dispatch into. No renderer, no pointer simulation: dispatch an intent, then read reactive state off `ctx`. */
|
|
73
|
+
readonly ui: HeadlessCommandInterface;
|
|
61
74
|
}
|
|
62
75
|
export declare function createHeadlessRunner<TAssetRef extends ModelAssetRef, TMultiplayer>(options: HeadlessRunnerOptions<TAssetRef, TMultiplayer>): HeadlessRunner;
|
|
@@ -29,6 +29,20 @@ export function createHeadlessRunner(options) {
|
|
|
29
29
|
input: ctx.input,
|
|
30
30
|
userId: player.userId,
|
|
31
31
|
publishInput,
|
|
32
|
+
ui: {
|
|
33
|
+
invoke(name, input) {
|
|
34
|
+
const result = ctx.game.commands.run(name, input);
|
|
35
|
+
if (result.status === "unknown-command") {
|
|
36
|
+
throw new Error(`headless ui.invoke: unknown command "${name}" — define it via ctx.game.commands.define`);
|
|
37
|
+
}
|
|
38
|
+
if (result.status === "rejected") {
|
|
39
|
+
throw new Error(`headless ui.invoke: command "${name}" rejected — ${result.reason}`);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
tryInvoke: (name, input) => ctx.game.commands.run(name, input),
|
|
43
|
+
names: () => ctx.game.commands.names(),
|
|
44
|
+
has: (name) => ctx.game.commands.has(name),
|
|
45
|
+
},
|
|
32
46
|
step(dtSeconds, input) {
|
|
33
47
|
if (input !== undefined)
|
|
34
48
|
publishInput(input);
|
|
@@ -22,6 +22,8 @@ export interface ModelAssetRef {
|
|
|
22
22
|
collisionMesh?: CollisionMeshData;
|
|
23
23
|
/** Authored asset-space metadata — canonical facing (degrees), source-unit scale, footprint, anchor, bounds, and rotation policy. The upstream owner of placement corrections; see {@link "scene/assetSpace".AssetSpace}. */
|
|
24
24
|
space?: AssetSpace;
|
|
25
|
+
/** Animation clip names read from the GLB at asset reindex. Lets the shell auto-animate rigged catalog assets (semantic clip roles → default idle/walk/run + one-shots) with no game-side clip strings. */
|
|
26
|
+
clips?: readonly string[];
|
|
25
27
|
}
|
|
26
28
|
/**
|
|
27
29
|
* A catalog entry backed by a registered {@link registerAssetGenerator} instead of a GLB URL — a
|
|
@@ -107,6 +107,27 @@ export declare function fittedEntityColliders(model: ModelBodySource): EntityCol
|
|
|
107
107
|
* @internal
|
|
108
108
|
*/
|
|
109
109
|
export declare function fittedObjectColliders(model: ModelBodySource): EntityColliderSet | null;
|
|
110
|
+
/**
|
|
111
|
+
* Entity-local AABB of what the renderer actually mounted for a kind — the runtime counterpart to
|
|
112
|
+
* index-measured `dims`. Reported through `ctx.scene.entity.reportBounds` /
|
|
113
|
+
* `ctx.scene.object.reportBounds` (the shell does this automatically for custom
|
|
114
|
+
* `renderEntity`/`renderObject` content and models without index dims) so collider resolution can
|
|
115
|
+
* wrap the real rendered shape instead of a fixed-size guess.
|
|
116
|
+
*/
|
|
117
|
+
export interface MeasuredBounds {
|
|
118
|
+
min: EntityPosition;
|
|
119
|
+
max: EntityPosition;
|
|
120
|
+
}
|
|
121
|
+
/** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
|
|
122
|
+
* entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
|
|
123
|
+
* @internal
|
|
124
|
+
*/
|
|
125
|
+
export declare function measuredEntityColliders(bounds: MeasuredBounds): EntityColliderSet | null;
|
|
126
|
+
/** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
|
|
127
|
+
* stores for an object catalog id. `null` when the bounds are degenerate.
|
|
128
|
+
* @internal
|
|
129
|
+
*/
|
|
130
|
+
export declare function measuredObjectColliders(bounds: MeasuredBounds): EntityColliderSet | null;
|
|
110
131
|
/** @internal */
|
|
111
132
|
export declare function resolveColliders(set: EntityColliderSet | null | undefined): ResolvedCollider[];
|
|
112
133
|
/** @internal */
|
package/dist/scene/colliders.js
CHANGED
|
@@ -188,6 +188,68 @@ export function fittedObjectColliders(model) {
|
|
|
188
188
|
},
|
|
189
189
|
};
|
|
190
190
|
}
|
|
191
|
+
/** Thin measured axes (decals, blades, flat panels) keep at least this half-extent so the box stays hittable. */
|
|
192
|
+
const MIN_MEASURED_HALF_EXTENT = 0.01;
|
|
193
|
+
/** Tight AABB from measured bounds — `null` when inverted, non-finite, or a degenerate point. */
|
|
194
|
+
function measuredBox(bounds) {
|
|
195
|
+
const { min, max } = bounds;
|
|
196
|
+
let volume = 0;
|
|
197
|
+
for (let axis = 0; axis < 3; axis += 1) {
|
|
198
|
+
const lo = min[axis];
|
|
199
|
+
const hi = max[axis];
|
|
200
|
+
if (!Number.isFinite(lo) || !Number.isFinite(hi) || hi < lo)
|
|
201
|
+
return null;
|
|
202
|
+
volume = Math.max(volume, hi - lo);
|
|
203
|
+
}
|
|
204
|
+
if (!(volume > 0))
|
|
205
|
+
return null;
|
|
206
|
+
return {
|
|
207
|
+
halfExtents: [
|
|
208
|
+
Math.max((max[0] - min[0]) / 2, MIN_MEASURED_HALF_EXTENT),
|
|
209
|
+
Math.max((max[1] - min[1]) / 2, MIN_MEASURED_HALF_EXTENT),
|
|
210
|
+
Math.max((max[2] - min[2]) / 2, MIN_MEASURED_HALF_EXTENT),
|
|
211
|
+
],
|
|
212
|
+
offset: [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Damage hitbox exactly wrapping runtime-measured render bounds — what `reportBounds` stores for an
|
|
216
|
+
* entity kind. `null` when the bounds are degenerate, keeping the caller's existing fallback.
|
|
217
|
+
* @internal
|
|
218
|
+
*/
|
|
219
|
+
export function measuredEntityColliders(bounds) {
|
|
220
|
+
const box = measuredBox(bounds);
|
|
221
|
+
if (box === null)
|
|
222
|
+
return null;
|
|
223
|
+
return {
|
|
224
|
+
hitboxes: [
|
|
225
|
+
{
|
|
226
|
+
name: "body",
|
|
227
|
+
purpose: "damage",
|
|
228
|
+
shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
|
|
229
|
+
damageEligible: true,
|
|
230
|
+
blocks: false,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
/** Blocking physical body exactly wrapping runtime-measured render bounds — what `reportBounds`
|
|
236
|
+
* stores for an object catalog id. `null` when the bounds are degenerate.
|
|
237
|
+
* @internal
|
|
238
|
+
*/
|
|
239
|
+
export function measuredObjectColliders(bounds) {
|
|
240
|
+
const box = measuredBox(bounds);
|
|
241
|
+
if (box === null)
|
|
242
|
+
return null;
|
|
243
|
+
return {
|
|
244
|
+
body: {
|
|
245
|
+
name: "body",
|
|
246
|
+
purpose: "physical",
|
|
247
|
+
shape: { kind: "aabb", halfExtents: box.halfExtents, offset: box.offset },
|
|
248
|
+
damageEligible: false,
|
|
249
|
+
blocks: true,
|
|
250
|
+
},
|
|
251
|
+
};
|
|
252
|
+
}
|
|
191
253
|
/** @internal */
|
|
192
254
|
export function resolveColliders(set) {
|
|
193
255
|
if (set === null || set === undefined)
|
|
@@ -19,6 +19,11 @@ export function applyPoolDelta(map, statId, amount) {
|
|
|
19
19
|
}
|
|
20
20
|
const change = changeStatPool(existing, amount);
|
|
21
21
|
const stat = change.pool;
|
|
22
|
+
// No-op change: `changeStatPool` handed back the same pool reference, so keep
|
|
23
|
+
// the existing map too — callers can guard on reference identity.
|
|
24
|
+
if (stat === existing) {
|
|
25
|
+
return { status: "ok", map, stat, hitMin: change.hitMin, hitMax: change.hitMax };
|
|
26
|
+
}
|
|
22
27
|
return {
|
|
23
28
|
status: "ok",
|
|
24
29
|
map: { ...map, [statId]: stat },
|
|
@@ -81,10 +86,13 @@ export function createEntityStatsApi(resolve) {
|
|
|
81
86
|
const map = resolve(instanceId);
|
|
82
87
|
if (map === undefined)
|
|
83
88
|
return { reason: `unknown instance "${instanceId}"` };
|
|
89
|
+
const existing = map[statId];
|
|
84
90
|
const result = applyPoolDelta(map, statId, amount);
|
|
85
91
|
if (result.status === "rejected")
|
|
86
92
|
return { reason: result.reason };
|
|
87
|
-
|
|
93
|
+
// Skip the store write when nothing moved so reference guards stay stable.
|
|
94
|
+
if (result.stat !== existing)
|
|
95
|
+
map[statId] = result.stat;
|
|
88
96
|
return null;
|
|
89
97
|
},
|
|
90
98
|
};
|
|
@@ -6,6 +6,17 @@ export type StatModifierSet<TStat extends string> = Partial<Record<TStat, StatMo
|
|
|
6
6
|
export interface CreateStatsOptions {
|
|
7
7
|
now?: () => number;
|
|
8
8
|
}
|
|
9
|
+
/** One serializable modifier source in a {@link StatsSnapshot}. */
|
|
10
|
+
interface StatModifierSourceSnapshot<TStat extends string> {
|
|
11
|
+
id: string;
|
|
12
|
+
modifiers: StatModifierSet<TStat>;
|
|
13
|
+
expiresAtMs?: number;
|
|
14
|
+
}
|
|
15
|
+
/** Complete plain-data state for a {@link Stats} runtime. */
|
|
16
|
+
export interface StatsSnapshot<TStat extends string> {
|
|
17
|
+
base: Record<TStat, number>;
|
|
18
|
+
sources: StatModifierSourceSnapshot<TStat>[];
|
|
19
|
+
}
|
|
9
20
|
export interface Stats<TStat extends string> {
|
|
10
21
|
setBase(stat: TStat, value: number): void;
|
|
11
22
|
getBase(stat: TStat): number;
|
|
@@ -17,6 +28,10 @@ export interface Stats<TStat extends string> {
|
|
|
17
28
|
get(stat: TStat, nowMs?: number): number;
|
|
18
29
|
pruneExpired(nowMs: number): string[];
|
|
19
30
|
sources(): string[];
|
|
31
|
+
/** Return a detached JSON-serializable copy of base values and modifier sources. */
|
|
32
|
+
snapshot(): StatsSnapshot<TStat>;
|
|
33
|
+
/** Replace all runtime state from caller-owned decoded snapshot data. */
|
|
34
|
+
restore(snapshot: StatsSnapshot<TStat>): void;
|
|
20
35
|
}
|
|
21
36
|
/**
|
|
22
37
|
* A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
|
|
@@ -24,3 +39,4 @@ export interface Stats<TStat extends string> {
|
|
|
24
39
|
* @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
|
|
25
40
|
*/
|
|
26
41
|
export declare function createStats<TStat extends string>(base: Record<TStat, number>, options?: CreateStatsOptions): Stats<TStat>;
|
|
42
|
+
export {};
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
function cloneModifierSet(modifiers) {
|
|
2
|
+
const copy = {};
|
|
3
|
+
for (const stat of Object.keys(modifiers)) {
|
|
4
|
+
const modifier = modifiers[stat];
|
|
5
|
+
if (modifier !== undefined)
|
|
6
|
+
copy[stat] = { ...modifier };
|
|
7
|
+
}
|
|
8
|
+
return copy;
|
|
9
|
+
}
|
|
1
10
|
/**
|
|
2
11
|
* A stat block whose base values take stacking, timed buffs and debuffs, resolving the modified value on read.
|
|
3
12
|
*
|
|
4
13
|
* @capability stat-block base stats with stacking, expiring buffs and debuffs applied on read
|
|
5
14
|
*/
|
|
6
15
|
export function createStats(base, options) {
|
|
7
|
-
|
|
16
|
+
let baseValues = { ...base };
|
|
8
17
|
const sourceEntries = new Map();
|
|
9
18
|
const clock = options?.now;
|
|
10
19
|
function resolveNow(nowMs) {
|
|
@@ -75,5 +84,25 @@ export function createStats(base, options) {
|
|
|
75
84
|
sources() {
|
|
76
85
|
return Array.from(sourceEntries.keys());
|
|
77
86
|
},
|
|
87
|
+
snapshot() {
|
|
88
|
+
return {
|
|
89
|
+
base: { ...baseValues },
|
|
90
|
+
sources: Array.from(sourceEntries, ([id, entry]) => ({
|
|
91
|
+
id,
|
|
92
|
+
modifiers: cloneModifierSet(entry.modifiers),
|
|
93
|
+
...(entry.expiresAtMs === undefined ? {} : { expiresAtMs: entry.expiresAtMs }),
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
restore(snapshot) {
|
|
98
|
+
baseValues = { ...snapshot.base };
|
|
99
|
+
sourceEntries.clear();
|
|
100
|
+
for (const source of snapshot.sources) {
|
|
101
|
+
sourceEntries.set(source.id, {
|
|
102
|
+
modifiers: cloneModifierSet(source.modifiers),
|
|
103
|
+
expiresAtMs: source.expiresAtMs,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
},
|
|
78
107
|
};
|
|
79
108
|
}
|
package/dist/stats/statPool.js
CHANGED
|
@@ -33,10 +33,13 @@ export function patchStatPool(pool, patch) {
|
|
|
33
33
|
*/
|
|
34
34
|
export function changeStatPool(pool, amount) {
|
|
35
35
|
const next = patchStatPool(pool, { current: pool.current + amount });
|
|
36
|
+
const applied = next.current - pool.current;
|
|
36
37
|
return {
|
|
37
38
|
previous: pool,
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
// Preserve the input reference on a no-op change (regen at max, damage at 0,
|
|
40
|
+
// clamp to a bound) so reference-equality guards in HUD hooks don't churn.
|
|
41
|
+
pool: applied === 0 ? pool : next,
|
|
42
|
+
applied,
|
|
40
43
|
hitMin: next.current === next.min,
|
|
41
44
|
hitMax: next.current === next.max,
|
|
42
45
|
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless, serializable model for a set of player-toggled windows/panels — the data layer behind a
|
|
3
|
+
* WoW-style UI where B opens the bag, C the character sheet, and ESC closes the topmost window. It
|
|
4
|
+
* carries *which panels are open*, their focus/z stacking order, per-panel position overrides, and
|
|
5
|
+
* exclusive-group membership, with zero rendering opinion. The React chrome in
|
|
6
|
+
* `@jgengine/react/panels` consumes this; a game keeps layout, skin, keybinds, and window content
|
|
7
|
+
* caller-owned. Pure and immutable: every reducer returns a new state and never mutates its input.
|
|
8
|
+
*/
|
|
9
|
+
/** A pixel position override for a panel (top-left, relative to the host surface). */
|
|
10
|
+
export interface PanelPosition {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A caller-authored panel/window declaration — the DATA input to the model. Only `id` is required so
|
|
16
|
+
* a bag, a character sheet, and a one-off dialog all share the shape.
|
|
17
|
+
*/
|
|
18
|
+
export interface PanelDef {
|
|
19
|
+
id: string;
|
|
20
|
+
/** Human title shown in the window's title bar; falls back to `id`. */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Keybind that opens/toggles the panel — a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`), matched case-insensitively by {@link panelByHotkey}. */
|
|
23
|
+
hotkey?: string;
|
|
24
|
+
/** Open on {@link createPanelState} (default false). */
|
|
25
|
+
initial?: boolean;
|
|
26
|
+
/** Exclusive group: opening one panel in a group closes the others in it (e.g. only one center window). */
|
|
27
|
+
group?: string;
|
|
28
|
+
/** Whether the player can close it (title-bar button / ESC). Default true; `false` pins it open. */
|
|
29
|
+
closable?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** @internal Serializable per-panel metadata lifted from {@link PanelDef} so reducers stay `(state, id)` pure. */
|
|
32
|
+
export interface PanelMeta {
|
|
33
|
+
group?: string;
|
|
34
|
+
closable?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Serializable state for a set of panels: which are `open`, their `z` focus stack (higher renders on
|
|
38
|
+
* top), optional per-panel `pos` overrides, and the `meta` registry (group/closable) captured from
|
|
39
|
+
* the defs so the reducers below need no defs argument. Plain JSON — persist and rehydrate it freely.
|
|
40
|
+
*/
|
|
41
|
+
export interface PanelState {
|
|
42
|
+
/** Open panels, keyed by id (`true` for open). Closed panels are absent. */
|
|
43
|
+
open: Record<string, true>;
|
|
44
|
+
/** z / focus order per open panel — the panel with the highest value is focused/on top. */
|
|
45
|
+
z: Record<string, number>;
|
|
46
|
+
/** Per-panel position overrides; remembered across close/reopen. */
|
|
47
|
+
pos: Record<string, PanelPosition>;
|
|
48
|
+
/** Group/closable metadata captured from the defs at construction. */
|
|
49
|
+
meta: Record<string, PanelMeta>;
|
|
50
|
+
/** Next z value to assign on open/focus — monotonic so focus order is deterministic. */
|
|
51
|
+
nextZ: number;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Build the initial {@link PanelState} from the panel defs: panels flagged `initial` start open (in
|
|
55
|
+
* declaration order, respecting group exclusivity), and every def's group/closable is captured into
|
|
56
|
+
* the state so later reducers need no defs.
|
|
57
|
+
*
|
|
58
|
+
* @capability panel-state serializable open/z/position state for a set of toggleable game windows
|
|
59
|
+
*/
|
|
60
|
+
export declare function createPanelState(defs: readonly PanelDef[]): PanelState;
|
|
61
|
+
/** Whether panel `id` is currently open. */
|
|
62
|
+
export declare function isOpen(state: PanelState, id: string): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Open panel `id`, raising it to the top of the focus stack. If it belongs to a `group`, the other
|
|
65
|
+
* open panels in that group are closed (exclusive windows). Opening an already-open panel just
|
|
66
|
+
* refocuses it. Position overrides are preserved.
|
|
67
|
+
*
|
|
68
|
+
* @capability panel-open open a window, raise its z, and enforce exclusive-group membership
|
|
69
|
+
*/
|
|
70
|
+
export declare function openPanel(state: PanelState, id: string): PanelState;
|
|
71
|
+
/**
|
|
72
|
+
* Close panel `id`. No-op if it is already closed. Its position override is kept so reopening restores
|
|
73
|
+
* the last placement.
|
|
74
|
+
*
|
|
75
|
+
* @capability panel-close close a window while remembering its last position
|
|
76
|
+
*/
|
|
77
|
+
export declare function closePanel(state: PanelState, id: string): PanelState;
|
|
78
|
+
/**
|
|
79
|
+
* Toggle panel `id` — open it (with group exclusivity) if closed, close it if open. The one-key
|
|
80
|
+
* behavior behind a keybind like `B` for the bag.
|
|
81
|
+
*
|
|
82
|
+
* @capability panel-toggle toggle a window open/closed from a single keybind
|
|
83
|
+
*/
|
|
84
|
+
export declare function togglePanel(state: PanelState, id: string): PanelState;
|
|
85
|
+
/**
|
|
86
|
+
* Raise open panel `id` to the top of the focus stack (assign it the highest z). No-op if it is
|
|
87
|
+
* closed or already on top, so a pointer-down that refocuses the top window returns the same state.
|
|
88
|
+
*
|
|
89
|
+
* @capability panel-focus raise a window to the top of the focus/z stack
|
|
90
|
+
*/
|
|
91
|
+
export declare function focusPanel(state: PanelState, id: string): PanelState;
|
|
92
|
+
/**
|
|
93
|
+
* Close the topmost closable open panel — the ESC handler. Skips panels pinned with `closable: false`
|
|
94
|
+
* and returns the same state when nothing closable is open.
|
|
95
|
+
*
|
|
96
|
+
* @capability panel-close-top close the focused (topmost) closable window — the ESC behavior
|
|
97
|
+
*/
|
|
98
|
+
export declare function closeTopPanel(state: PanelState): PanelState;
|
|
99
|
+
/**
|
|
100
|
+
* Find the panel whose `hotkey` matches `code`, case-insensitively — the keybind router. `code` may be
|
|
101
|
+
* a `KeyboardEvent.code` (`"KeyB"`) or key (`"b"`); it is compared verbatim to each def's `hotkey`, so
|
|
102
|
+
* author the hotkey in whichever form the caller feeds in. Returns the first match's id, or null.
|
|
103
|
+
*
|
|
104
|
+
* @capability panel-hotkey resolve a keybind (code or key) to the panel id it toggles
|
|
105
|
+
*/
|
|
106
|
+
export declare function panelByHotkey(defs: readonly PanelDef[], code: string): string | null;
|
|
107
|
+
/**
|
|
108
|
+
* Set panel `id`'s position override — the drag commit. Does not require the panel to be open, so a
|
|
109
|
+
* caller can pre-place a window before it is shown.
|
|
110
|
+
*
|
|
111
|
+
* @capability panel-move override a window's on-surface position (the drag commit)
|
|
112
|
+
*/
|
|
113
|
+
export declare function movePanel(state: PanelState, id: string, pos: PanelPosition): PanelState;
|
|
114
|
+
/**
|
|
115
|
+
* The open panels' defs sorted ascending by z — the render order for a window host (map to elements
|
|
116
|
+
* so the last, highest-z window paints on top). Defs unknown to the state are skipped.
|
|
117
|
+
*
|
|
118
|
+
* @capability panel-ordered-open open panels' defs in z/render order for a window host
|
|
119
|
+
*/
|
|
120
|
+
export declare function orderedOpen(state: PanelState, defs: readonly PanelDef[]): PanelDef[];
|