@jgengine/react 0.6.0 → 0.7.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 CHANGED
@@ -11,9 +11,174 @@ Agents building on the published SDK can also read this programmatically:
11
11
  same data as typed values, so an updater can diff its installed version against
12
12
  the latest and surface the migration steps.
13
13
 
14
- ## Unreleased
14
+ ## 0.7.0
15
+
16
+ The engine-gaps release — 22 system-level additions across turn/tactics, cards & boards,
17
+ crafting, survival, navigation & AI, camera rigs, physics & vehicles, traversal &
18
+ destruction, world items & building, map/HUD/ping, combat feel & abilities, audio,
19
+ interaction, sensors, embodiment, multiplayer depth, and objective/session machines.
20
+ **Every 0.6.0 API is unchanged** — the whole release is additive, so upgrading is a version bump.
21
+
22
+ ### Migrate
23
+
24
+ - Bump every `@jgengine/*` dependency to `^0.7.0` (the eight packages version in lockstep).
25
+ - No code change is required — 0.7.0 only adds surface; no 0.6.0 API moved or was removed. Existing games keep the orbit/first-person camera, single-player-entity control, and every existing primitive exactly as before.
26
+ - Opt into any new system by importing it directly: a camera rig via `camera.rig` + its config block; a `sensor/*` probe with `@jgengine/shell/vision` renderers; an `ai/*` director over the `nav/` navmesh; `turn/*` + `tactics/*` for turn-based/grid games; `cards/*` + `board/*` for deckbuilders; `crafting/*` for recipes/production/farming; `survival/*` + `world/{envField,weather,realm}` for survival; `combat/{abilityKit,animationState,defensiveWindow,…}` for action feel; `physics/{vehicleBody,traversal,structure,ragdoll,…}` for vehicles/destruction; `session/*` for contested/round/downed/ring/extraction machines; and `multiplayer/*` for lag-comp/hidden-commit/matchmaking.
27
+ - `entityStore.update()` now also accepts `name`, so possession/form can retarget an instance's catalog id without despawn/respawn — no action needed unless you relied on `name` being immutable.
28
+
29
+ ### Added
30
+
31
+ - **Turn-based & tactics stack** — six pure, renderer-free `@jgengine/core` primitives for turn-based, grid-tactics, and card games (XCOM, BG3, Into the Breach, Slay the Spire, Marvel Snap, Tactical Breach Wizards, Divinity surfaces).
32
+ - `@jgengine/core/turn/turnLoop` (`createTurnLoop`) — initiative machine with configurable `phases` and per-turn action-economy `pools` (`{ id, max, start? }`) that reset when a participant enters their turn; `advanceTurn`/`advancePhase`/`advanceRound`, `spend`/`canSpend`/`gain`/`refill`, and `setOrder`/`addParticipant`/`removeParticipant`. Covers both Slay-the-Spire single-energy resets and BG3's Action/Bonus/Movement/Reaction set.
33
+ - `@jgengine/core/turn/commit` (`createCommitController`, also `turnLoop.commit`) — three commit modes: `immediate`, `simultaneous` (sealed hidden submissions → `reveal()` on `allReady()`), and `rewind` (visible `pending()` → `rewind()`/`commit()`).
34
+ - `@jgengine/core/tactics/tacticalGrid` (`createTacticalGrid`) — tile occupancy, `reachable(from, budget)` flood-fill, `path`, and `push(id, dir, { distance, chain })` discrete knockback-to-tile with chained collisions (Into the Breach).
35
+ - `@jgengine/core/tactics/predictiveQuery` (`predictAreaEffect`/`predictArcEffect`/`predictTiles`) — a would-this-effect-hit query for pre-commit overlays and enemy-intent telegraphs, reusing the exact AoE/LoS targeting behind `ctx.scene.entity.effect` (new shared `resolveAreaTargets` in `combat/effects`) so predictions match what the effect actually drains.
36
+ - `@jgengine/core/tactics/snapshot` (`createSnapshotStore`, `deepClone`) — cheap, repeatable turn-undo over registered `capture()/restore()` slices (the grid, surfaces, and turn loop all qualify), with a `push()/pop()` undo stack.
37
+ - `@jgengine/core/tactics/surface` (`createSurfaceLayer`) — a stateful tile surface layer with its own `tick(dt)` and a data-driven combination matrix (`reactions: [{ when: [a, b], result }]`) — grease+fire, water+lightning — distinct from terrain/water.
38
+ - `@jgengine/core/combat/effects` now exports `resolveAreaTargets` (+ `AreaTarget`, `AreaTargetInput`), the shared in-radius→LoS→falloff→accept targeting that both `applyEffect` and the predictive query run, guaranteeing parity by construction. No behavior change to `effect`.
39
+
40
+ Pure, renderer-free primitives for card, board, and deckbuilder games — they sit **beside** the slot inventory, never replace it.
41
+
42
+ - **`@jgengine/core/cards/cardPile`** — a `cardPile` of named ordered zones (deck/hand/discard/exhaust) with seeded `shuffle`, `draw(n)` (hand limits + reshuffle-on-empty), `discard`/`exhaust`/`move`. Reuses the engine's seeded RNG (`pileRng`), so shuffles are deterministic under a seed. Slay the Spire, Balatro.
43
+ - **`@jgengine/core/cards/modifierPipeline`** — an ordered `{ source, apply(value) → value }` pipeline (`runPipeline` / `createModifierPipeline`) with an inspectable per-step `trace` (before/after/changed) for Balatro-style scoring readouts. Generic over the scored value.
44
+ - **`@jgengine/core/board/laneBoard`** — N lanes, per-side power aggregate + optional per-lane `LaneRule` modifier, with `laneOutcome`/`boardTotals`/`lanesWon`. Marvel Snap, Inscryption.
45
+ - **`@jgengine/core/board/timelineBoard`** — N slots each on an independent cooldown, `tick(dtMs)` resolving fires in expiry order (then slot index), multiple fires per slot per tick. The Bazaar auto-battlers.
46
+ - **`@jgengine/core/inventory/shapedGrid`** — a polyomino inventory variant: `Footprint` placement, `rotateFootprint`, `canPlace` overlap/bounds check, plus `gridAdjacencyQuery` (orthogonal/diagonal neighbors) feeding synergy effects, and `cellFromPoint` for pointer→cell snap. Backpack Hero, Tetris inventory.
47
+ - **`@jgengine/react/dragLayer`** — a 2-D UI-space drag/rotate/drop/snap gesture layer over the above: `useDragLayer`, headless `DraggableCard` (right-click rotate), `DropZone` (cell snap + active state), `DragGhost`.
48
+
49
+ - `@jgengine/core/crafting/recipe` — a recipe graph primitive: `RecipeDef { inputs, outputs, seconds?, station?, stationRange?, requires? }` turns inputs + an optional required-workstation-in-range + time into outputs. `craft` / `canCraft` consume and produce on an `InventoryState` atomically (rejecting `missing-inputs` / `no-station` / `locked` / `no-output-space`); `stationSatisfied` does the range check against placed `{ catalogId, position }` stations; `createRecipeGraph` indexes recipes by `producing` / `using` / `category`. For Valheim/Enshrouded-style workbench tiers, Tarkov hideout stations, Palworld base craft. (#71)
50
+ - `@jgengine/core/economy/techTree` — a prerequisite-gated tech tree that **generalizes flat `unlocks`** rather than duplicating it: `TechNodeDef extends UnlockDef` adds `requires` (prereq ids), a `recipe` payload, and `grants`. `createTechTree(defs)` wraps `createUnlocks` and gates `unlock(userId, id)` on prerequisites, exposing `available` (frontier) and `recipes` (payloads unlocked). Flat unlocks are just nodes with no `requires`. For Once Human Memetics / Abiotic Factor branching trees. (#72)
51
+ - `@jgengine/core/crafting/production` — `productionBuilding({ inputs, outputs, rate, power? })` plus `tickProduction`, which consumes buffered inputs and emits outputs continuously through game-time `dt` (so pause/fast-forward apply for free). `feedProduction` / `drainOutput` are the buffer I/O, `advanceTransport` slides items along a conveyor path, `resolvePowerGrid` powers demands greedily against a supply. For Palworld/Satisfactory/Factorio automation. (#74)
52
+ - `@jgengine/core/crafting/crop` — a `cropTile` soil-and-growth state machine (`tillTile` / `plantCrop` / `waterTile` / `advanceCropDay` / `harvestCrop`, with regrow and daily-water rules) that advances stages on the simClock day tick, plus `applyToolToTiles(tiles, center, pattern, apply)` with `squarePattern` / `diamondPattern` / `rectPattern` for watering-can/hoe AoE under the cursor. `createCropField` wraps a tile grid; `createDayTicker` reads day rollovers off `ctx.time.calendar()`. For Stardew/Coral Island/Palia farming. (#75)
53
+ - **Survival & environment layer.** Renderer-free `@jgengine/core` primitives for survival games (Project Zomboid moodles, The Long Dark / Green Hell survival, DayZ / Tarkov per-limb health, weather-driven games, Nightingale realm cards), plus the `@jgengine/shell` fire visuals.
54
+ - `@jgengine/core/survival/decayMeter` — named `decayMeter`s (`createDecayMeterSet`) that drain/recover on game-time `dt`, refill from consumables/actions, raise threshold moodles, and take environment-driven rate modifiers (cold speeds warmth loss, toxic biomes drop oxygen).
55
+ - `@jgengine/core/survival/moodle` — a stacking status-effect display distinct from numeric bars: `stackMoodles(...)` folds meter/ailment/buff moodles worst-first, and `createMoodleStack()` holds timed buffs (Valheim-style concurrent food buffs) that expire on `tick(dt)`.
56
+ - `@jgengine/core/survival/regionHealth` — a multi-region health component (`createMultiRegionHealth`): per-part pools with vulnerability + vital death, a stacking ailment queue (bleed/fracture) that drains over time, and per-injury treatment items (`treat("bandage")`). Shares the moodle display via `ailmentMoodles()`.
57
+ - `@jgengine/core/world/envField` — sampleable environment fields (`createEnvironmentField`): temperature, wetness, light-exposure, and ambient-light at any world position and time, with sky occluders, heat sources, and a day/night sun cycle. Meters and spawn gating read it renderer-free.
58
+ - `@jgengine/core/world/weather` — weather → gameplay modifiers (`resolveWeather` → grip/visibility/structure-damage/chill/ignition/spread) over a game-owned table, plus a **coarse cellular** fire-spread grid (`createFireGrid`, not a fluid solver): wind-biased propagation, fuel burn-through, firebreaks, and rain/wetness suppression.
59
+ - `@jgengine/core/world/realm` — runtime realm composition (`composeRealm`): assemble a played instance from a deck of modifier cards (major biome + minor weather/day-length/spawn cards) that override environment params and spawn tables, recomposing a sampleable environment field on top of the weather hooks.
60
+ - `@jgengine/shell/weather` `FireSpreadLayer` — renders a `FireGrid`'s burning flames + scorched cells; a `survival-demo` game wires the whole stack (moodle stack, per-part body panel, condition meters, weather readout, spreading fire).
61
+ - `@jgengine/core/audio/audioFalloff` — the audio contract + pure distance→gain math. `SoundDef`/`AudioBusDef` catalog types, `computeFalloffGain(distance, config)` (`"linear" | "inverse" | "none"` curves), and `resolveEmitterGain`/`distance3` helpers. No Web Audio in core — this is the tested math the shell plays from.
62
+ - `@jgengine/core/time/beatClock` — a BPM tick signal, separate from `simClock`. `createBeatClock({ bpm, beatsPerBar? }, onBeat?)` advances on **game-time** `dt` and fires `onBeat` per crossed beat; `createBeatInputBuffer` auto-corrects an off-beat press to fire on the next beat tick (`nextBeatTime` is the underlying pure quantization). For rhythm-quantized combat (Hi-Fi Rush–style).
63
+ - `@jgengine/shell` positional audio — `PlayableGame.audio = { sounds, buses? }` + `entitySounds`/`objectSounds` (kind/catalog-id → sound id, same convention as `entityModels`) declare looping positional emitters; the shell (`shell/audio/audioEngine`, `shell/audio/AudioComponents`) owns the `AudioContext`, attaches the listener to the camera every frame, and drives emitter gain from the core falloff curve. Music/SFX buses are plain per-bus gain nodes. No per-game Web Audio glue.
64
+ - `@jgengine/ws/voiceChannel` — a thin, coarse voice-channel router riding the same transport/presence model (channel/falloff model only, no WebRTC media). `createVoiceChannelRouter(channels?)`: `join`/`leave` any number of channels at once, `updatePosition` for proximity falloff (reusing the core audio falloff curve), `setMuted`, and `resolveRoutes(listenerUserId)` — one `{ fromUserId, channelId, gain }` per shared channel, so a positional proximity channel and a flat-gain walkie/crew channel resolve simultaneously and independently.
65
+ - `@jgengine/core/interaction/skillCheck` — a moving-target-zone, timed minigame (`evaluateSkillCheck`, `skillCheckMarkerPosition`, `skillCheckZoneAt`) for casting/reeling, active-reload, and production minigames from an `item.use` handler.
66
+ - `@jgengine/core/interaction/qte` — a timed-prompt QTE sequencer (`evaluateQteSequence`, `pendingQteStep`, `qteProgress`).
67
+ - `@jgengine/core/scene/captureCheck` — `captureChance`/`rollCapture`, an hp%+catchPower→probability formula for capture/tame mechanics.
68
+ - `@jgengine/core/scene/roster` — `createRoster()`, a persisted per-owner roster (capture/release/list/setEquipped) wired onto the runtime as `ctx.game.roster`, distinct from the ephemeral `game.social.party`.
69
+ - `@jgengine/core/stats/rollCheck` — `rollCheck`, a d20-style roll vs. DC with advantage/disadvantage and critical detection.
70
+ - Dialogue choices can now gate a branch behind a roll: `DialogueChoice.check` (+ `onSuccess`/`onFailure`) on the `@jgengine/react/components` `DialogueDef`/`DialogueChoice` types, resolved via `resolveDialogueInvoke`.
71
+ - `@jgengine/react` gained `SkillCheckBar`, `QteTrack`, and `CaptureOdds` headless minigame UI components, plus a `useRoster(userId?)` hook and extra `DialogueBox` slot classNames (`lineClassName`, `speakerClassName`, `choicesClassName`, `choiceClassName`, `checkClassName`).
72
+ - **Navigation & pointer-driven input.** A renderer-free foundation for click-to-move, RTS unit command, and pointer verbs.
73
+ - `@jgengine/core/nav/navGrid` — a walkable grid + A* pathfinding (`createNavGrid`, `findPath`, `smoothPath`); blocked start/goal snap to the nearest walkable cell, paths are string-pulled, and one graph feeds both click-to-move and AI routing.
74
+ - `@jgengine/core/nav/pathFollow` — an authored-polyline mover for tower-defense creeps needing no navmesh (`createPathFollow` + pure `advancePathFollow`); `pathFromNav` lifts an A* route so the same follower drives click-to-move.
75
+ - `@jgengine/core/input/pointer` — the renderer-free `PointerHit` contract (`{ point, normal, entity, object }`) plus `aimToPoint` / `moveTargetFromHit` / `groundOf` so gameplay consumes cursor hits (aim, move-to, routing) without three.js.
76
+ - `@jgengine/core/scene/selection` — pure box-select math (`createSelectionSet`, `screenRect`, `selectWithinRect`, `isMarquee`).
77
+ - `@jgengine/core/interaction/contextMenu` — `contextVerb` / `buildContextMenu` / `contextVerbInput`; catalog entities/objects carry `verbs` for right-click menus.
78
+ - `PlayableGame.pointer` config (`moveCommand`, `select`, `orderCommand`, `contextMenu`, `aim`) — the `@jgengine/shell` `GamePlayerShell` casts the cursor (`pointer.worldHit()`), renders a drag-marquee + right-click verb menu, routes primary-ability aim to the cursor, and remaps orbit to middle-drag so the left button drives verbs.
79
+ - **AI over the navmesh.** A renderer-free `ai/` domain for directors, aggro, jobs, and crowds — all ticking on game-time `dt` (the `ctx.time` simClock delta, so pause/fast-forward come free) and routing over the `nav/` navmesh.
80
+ - `@jgengine/core/ai/spawnDirector` — a budget/escalation spawn director for wave shooters and difficulty directors (`createSpawnDirectorState` + pure `advanceSpawnDirector`). Per-`WaveManifest` budgets spent on weighted `SpawnEntry`s (`cost`/`weight`/`minWave`) under a `maxAlive` cap; `duration` auto-advances waves (or `advanceWave` on clear); budget trickles, ramps with sim-time (`escalationPerSecond`), scales per player, and surges on `raiseAlert`. Seeded/deterministic; `pickSpawnPoint` biases placement toward players.
81
+ - `@jgengine/core/ai/threat` — an aggro/threat table (`createThreatTable`): accumulate/`decay` per source, `highest({ current, stickiness })` for sticky MMO target selection feeding `scene/targeting`, `ranked` for a threat meter.
82
+ - `@jgengine/core/scene/behaviors` gained `patrol({ waypoints, speed, loop? })` — a waypoint route as a `BehaviorDescriptor` on top of `wander`, driven by `pathFollow` over `findPath`.
83
+ - `@jgengine/core/ai/jobBoard` — a task/job queue NPCs pull from (`createJobBoard`): `post`/`claim`/`assign`/`release` plus a per-tick `advance(worker, dt, { distanceToStation })` state machine (`travelling → working → done`, `repeat` for production loops) that reports on completion. For colony/companion assignment (Palworld, Schedule I, Sons of the Forest).
84
+ - `@jgengine/core/ai/crowd` — a flow field with congestion + POI routing for management sims (`computeFlowField` Dijkstra steering without per-agent A*, `createCrowdField` per-cell occupancy whose `penalty()` reroutes flow around crowds, `selectPoi` weighting appeal/proximity/capacity with a `findPath`-length distance override).
85
+ - **Dropped-item world entities & loot filter.** Loot no longer has to teleport straight to inventory — it can lie on the ground as a rarity-coded, beam-and-labelled `worldItem` you walk up to and grab (Borderlands/Diablo loot beams, ARPG ground loot, Apex/Lethal-Company pick-ups).
86
+ - `@jgengine/core/game/worldItem` — the `worldItem` scene-entity model (position + item ref + rarity): `ctx.scene.worldItem.spawn / get / list / nearestInRadius / pickup` (a third scene bucket alongside object/entity, never merged into inventory). Pure helpers `createWorldItemStore`, `resolveDeathDrops` (splits rolled drops into scattered ground items vs direct grants), `scatterOffset` / `scatterPosition` (the on-death scatter impulse), `selectNearestWorldItem` (pickup-radius nearest selection), and `resolveWorldItemPresentation` (rarity baseline + filter overlay → render binding). Emits `worldItem.dropped` / `worldItem.picked_up`.
87
+ - `onDeath.dropMode: "world"` (+ optional `onDeath.scatter`) routes an entity's death drops through scattered ground items instead of granting straight to the killer; currency still grants directly. Item catalog entries carry `rarity` / `baseType` read by the render binding + filter.
88
+ - `@jgengine/core/game/lootFilter` — a PoE/Last-Epoch-style rule evaluator (`lootFilter`, `evaluateLootFilter`) keyed on rarity + base type + affix-tier that hides/recolors/beams/labels ground items. Rules are **data the game supplies**; first match wins, field-by-field over the rarity baseline. The render binding is engine-owned.
89
+ - `PlayableGame.worldItem` config (`rarityStyle`, `filter`, `pickupRadius`, `beamHeight`) + `pointer.grabWorldItems` — `@jgengine/shell`'s `WorldItems` draws the rarity beam/color/label per drop and `GamePlayerShell` grants + despawns on a click within pickup radius (using `pointer.worldHit()`). `@jgengine/react`'s `useWorldItems()` / `useNearestWorldItem(radius)` drive a HUD pickup prompt.
90
+
91
+ - **Interactive placement, building & terraform.** Data-only placement becomes the build tooling of Valheim/Enshrouded/The Sims/Fortnite/Dinkum/Palia, all pure `@jgengine/core/world` driven by `pointer.worldHit()`, with shell renderers for the ghost/tint/brush.
92
+ - `@jgengine/core/world/placementController` — `createPlacementController(footprint)` owns the placement ghost: `hover(hit)` → `PlacementPreview` (valid/invalid tint wrapping `validatePlacement`), `rotate`, grid/free/surface `snapMode`, `commit` → `PlacementCommit`.
93
+ - `@jgengine/core/world/connectors` — typed connector sockets with `snapToNearest` snap-to-nearest-compatible (`socketsCompatible`, `worldSockets`).
94
+ - `@jgengine/core/world/support` — `solveSupport` walks the connector graph to ground (`supported`/`unsupported`/hop-`distance` for white→red decay); `toDebrisBodies` sinks collapsed pieces into the `PhysicsWorld`.
95
+ - `@jgengine/core/world/walls` — `createWallDrawTool` (drag walls → auto-enclose → `footprintFromWalls` → `autoRoof` hip/gable/flat) plus `createSurfacePaint` for per-tile floor/wall surfaces.
96
+ - `@jgengine/core/world/placedStructureStore` — `createPlacedStructureStore` save/load/select/move/delete with a `snapshot`↔`load` round-trip that survives reload.
97
+ - `@jgengine/core/world/terraform` — `createEditableTerrain({ bounds, base, cellSize })` makes a `TerrainField` you can **write back to** via `apply(edit)` (raise/lower/flatten/paint), and `createTerraformBrush` is the cursor tool. This height-offset grid is the shared terrain-edit write-back pattern.
98
+ - `@jgengine/core/world/buildPermissions` — `createPlotPermissions` (per-plot/guild `BuildRole` edit authority) + `createContributionPool` (co-op pooled-resource contribution model).
99
+ - `@jgengine/shell` renderers: `structures/PlacementGhost` (tinted ghost), `terrain/EditableGround` (renders an `EditableTerrain` with surface paint), `terrain/TerraformBrushCursor` (brush ring); the `builder-sandbox` demo game wires them to `pointer.worldHit()`.
100
+ - **Map, fog of war & contextual ping.** Minimap/world-map/fog/compass + a squad ping verb, built on renderer-free core state, a shell terrain bake, and react HUD components. (Stacked on the pointer foundation above.)
101
+ - `@jgengine/core/world/markers` — a reactive `createMarkerSet()` of `MapMarker`s (objective/entity/loot/ping) with `add`/`remove`/`query`/`prune`/`subscribe`; `DEFAULT_MARKER_KINDS` supplies content-agnostic colors + glyphs.
102
+ - `@jgengine/core/world/fog` — reveal-on-event fog: `createFogField({ bounds, cellSize })` with `reveal` (dig/act) and `revealAlong` (walked trail); revealed cells stay revealed and render from a stable `cells()` snapshot.
103
+ - `@jgengine/core/world/minimap` — pure projection + bearings (`projectToMinimap`, `clampToMinimapEdge`, `compassBearing`, `headingToBearing`, `bearingToCardinal`, `relativeBearing`).
104
+ - `@jgengine/core/game/ping` — `classifyPing(hit, …)` (hostile → enemy, tagged object → its category, ground → location) + `createPingSystem` that classifies, drops a categorized marker, and broadcasts a `PingPayload` over the existing party feed under `PING_FEED_ACTION`. `PlayableGame.pointer.pingCommand` binds the `ping` action → `worldHit()` → your command.
105
+ - `@jgengine/react` — `useMarkers` / `useFog` hooks and `Minimap` / `Compass` / `WorldMap` headless components (bind a core `MarkerSet`/`FogField`, override the `kindStyles` palette).
106
+ - `@jgengine/shell/map` — `bakeTerrainMap` (top-down terrain image for the map background) and `MapMarkerBeacons` world-space beacons; the `extraction-map` demo game wires the whole loop.
107
+ - **Physics joints & constraints** — `PhysicsWorld` gains `hingeJoint`/`fixedJoint`/`distanceJoint`/`springJoint(JointOptions)` between two bodies or a body and a fixed world point, plus `removeJoint`, `setJointAnchor` (move a follow point), `setJointRest`, and `readJointSegments`. The sim is translational: `hinge`/`fixed` pin the shared anchor, `distance` holds a separation, `spring` drives toward `restLength` with stiffness/damping. Foundation under vehicle suspension, ragdolls, grapples, and carry. Tune the buffers with `jointCapacity` / `jointCorrection` in `PhysicsWorldConfig`.
108
+ - **Physics collision → gameplay-event hook** — `PhysicsWorld.onCollision(listener, minApproachSpeed?)` delivers each impacting contact as a reused `CollisionEvent { a, b, nx, ny, nz, approachSpeed, impulse }` during `step`. The seam crash-damage and destruction read; contacts otherwise stay inside the sim.
109
+ - **`@jgengine/core/physics/ragdoll`** — `createRagdoll(world, { bones, links, balance? })` builds a jointed multi-body character on the joint API: floppy by default, or active-ragdoll when a balance motor drives the root toward a target height (`Ragdoll.balance(dt, moveX?, moveZ?)`), with `centerOfMass`, `applyImpulse`, `remove`.
110
+ - **`@jgengine/core/physics/carryable`** — `Carryable` grabs a physics body to a moving follow point via a spring constraint (the raycast pick is the caller's), supports shared multi-owner carry (follow point = owner average), drop/throw, and `carrySpeedMultiplier(mass, capacity, owners)` encumbrance.
111
+ - **`@jgengine/core/physics/forceVolume`** — `ForceVolume` (impulse/velocity/accelerate trigger region, `once` for boost pads vs continuous fans) and `PlatformCarry` (carry bodies standing on a moving platform by its per-`step` delta).
112
+ - **`@jgengine/core/physics/spatialGrid`** — `SpatialGrid`, a broad-phase uniform grid over the x/z plane, separate from the rigid-body sim, for cheap same-tick proximity across hundreds–thousands of simple movers: `rebuild(count, xs, zs)` then `queryCircle` (swarm enemies hitting a player/AoE) or `forEachPair` (mutual separation).
113
+ - **`@jgengine/shell/world/InstancedJoints`** — debug LineSegments overlay drawing a `PhysicsWorld`'s active joints from `readJointSegments`.
114
+ - **`@jgengine/core/physics/traversal`** — `Grapple` (a fired-anchor rope on the joint API: `fire`/`reel`/`payOut`/`moveAnchor`/`release` for grapple, zipline, and swing traversal) and `Glide` (reduced-gravity, forward-thrust wingsuit/glider via `apply(dt, steerX, steerZ)` before `step`). Grapple/zipline/glide (Sekiro, Deep Rock, Enshrouded) over the shared physics sim.
115
+ - **`@jgengine/core/world/carve`** — runtime destructible terrain. `VoxelVolume` is an editable dense voxel grid with `carve`/`deposit` sphere ops honouring per-material `strength` against a tool (Deep Rock dig, Astroneer deposit); `CarvableField` / `carvableTerrain(base)` writes craters and mounds back into any `TerrainField`'s height so ground-snap, collision, and the terrain mesh all see the deformation (Helldivers 2 craters).
116
+ - **`@jgengine/core/physics/structure`** — `StructureGraph`, a structural-integrity graph over a building: nodes (pieces), load-bearing edges, anchored foundations. `damage`/`damageEdge`/`severEdge` recompute reachability to an anchor and return one `CollapseEvent` of every newly-disconnected piece; `toDebris(world, event)` sinks the fallen pieces into a `PhysicsWorld` as rigid bodies. Coarse by design — the collapse **event** replicates, not each fragment (The Finals, Rainbow Six).
117
+ - **`@jgengine/shell/terrain/CarvedTerrain`** — meshes any `TerrainField` (a `CarvableField` with its craters/mounds) into a vertex-coloured deformed ground; `createFieldGroundGeometry` is the underlying geometry builder.
118
+ - **Analog axis input** (`@jgengine/core/input/axisInput`) — `AxisInput { throttle, brake, steer, handbrake }` continuous channel, distinct from the digital action bindings. `AxisChannel` ramps held keys into pedal-like analog values (`sample`) or takes a raw gamepad axis (`setAnalog`); `DRIVE_AXIS_BINDINGS` is a ready WASD/arrow map.
119
+ - **Vehicle controller** (`@jgengine/core/physics/vehicleBody`) — `createVehicleBody(world, config)`: chassis body + per-wheel suspension raycast + spring-damper (on `springJoint`) + a `GripCurve` (`sampleGripCurve`) that bleeds lateral velocity for cornering and, under handbrake, drift. Driven by an `AxisInput`; still a colliding body, so contact feeds crash damage. (Rocket League, Trackmania, Wreckfest.)
120
+ - **Buoyant boat** (`@jgengine/core/physics/buoyancy`) — `createBuoyantBody(world, { body, water, … })` floats a body on a CPU `waterSurface` (Archimedes per hull point + water drag) and, given an `AxisInput`, drives it as a boat (thrust + yaw + keel).
121
+ - **Mount / rideable controller** (`@jgengine/core/scene/mount`) — `createMountController()` transfers camera + input to a driven entity with its own movement kit: `register({ id, kit, seats })`, `mount`/`dismount`, `cameraTarget`/`driveTarget`, `driver`/`occupants`. Control seat drives, passenger seats ride (multi-seat shared vehicles). (Palworld, V Rising, Sea of Thieves.)
122
+ - **Crash-damage stages** (`@jgengine/core/physics/damageZones`) — `createDamageModel({ zones, disableAt })` maps accumulated `onCollision` impulse to coarse discrete stages (`absorb`/`routeCollision`), with an optional `detachStage` (part → debris) and a `disabled` threshold. Coarse stages, not soft-body. (Wreckfest.)
123
+ - **Race state machine** (`@jgengine/core/game/race`) — `raceTrack({ checkpoints, laps })` + `createRaceState({ track, win })` emit `checkpoint.hit` / `lap.completed` / `position.changed` / `race.finished` on game time, keep split times, resolve a pluggable win condition (`firstPastPost`, `topK`, `everyoneFinishes`, `lastStanding`), and `resetToCheckpoint`. (Trackmania, Mario Kart, Fall Guys.)
124
+ - **Lag-compensated hit registration** (`@jgengine/core/multiplayer/lagCompensation`) — `createPositionHistory({ historyMs })` retains an N-sample position ring per entity; `rewindTimestamp(now, rtt, interpDelay)` and `resolveHitscan(history, targets, ray, atMs)` (ray-sphere) register a shot where the target *was* at the shooter's perceived time. Coarse server-side rewind, not full rollback. The `@jgengine/node` ws host records accepted presence poses and exposes `server.rewind({ serverId, atMs })` (`positionHistoryMs` option). (Valorant, Apex.)
125
+ - **Simultaneous hidden-commit + reveal** (`@jgengine/core/multiplayer/simultaneousCommit`) — `createCommitRound({ participants })`: each player `seal`s a sealed action, nothing is readable until `allSealed()`, then `reveal()` returns commits in deterministic participant order (independent of network arrival) for `resolveCommits`. (Marvel Snap.)
126
+ - **Combat-snapshot replay** (`@jgengine/core/multiplayer/combatSnapshot`) — `serializeBoard({ ownerId, units, stats, seed })` deep-freezes a build into a portable `BoardSnapshot`; `replayCombat(a, b, rules)` resolves it deterministically (seeded PRNG) against a live opponent's snapshot — an async-PvP primitive distinct from the live-sync adapters. (The Bazaar.)
127
+ - **Shared-vehicle stations** (`@jgengine/core/scene/stationClaim`) — `createStationClaim(controller?)` layers facet stations (`steer`/`sails`/`cannon`) on `scene/mount`: `register({ id, kit, stations })`, `claim`/`release`, `controllerOf(vehicleId, facet)`, `facetOf`, `openFacets`, `crew`. One control station drives the hull; the rest ride but command their facet. (Sea of Thieves.)
128
+ - **Shared / group wallet** (`@jgengine/core/economy/sharedWallet`) — `createWalletBook()` holds per-`WalletScope` balances (`userScope`/`groupScope`) beside the per-user `economy/wallet`: `grantTo`/`chargeFrom`/`balanceIn`, with a `contributionOf`/`contributorsOf` ledger tracking who funded a shared pool. (Schedule I company funds, Lethal Company quota.)
129
+ - **Session matchmaking** (`@jgengine/core/multiplayer/matchmaking`) — data-driven browse/filter (`browseSessions`, `matchesFilter`, `quickMatch`) hiding private/closed lobbies, plus join-by-code (`findByJoinCode`, `normalizeJoinCode`, `generateJoinCode`). The node host carries generic `SessionAttributes` (`label`/`mode`/`visibility`/`joinCode`/`tags`) on `GameServerRecord`/`ServerListing` and gains `browseServers` + `joinByCode`; the ws backend exposes `browse` / `joinByCode` / `createSession`. (Fortnite island browse, Web Fishing code lobbies.)
130
+ - **Camera rig library** (`@jgengine/shell`, config types in `@jgengine/core/game/playableGame`) — the single orbit camera is now one of eight rigs, selected and tuned through `PlayableGame.camera` (never by writing camera positions from `onTick`). Set `camera.rig`:
131
+ - `topDown` — fixed height/pitch/yaw with decoupled follow for ARPG-iso and top-down (Diablo IV/PoE 2/Last Epoch, Hades II); `camera.topDown: { height, pitch, yaw, followSmoothing, zoom }`.
132
+ - `rts` — free-pan / edge-scroll / rotate / zoom independent of any avatar (The Sims 4, Manor Lords, Two Point Museum); `camera.rts: { panSpeed, edgeScroll, rotateSpeed, bounds, start }`.
133
+ - `shoulder` — over-the-shoulder with ADS transition and shoulder-swap, reticle decoupled from camera center (Remnant II, Helldivers 2, The First Descendant); `camera.shoulder: { shoulderOffset, distance, ads, side }`.
134
+ - `lockOn` — yaw bound to the player→target vector with the move axis reinterpreted as strafe (Elden Ring, Sekiro); `camera.lockOn: { targetEntityId?, distance, framingBias, yawSmoothing }`.
135
+ - `chase` — speed-reactive vehicle chase (speed→FOV curve, spring-arm damping, procedural shake) plus fixed `cockpit`/`hood`/`rear` views (Forza Horizon 5, Rocket League, Trackmania); `camera.chase: { distance, springDamping, fov, shakePerSpeed, view }`.
136
+ - **Every rig accepts `followEntityId: null`** — avatar-less games (city-builders, card games, auto-battlers) can now mount a camera; the orbit rig no longer bails when there is no follow target.
137
+ - **Camera-shake / trauma channel** — every rig reads a shared trauma channel; feed it from any system with `import { cameraShake } from "@jgengine/shell/camera"` (`cameraShake(amplitude, decayPerSecond?)`, amplitude 0..1) or from React via `useCameraShake()`. Tune with `camera.shake: { maxOffset, maxRoll, decayPerSecond, exponent, frequency }`. (Combat hitstop and other systems feed the same channel.)
138
+ - **Cinematic camera + mode-swap cross-fade** — `camera.cinematic: { keyframes: [{ position, lookAt, fov?, duration?, ease? }], loop? }` plays a scripted keyframe path over the active rig, and `camera.transitionSeconds` cross-fades the camera when the rig changes so mode swaps no longer hard-cut.
139
+ - Pure rig math (shake decay/trauma, spring-arm damping, speed→FOV, lerp/cross-fade, offset/strafe vectors, keyframe sampling) is exported from `@jgengine/shell/camera` as testable functions.
140
+ - **Sensors, vision & observer tools** (`@jgengine/core/sensor/*`, renderers in `@jgengine/shell/vision` and `@jgengine/shell/replay`) — a coherent "query hidden/tagged/framed world state and surface it" family:
141
+ - **Reveal vision** (`sensor/revealQuery`) — `createRevealQuery` is an occlusion-ignoring tagged-entity radius query (`inRadius` already never checks occlusion; this scopes it to catalog-declared tags for a vision readout) paired with a toggleable screen-space reveal effect (`shell/vision/RevealVision`: `RevealHighlights` for through-wall 3D highlights, `RevealScreenTint` for the full-screen tint) — Dark Sight / detective-vision / wallhack-style highlight (Hunt: Showdown).
142
+ - **Hidden-state probe** (`sensor/hiddenStateProbe`) — `probeHiddenState`/`probeHiddenStateAll` read a hidden zone/entity state variable in range and surface a distance-weighted reading; `shell/vision/HiddenStateProbeHud`'s `SensorReadoutMeter` renders it as a handheld sensor needle (EMF reader/spirit box/thermometer/geiger, Phasmophobia).
143
+ - **View-frustum sensor** (`sensor/frustumSensor`) — `projectToView`/`framingScore`/`createFrustumSensor` answer "what's in this held camera's view, how well framed, and for how long" (dwell time resets the instant a subject leaves frame); `shell/vision/FrustumSensorHud`'s `FrustumSensorReadout` drives it off the live render camera for a photo-mode HUD (Content Warning).
144
+ - **Session-recording buffer** (`sensor/recordingBuffer`) — `createRecordingBuffer` appends timestamped snapshots on game-time and seeks/scrubs them for replay, photo mode, or kill-cam; `shell/replay/useSessionRecorder` records an entity's pose every frame.
145
+ - **`observer` camera rig** (config in `@jgengine/core/game/playableGame`: `ObserverCameraConfig`) — a detached spectator/photo cam bound to any entity or fixed point that reads no player input at all (van CCTV spectate, Forza-style photo mode free cam, Trackmania ghost/kill-cam).
146
+
147
+ - **Possession** (`@jgengine/core/scene/possession`, `createPossession`) — a player can own N scene entities and switch which one is under active control, distinct from the social party. `ctx.player.possession.own/disown/owns/listOwned(userId, entityId)` tracks ownership; `possess(userId, entityId)` swaps active control (rejecting entities the user doesn't own), flips the previous/next entity's `EntityRole` between `"player"`/`"npc"` (reusing entity control, not forking it), and emits `possession.swapped`. `active(userId)` defaults to `userId` itself until a swap happens. `@jgengine/shell`'s `GamePlayerShell` rebinds WASD movement, targeting, hotbar `from`, and the camera rig's `followEntityId` to the active possessed entity on every swap — no per-game camera glue required.
148
+ - **Form / shapeshift** (`@jgengine/core/scene/form`, `createForms`) — a `form` bundles movement params + an ability-id list + a mesh (reusing the entity's catalog name, so mesh, movement defaults, and receive/role all follow the swap through the existing name-keyed resolution — no parallel mesh system). `ctx.scene.entity.form.register(defs)` in `onInit`; `shapeshift(instanceId, formId, durationSeconds?)` applies the bundle and optionally reverts automatically after `durationSeconds` of **game time** (`ctx.time.after`, so it obeys pause/fast-forward); `revert(instanceId)` reverts early. Emits `form.changed`.
149
+ - **Cosmetic loadouts + emote broadcast** (`@jgengine/core/game/cosmetics` `createCosmetics`; `@jgengine/core/game/social` `Social.emotes`) — `ctx.player.cosmetics.register(defs)` + `apply(userId, loadoutId)` / `equip(userId, slot, cosmeticId)` manage a per-player cosmetic slot map (skin/back/aura/…), emitting `cosmetics.changed`. `ctx.game.social.emotes.play(fromUserId, emoteId, radius?)` broadcasts to nearby **player**-role entities (reusing `scene.entity.inRadius`, not a parallel proximity system) and emits `emote.played` — bind it through the existing `ctx.game.feed` primitive (`feed.bind("emote.played")`) for a HUD feed, no new hook needed.
150
+ - `entityStore`'s `update()` patch now also accepts `name`, so possession/form (and any future system) can retarget an instance's catalog id without despawn/respawn.
151
+
152
+ An additive layer over effects/projectiles/death that adds melee/action feel. Every model is a renderer-free `@jgengine/core` factory a game composes per entity; `@jgengine/shell` renders the world/HUD side. No existing API moved.
153
+
154
+ - **Animation state machine** — `@jgengine/core/combat/animationState`: `createAnimationState({ clips })` over `AnimationClip`s whose `FrameRange`s tag `windup | active | recovery | cancel` windows. The root contract combat/defense subscribe to (`inPhase`, `isActive`, `canCancel`, `activeWindowMs`).
155
+ - **Shared accumulator meter** — `@jgengine/core/stats/accumulatorMeter`: `createAccumulatorMeter({ max, mode, decayPerSecond, decayDelayMs, tiers })`, the fill/decay/threshold-fire/tier primitive. `@jgengine/core/combat/breakMeters` builds `createStaggerMeter` (poise/posture break → riposte) and `createBuildupMeter` (bleed/frost/rot proc) on it.
156
+ - **Defensive window + attack tags** — `@jgengine/core/combat/defensiveWindow` (`resolveDefense` / `createDefensiveWindow`, parry/block/dodge evaluated against the attacker's active frames) reading `@jgengine/core/combat/attackTags` (`attackMeta`, unblockable/thrust/sweep/grab; `counters` for Mikiri-style reads).
157
+ - **Combo strings** — `@jgengine/core/combat/comboString`: `advanceCombo` / `createComboRunner` — ordered attacks with stance-conditioned cancel points over the animation SM.
158
+ - **Dash / dodge** — `@jgengine/core/movement/dash`: `createDashState` — directional burst + i-frame window + stamina/cooldown.
159
+ - **Hit reaction, telegraphs, typed damage numbers** — `@jgengine/core/combat/hitReaction` + `ctx.scene.entity.hitReaction(...)` (knockback impulse + hitstop + `combat.hitReaction` shake channel); `@jgengine/core/combat/telegraph` + `ctx.scene.entity.telegraph(...)` (windup→activation ground decal bound to an effect, drawn by the shell); `ctx.scene.entity.floatText({ crit, element, hitType, scale })` styled by `@jgengine/shell/world/floatTextStyle`.
160
+
161
+ Genre systems over the existing effects/projectiles/targeting/loot primitives. Every model is a renderer-free `@jgengine/core` factory the game ticks on game-time `dt`; `@jgengine/react` adds four-state slot binding hooks. No existing API moved. The ult/streak meters build on the `stats/accumulatorMeter` from the combat-feel layer above.
162
+
163
+ - **Ability kit** — `@jgengine/core/combat/abilityKit`: `createAbilityKit([{ id, cooldownMs, chargesMax?, resourceCost?, castType? }])` models an ability slot **separate from an inventory item**, exposing the four HUD states `ready | cooldown | no-resource | just-cast` plus charges + cooldown fraction. Resource-agnostic — reports `no-resource` against a supplied `resourceAvailable`, the game spends. (MOBA/ARPG action bars, hero-shooter kits.)
164
+ - **Event-fed meters** — `@jgengine/core/stats/eventMeter`: `createEventMeter({ max, mode, gains, resets?, tiers? })` on the shared accumulator. Mode `"hold"` = ult/adrenaline charge (`feed` combat tags, `ready()`, `consume()`); mode `"reset"` = kill-streak/combo with tiered thresholds that resets on a break tag. (Overwatch/Marvel Rivals ult, Returnal/DMC streak.)
165
+ - **Auto-target policy** — `@jgengine/core/scene/autoTarget`: `selectAutoTarget` / `createAutoTargeter` — zero-input per-tick target selection `nearest | farthest | random | strongest | weakest | first | last` (path-progress aware). (Vampire Survivors auto-fire, Bloons tower priority.)
166
+ - **Resistance matrix** — `@jgengine/core/combat/resistance`: `resolveResistance` / `resistanceScale` — damage-category × target-property → `immune | resist | normal | vulnerable` multiplier over the `receive` gate. (Bloons pop-types, elemental RPG weaknesses.)
167
+ - **Run draft** — `@jgengine/core/game/runDraft`: `createRunDraft` / `createRunModifierStack` — pause, present N weighted picks (`pickWeighted`), choose, stack the modifiers for the run (aggregated onto `stats/statModifiers`). (Vampire Survivors level-ups, Hades boons.)
168
+ - **React** — `@jgengine/react` `useAbilitySlots` / `useAbilitySlot` (four-state snapshots) and `useEventMeter` (ult/streak bar view).
169
+
170
+ - `@jgengine/core/item/durability` — per-instance item durability + repair. A catalog `DurabilitySpec` (`max`, `wearPerUse`/`wearPerHit`, `disableAtZero`, `repair`); `createDurability`/`wear`/`isDisabled`/`durabilityFraction` for the wear loop, `repairQuote(spec, state, { station?, to? })` for a quote-then-apply repair (material cost scaled by points restored, optional `qualityLossPerRepair` shrinking `max`), and `createDurabilityTracker()` to hold state per instance id. For weapon/tool/armor degradation repaired at stations.
171
+ - `@jgengine/core/item/affix` — rarity-weighted procgen roller. `createAffixRoller({ pools, rarities })` turns `base × rarity` into `{ rolled affixes, computed stats, name }`: draws `affixCount` distinct affixes without replacement (weighted via the engine `pickWeighted`), computes stats (base × rarity scale, then `add` then `mul` affixes), and composes a name from rarity + prefix/suffix parts. `seededRng(seed)` gives deterministic drops. For looter-shooter / ARPG generated weapons.
172
+ - `@jgengine/core/item/modularItem` — parts-in-typed-slots assembly. `ModularItemDef` with category-constrained `MountSlotDef`s; `install`/`uninstall` validate slot + category + occupancy, `computeEffectiveStats` rolls part `stats` (additive) then `multipliers` over the frame's `baseStats`, `missingRequiredSlots`/`isComplete` gate a buildable whole; `createModularItem(def)` is the stateful wrapper. For piece-by-piece guns and mech loadouts.
173
+ - `@jgengine/core/inventory/storageTier` — tiered extraction-economy inventory. A `tier: "carried" | "banked"` on inventory containers (`InventoryDeclaration.tier`); `partitionOnDeath` splits a death snapshot into kept (banked) vs lost (carried) with merged stacks, `createDeliveryQueue()` is the delayed-delivery (insurance) hook (`schedule`/`due`/`claimDue` on the game clock), `insureLost` filters the lost set to insured items with a delayed `deliverAt`, and `resolveConsolation` yields a baseline loadout id (apply via `applyLoadout`) for the post-death consolation grant. The inventory foundation the extraction session/round machines build on.
174
+ - `InventoryDeclaration` (`@jgengine/core/game/defineGame`) gained an optional `tier?: StorageTier` flag so containers declare carried-vs-banked storage directly.
175
+ - `@jgengine/core/session/contestedChannel` — the interrupt-on-damage progress objective (plant/defuse, cash-out, urn deposit, hold-to-extract). `createContestedChannel({ duration, interruptOnDamage?, resetOnInterrupt?, favorability?, ratePerOccupant?, contested?, decayRate? })`: `start(team)`, `tick(dt, occupants)` (per-team occupancy → `start`/`tick`/`contested`/`paused`/`complete` events), `damage()` interrupts. `favorability`/`ratePerOccupant` scale the fill rate; `contested: "pause" | "decay"` handles a contesting team.
176
+ - `@jgengine/core/session/roundState` — the buy→live→end match machine. `createRoundState({ phases, teams, maxRounds?, winReward?, lossBonus? })`: `tick(dt)` runs phase timers and auto-advances rounds, `concludeRound(winner)` settles win + escalating loss-bonus economy (`lossBonusFor`), `onPhaseEnd(hook)` gates commerce/spawns, `match.end` fires at `maxRounds`.
177
+ - `@jgengine/core/combat/downed` — the alive→downed→dead revive chain. `createDownedState({ bleedoutSeconds, reviveSeconds?, reviveHealthFraction?, banner? })`: `down`/`tick` (bleedout → `died` + optional banner), `revive(id, dt)` accumulates ally hold time, `finish` executes, `respawnFromBanner` beacons back. Sits in front of engine death resolution.
178
+ - `@jgengine/core/session/ring` — the shrinking battle-royale safe zone. `RingConfig` = `{ center, phases }`; `ringSampleAt(config, t)`/`createRing` give the live `{ center, radius, damagePerSecond }` (interpolated on the game clock), `isOutside`/`distanceOutside`/`damageOutside(t, dt, positions)` for out-of-bounds DoT.
179
+ - `@jgengine/core/session/extraction` — the raid-scoped extract-to-bank session, composed from the contested channel + `inventory/storageTier`. `createRaidSession({ extracts, insurance?, consolation? })`: `beginExtract`/`tickExtract`/`damage` drive hold-to-leave, `resolveExtraction` banks everything carried, `resolveDeath` partitions/insures/consoles via storage tiers, `claimDeliveries(now)` drains insured returns.
180
+ - `@jgengine/core/runtime/persistenceScope` — run-vs-meta persistence split with reset boundaries. `partitionScopes`/`resetRun`/`mergeScopes` over flat records, `clearRunFields`/`applyRunReset` over player rows/profiles, `planScenarioReset(...)` normalizes a season/scenario wipe applied through the new optional `HostPersistence.resetScenario` — implemented by `@jgengine/sql` (deletes a server's chunks + session and run-resets each profile in one transaction, keeping account meta).
15
181
 
16
- _Nothing yet._
17
182
 
18
183
  ## 0.6.0
19
184
 
@@ -1,8 +1,11 @@
1
1
  import { type ReactNode } from "react";
2
2
  import type { InventorySlot } from "@jgengine/core/inventory/inventoryModel";
3
3
  import type { ProximityPrompt as ProximityPromptDef } from "@jgengine/core/interaction/proximityPrompt";
4
+ import { type SkillCheckConfig, type SkillCheckResult } from "@jgengine/core/interaction/skillCheck";
5
+ import { type QteStep } from "@jgengine/core/interaction/qte";
4
6
  import type { FeedEntry } from "@jgengine/core/game/feed";
5
7
  import type { StatLevelUpEvent } from "@jgengine/core/game/events";
8
+ import { type CheckAdvantage, type CheckResult } from "@jgengine/core/stats/rollCheck";
6
9
  export declare function SlotGrid({ inventoryId, className, renderSlot, }: {
7
10
  inventoryId: string;
8
11
  className?: string;
@@ -33,12 +36,27 @@ export declare function KeybindRow({ action, keys, className, }: {
33
36
  keys: readonly string[];
34
37
  className?: string;
35
38
  }): import("react").JSX.Element;
39
+ export interface DialogueCheck {
40
+ label?: string;
41
+ modifier: number;
42
+ dc: number;
43
+ advantage?: CheckAdvantage;
44
+ }
36
45
  export interface DialogueChoice {
37
46
  label: string;
38
47
  invoke: {
39
48
  command: string;
40
49
  args?: unknown;
41
50
  } | null;
51
+ check?: DialogueCheck;
52
+ onSuccess?: {
53
+ command: string;
54
+ args?: unknown;
55
+ } | null;
56
+ onFailure?: {
57
+ command: string;
58
+ args?: unknown;
59
+ } | null;
42
60
  }
43
61
  export type DialogueLine = {
44
62
  speaker: string;
@@ -50,10 +68,42 @@ export interface DialogueDef {
50
68
  id: string;
51
69
  lines: readonly DialogueLine[];
52
70
  }
53
- export declare function DialogueBox({ dialogue, onChoice, className, }: {
71
+ export declare function resolveDialogueInvoke(choice: DialogueChoice, result: CheckResult | null): {
72
+ command: string;
73
+ args?: unknown;
74
+ } | null;
75
+ export declare function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }: {
54
76
  dialogue: DialogueDef;
55
- onChoice?: (choice: DialogueChoice) => void;
77
+ onChoice?: (choice: DialogueChoice, result: CheckResult | null) => void;
78
+ rng?: () => number;
56
79
  className?: string;
80
+ lineClassName?: string;
81
+ speakerClassName?: string;
82
+ choicesClassName?: string;
83
+ choiceClassName?: string;
84
+ checkClassName?: string;
85
+ }): import("react").JSX.Element;
86
+ export declare function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }: {
87
+ config: SkillCheckConfig;
88
+ startedAt: number;
89
+ className?: string;
90
+ trackClassName?: string;
91
+ zoneClassName?: string;
92
+ markerClassName?: string;
93
+ renderStatus?: (result: SkillCheckResult) => ReactNode;
94
+ }): import("react").JSX.Element;
95
+ export declare function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }: {
96
+ steps: readonly QteStep[];
97
+ startedAt: number;
98
+ className?: string;
99
+ stepClassName?: string;
100
+ activeClassName?: string;
101
+ doneClassName?: string;
102
+ }): import("react").JSX.Element;
103
+ export declare function CaptureOdds({ chance, className, fillClassName, }: {
104
+ chance: number;
105
+ className?: string;
106
+ fillClassName?: string;
57
107
  }): import("react").JSX.Element;
58
108
  export declare function ToastStack({ action, limit, className, renderToast, }: {
59
109
  action: string;
@@ -1,5 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useEffect, useRef, useState } from "react";
3
+ import { evaluateSkillCheck, } from "@jgengine/core/interaction/skillCheck";
4
+ import { pendingQteStep } from "@jgengine/core/interaction/qte";
5
+ import { rollCheck } from "@jgengine/core/stats/rollCheck";
3
6
  import { useGameContext } from "./provider.js";
4
7
  import { useCurrency, useEntityStat, useFeed, useInventory, useLocalPlayerDead } from "./hooks.js";
5
8
  export function SlotGrid({ inventoryId, className, renderSlot, }) {
@@ -40,8 +43,61 @@ export function Screen({ id, open = true, className, children, }) {
40
43
  export function KeybindRow({ action, keys, className, }) {
41
44
  return (_jsxs("div", { className: className, "data-action": action, children: [_jsx("span", { children: action }), keys.map((key) => (_jsx("kbd", { children: key }, key)))] }));
42
45
  }
43
- export function DialogueBox({ dialogue, onChoice, className, }) {
44
- return (_jsx("div", { className: className, "data-dialogue": dialogue.id, children: dialogue.lines.map((line, index) => "choices" in line ? (_jsx("div", { "data-choices": true, children: line.choices.map((choice) => (_jsx("button", { type: "button", onClick: () => onChoice?.(choice), children: choice.label }, choice.label))) }, index)) : (_jsxs("p", { children: [_jsx("span", { "data-speaker": true, children: line.speaker }), _jsx("span", { children: line.text })] }, index))) }));
46
+ export function resolveDialogueInvoke(choice, result) {
47
+ if (result === null)
48
+ return choice.invoke;
49
+ return result.success ? (choice.onSuccess ?? choice.invoke) : (choice.onFailure ?? choice.invoke);
50
+ }
51
+ export function DialogueBox({ dialogue, onChoice, rng, className, lineClassName, speakerClassName, choicesClassName, choiceClassName, checkClassName, }) {
52
+ return (_jsx("div", { className: className, "data-dialogue": dialogue.id, children: dialogue.lines.map((line, index) => "choices" in line ? (_jsx("div", { className: choicesClassName, "data-choices": true, children: line.choices.map((choice) => (_jsxs("button", { type: "button", className: choiceClassName, "data-dc": choice.check?.dc, onClick: () => onChoice?.(choice, choice.check === undefined ? null : rollCheck(choice.check, rng)), children: [_jsx("span", { children: choice.label }), choice.check !== undefined && (_jsxs("span", { className: checkClassName, "data-check": true, children: [choice.check.label ?? "Check", " DC ", choice.check.dc, choice.check.advantage !== undefined && choice.check.advantage !== "normal"
53
+ ? ` (${choice.check.advantage})`
54
+ : ""] }))] }, choice.label))) }, index)) : (_jsxs("p", { className: lineClassName, children: [_jsx("span", { className: speakerClassName, "data-speaker": true, children: line.speaker }), _jsx("span", { children: line.text })] }, index))) }));
55
+ }
56
+ export function SkillCheckBar({ config, startedAt, className, trackClassName, zoneClassName, markerClassName, renderStatus, }) {
57
+ const ctx = useGameContext();
58
+ const [, tick] = useState(0);
59
+ useEffect(() => {
60
+ let frame;
61
+ const step = () => {
62
+ tick((n) => n + 1);
63
+ frame = requestAnimationFrame(step);
64
+ };
65
+ frame = requestAnimationFrame(step);
66
+ return () => cancelAnimationFrame(frame);
67
+ }, []);
68
+ const elapsed = Math.max(0, ctx.time.now() - startedAt);
69
+ const result = evaluateSkillCheck(config, elapsed);
70
+ const zoneLeft = (result.zone.start / config.trackWidth) * 100;
71
+ const zoneWidth = ((result.zone.end - result.zone.start) / config.trackWidth) * 100;
72
+ const markerLeft = (result.markerPosition / config.trackWidth) * 100;
73
+ return (_jsxs("div", { className: className, "data-skill-check": true, "data-in-zone": result.success, "data-timed-out": result.timedOut, children: [_jsxs("div", { className: trackClassName, "data-track": true, style: { position: "relative" }, children: [_jsx("div", { className: zoneClassName, "data-zone": true, style: { position: "absolute", left: `${zoneLeft}%`, width: `${zoneWidth}%`, height: "100%" } }), _jsx("div", { className: markerClassName, "data-marker": true, style: { position: "absolute", left: `${markerLeft}%`, height: "100%" } })] }), renderStatus?.(result)] }));
74
+ }
75
+ export function QteTrack({ steps, startedAt, className, stepClassName, activeClassName, doneClassName, }) {
76
+ const ctx = useGameContext();
77
+ const [, tick] = useState(0);
78
+ useEffect(() => {
79
+ let frame;
80
+ const step = () => {
81
+ tick((n) => n + 1);
82
+ frame = requestAnimationFrame(step);
83
+ };
84
+ frame = requestAnimationFrame(step);
85
+ return () => cancelAnimationFrame(frame);
86
+ }, []);
87
+ const elapsed = Math.max(0, ctx.time.now() - startedAt);
88
+ const active = pendingQteStep(steps, elapsed);
89
+ return (_jsx("div", { className: className, "data-qte": true, children: steps.map((step) => {
90
+ const isActive = active?.id === step.id;
91
+ const isDone = elapsed > step.windowEnd;
92
+ const classes = [stepClassName, isActive ? activeClassName : isDone ? doneClassName : undefined]
93
+ .filter(Boolean)
94
+ .join(" ");
95
+ return (_jsx("div", { className: classes.length > 0 ? classes : undefined, "data-qte-step": step.id, "data-active": isActive, "data-done": isDone, children: step.action }, step.id));
96
+ }) }));
97
+ }
98
+ export function CaptureOdds({ chance, className, fillClassName, }) {
99
+ const percent = Math.round(Math.min(1, Math.max(0, chance)) * 100);
100
+ return (_jsxs("div", { className: className, role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": percent, "data-capture-odds": percent, children: [_jsx("div", { className: fillClassName, style: { width: `${percent}%`, height: "100%" } }), _jsxs("span", { "data-capture-odds-label": true, style: { position: "relative", zIndex: 1 }, children: [percent, "%"] })] }));
45
101
  }
46
102
  function defaultToast(entry) {
47
103
  const data = entry.data;
@@ -0,0 +1,67 @@
1
+ import { type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
2
+ import { type Cell, type Rotation } from "@jgengine/core/inventory/shapedGrid";
3
+ export interface DragPayload<T> {
4
+ id: string;
5
+ value: T;
6
+ rotation: Rotation;
7
+ }
8
+ export interface DragState<T> {
9
+ payload: DragPayload<T> | null;
10
+ point: {
11
+ x: number;
12
+ y: number;
13
+ };
14
+ origin: {
15
+ x: number;
16
+ y: number;
17
+ };
18
+ overTarget: string | null;
19
+ overCell: Cell | null;
20
+ }
21
+ export interface DropInfo<T> {
22
+ payload: DragPayload<T>;
23
+ target: string | null;
24
+ cell: Cell | null;
25
+ point: {
26
+ x: number;
27
+ y: number;
28
+ };
29
+ }
30
+ export interface DragLayer<T> {
31
+ state: DragState<T>;
32
+ dragging: boolean;
33
+ beginDrag(payload: {
34
+ id: string;
35
+ value: T;
36
+ rotation?: Rotation;
37
+ }, event: ReactPointerEvent): void;
38
+ rotate(quarterTurns?: number): void;
39
+ setTarget(target: string | null, cell?: Cell | null): void;
40
+ endDrag(): DropInfo<T> | null;
41
+ cancel(): void;
42
+ }
43
+ export declare function useDragLayer<T>(options?: {
44
+ onDrop?: (info: DropInfo<T>) => void;
45
+ }): DragLayer<T>;
46
+ export declare function DragGhost<T>({ layer, className, style, children, }: {
47
+ layer: DragLayer<T>;
48
+ className?: string;
49
+ style?: CSSProperties;
50
+ children?: (payload: DragPayload<T>) => ReactNode;
51
+ }): import("react").JSX.Element | null;
52
+ export declare function DraggableCard<T>({ id, value, layer, className, children, onRotate, }: {
53
+ id: string;
54
+ value: T;
55
+ layer: DragLayer<T>;
56
+ className?: string;
57
+ children?: ReactNode;
58
+ onRotate?: boolean;
59
+ }): import("react").JSX.Element;
60
+ export declare function DropZone<T>({ id, layer, className, activeClassName, cellSize, children, }: {
61
+ id: string;
62
+ layer: DragLayer<T>;
63
+ className?: string;
64
+ activeClassName?: string;
65
+ cellSize?: number;
66
+ children?: ReactNode;
67
+ }): import("react").JSX.Element;
@@ -0,0 +1,143 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useMemo, useRef, useState, } from "react";
3
+ import { cellFromPoint, } from "@jgengine/core/inventory/shapedGrid";
4
+ const EMPTY_POINT = { x: 0, y: 0 };
5
+ export function useDragLayer(options) {
6
+ const [state, setState] = useState({
7
+ payload: null,
8
+ point: EMPTY_POINT,
9
+ origin: EMPTY_POINT,
10
+ overTarget: null,
11
+ overCell: null,
12
+ });
13
+ const moveRef = useRef(null);
14
+ const detach = useCallback(() => {
15
+ if (moveRef.current !== null) {
16
+ window.removeEventListener("pointermove", moveRef.current);
17
+ moveRef.current = null;
18
+ }
19
+ }, []);
20
+ const beginDrag = useCallback((payload, event) => {
21
+ const point = { x: event.clientX, y: event.clientY };
22
+ setState({
23
+ payload: { id: payload.id, value: payload.value, rotation: payload.rotation ?? 0 },
24
+ point,
25
+ origin: point,
26
+ overTarget: null,
27
+ overCell: null,
28
+ });
29
+ const onMove = (moveEvent) => {
30
+ setState((prev) => prev.payload === null
31
+ ? prev
32
+ : { ...prev, point: { x: moveEvent.clientX, y: moveEvent.clientY } });
33
+ };
34
+ detach();
35
+ moveRef.current = onMove;
36
+ window.addEventListener("pointermove", onMove);
37
+ }, [detach]);
38
+ const rotate = useCallback((quarterTurns = 1) => {
39
+ setState((prev) => prev.payload === null
40
+ ? prev
41
+ : {
42
+ ...prev,
43
+ payload: {
44
+ ...prev.payload,
45
+ rotation: (((prev.payload.rotation + quarterTurns) % 4) + 4) % 4,
46
+ },
47
+ });
48
+ }, []);
49
+ const setTarget = useCallback((target, cell = null) => {
50
+ setState((prev) => ({ ...prev, overTarget: target, overCell: cell }));
51
+ }, []);
52
+ const reset = useCallback(() => {
53
+ detach();
54
+ setState({
55
+ payload: null,
56
+ point: EMPTY_POINT,
57
+ origin: EMPTY_POINT,
58
+ overTarget: null,
59
+ overCell: null,
60
+ });
61
+ }, [detach]);
62
+ const endDrag = useCallback(() => {
63
+ let info = null;
64
+ setState((prev) => {
65
+ if (prev.payload !== null) {
66
+ info = {
67
+ payload: prev.payload,
68
+ target: prev.overTarget,
69
+ cell: prev.overCell,
70
+ point: prev.point,
71
+ };
72
+ }
73
+ return prev;
74
+ });
75
+ if (info !== null)
76
+ options?.onDrop?.(info);
77
+ reset();
78
+ return info;
79
+ }, [options, reset]);
80
+ return {
81
+ state,
82
+ dragging: state.payload !== null,
83
+ beginDrag,
84
+ rotate,
85
+ setTarget,
86
+ endDrag,
87
+ cancel: reset,
88
+ };
89
+ }
90
+ export function DragGhost({ layer, className, style, children, }) {
91
+ if (layer.state.payload === null)
92
+ return null;
93
+ const { x, y } = layer.state.point;
94
+ return (_jsx("div", { className: className, "data-drag-ghost": "", style: {
95
+ position: "fixed",
96
+ left: x,
97
+ top: y,
98
+ transform: `translate(-50%, -50%) rotate(${layer.state.payload.rotation * 90}deg)`,
99
+ pointerEvents: "none",
100
+ zIndex: 1000,
101
+ ...style,
102
+ }, children: children?.(layer.state.payload) }));
103
+ }
104
+ export function DraggableCard({ id, value, layer, className, children, onRotate, }) {
105
+ const isDragging = layer.state.payload?.id === id;
106
+ return (_jsx("div", { className: className, "data-card": id, "data-dragging": isDragging ? "" : undefined, onPointerDown: (event) => {
107
+ event.preventDefault();
108
+ layer.beginDrag({ id, value }, event);
109
+ }, onPointerUp: () => {
110
+ if (layer.dragging)
111
+ layer.endDrag();
112
+ }, onContextMenu: (event) => {
113
+ if (onRotate === false)
114
+ return;
115
+ event.preventDefault();
116
+ if (layer.dragging)
117
+ layer.rotate(1);
118
+ }, children: children }));
119
+ }
120
+ export function DropZone({ id, layer, className, activeClassName, cellSize, children, }) {
121
+ const ref = useRef(null);
122
+ const active = layer.state.overTarget === id && layer.dragging;
123
+ const resolveCell = useCallback((event) => {
124
+ if (cellSize === undefined || ref.current === null)
125
+ return null;
126
+ const rect = ref.current.getBoundingClientRect();
127
+ return cellFromPoint({ x: event.clientX - rect.left, y: event.clientY - rect.top }, cellSize);
128
+ }, [cellSize]);
129
+ const composed = useMemo(() => [className, active ? activeClassName : undefined].filter(Boolean).join(" ") || undefined, [className, active, activeClassName]);
130
+ return (_jsx("div", { ref: ref, className: composed, "data-dropzone": id, "data-active": active ? "" : undefined, onPointerEnter: (event) => {
131
+ if (layer.dragging)
132
+ layer.setTarget(id, resolveCell(event));
133
+ }, onPointerMove: (event) => {
134
+ if (layer.dragging && layer.state.overTarget === id)
135
+ layer.setTarget(id, resolveCell(event));
136
+ }, onPointerLeave: () => {
137
+ if (layer.dragging && layer.state.overTarget === id)
138
+ layer.setTarget(null, null);
139
+ }, onPointerUp: () => {
140
+ if (layer.dragging)
141
+ layer.endDrag();
142
+ }, children: children }));
143
+ }
package/dist/hooks.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
2
+ import type { AbilityKit, AbilitySlotSnapshot } from "@jgengine/core/combat/abilityKit";
3
+ import type { EventMeter } from "@jgengine/core/stats/eventMeter";
2
4
  import type { GameEvents } from "@jgengine/core/game/events";
3
5
  import type { FeedEntry } from "@jgengine/core/game/feed";
4
6
  import type { QuestInstance } from "@jgengine/core/game/quest";
@@ -8,6 +10,8 @@ import type { InventorySlot } from "@jgengine/core/inventory/inventoryModel";
8
10
  import type { StatValue } from "@jgengine/core/scene/entityStats";
9
11
  import type { SceneEntity } from "@jgengine/core/scene/entityStore";
10
12
  import type { SceneObject } from "@jgengine/core/scene/objectStore";
13
+ import type { RosterEntry } from "@jgengine/core/scene/roster";
14
+ import type { WorldItemRecord } from "@jgengine/core/game/worldItem";
11
15
  import type { ClockSnapshot, SimClock } from "@jgengine/core/time/simClock";
12
16
  import { type PositionedPrompt } from "@jgengine/core/interaction/proximityPrompt";
13
17
  export declare function useGameStore<T>(selector: (ctx: GameContext) => T): T;
@@ -21,6 +25,9 @@ export declare function usePlayer(): {
21
25
  };
22
26
  export declare function useSceneEntities(): readonly SceneEntity[];
23
27
  export declare function useSceneObjects(): readonly SceneObject[];
28
+ export declare function useWorldItems(): readonly WorldItemRecord[];
29
+ /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
30
+ export declare function useNearestWorldItem(radius: number): WorldItemRecord | null;
24
31
  export declare function useEntityStat(instanceId: string, statId: string): StatValue | null;
25
32
  export declare function useTarget(fromInstanceId: string): string | null;
26
33
  export declare function useInventory(inventoryId: string): readonly InventorySlot[];
@@ -33,6 +40,7 @@ export declare function useQuestJournal(): QuestInstance[];
33
40
  export declare function useFriends(): FriendEntry[];
34
41
  export declare function useParty(): PartyMemberEntry[];
35
42
  export declare function usePresence(userId: string): PresenceInfo;
43
+ export declare function useRoster(userId?: string): readonly RosterEntry[];
36
44
  export declare function useLeaderboard(stat: string, options: {
37
45
  scope: LeaderboardScope;
38
46
  limit?: number;
@@ -46,3 +54,15 @@ export declare function useGameClock(): ClockSnapshot & {
46
54
  controls: SimClock;
47
55
  };
48
56
  export declare function useActivePrompt<T extends PositionedPrompt>(prompts?: readonly T[]): T | null;
57
+ export interface AbilitySlotBindingOptions {
58
+ intervalMs?: number;
59
+ }
60
+ export declare function useAbilitySlots(kit: AbilityKit, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot[];
61
+ export declare function useAbilitySlot(kit: AbilityKit, slotId: string, resourceAvailable?: number, options?: AbilitySlotBindingOptions): AbilitySlotSnapshot | null;
62
+ export interface EventMeterView {
63
+ value: number;
64
+ fraction: number;
65
+ tier: string | null;
66
+ ready: boolean;
67
+ }
68
+ export declare function useEventMeter(meter: EventMeter, options?: AbilitySlotBindingOptions): EventMeterView;
package/dist/hooks.js CHANGED
@@ -1,4 +1,4 @@
1
- import { useMemo, useSyncExternalStore } from "react";
1
+ import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
2
2
  import { resolveActivePrompt, } from "@jgengine/core/interaction/proximityPrompt";
3
3
  import { useGameContext } from "./provider.js";
4
4
  export function useGameStore(selector) {
@@ -20,6 +20,19 @@ export function useSceneEntities() {
20
20
  export function useSceneObjects() {
21
21
  return useGameStore((ctx) => ctx.scene.object.list());
22
22
  }
23
+ export function useWorldItems() {
24
+ return useGameStore((ctx) => ctx.scene.worldItem.list());
25
+ }
26
+ /** Nearest ground item within `radius` of the local player — drives a pickup prompt/highlight. */
27
+ export function useNearestWorldItem(radius) {
28
+ return useGameStore((ctx) => {
29
+ const player = localPlayerEntity(ctx);
30
+ if (player === null)
31
+ return null;
32
+ const instanceId = ctx.scene.worldItem.nearestInRadius(player.position, radius);
33
+ return instanceId === null ? null : ctx.scene.worldItem.get(instanceId);
34
+ });
35
+ }
23
36
  export function useEntityStat(instanceId, statId) {
24
37
  return useGameStore((ctx) => ctx.scene.entity.stats.get(instanceId, statId));
25
38
  }
@@ -47,6 +60,9 @@ export function useParty() {
47
60
  export function usePresence(userId) {
48
61
  return useGameStore((ctx) => ctx.game.social.presence.get(userId));
49
62
  }
63
+ export function useRoster(userId) {
64
+ return useGameStore((ctx) => ctx.game.roster.list(userId ?? ctx.player.userId));
65
+ }
50
66
  export function useLeaderboard(stat, options) {
51
67
  return useGameStore((ctx) => ctx.game.leaderboard.getTop(stat, options));
52
68
  }
@@ -79,3 +95,26 @@ export function useActivePrompt(prompts) {
79
95
  return resolveActivePrompt({ x: player.position[0], z: player.position[2] }, prompts);
80
96
  });
81
97
  }
98
+ function useEngineHeartbeat(intervalMs) {
99
+ const ctx = useGameContext();
100
+ useSyncExternalStore(ctx.subscribe, ctx.version, ctx.version);
101
+ const [, setTick] = useState(0);
102
+ useEffect(() => {
103
+ if (intervalMs <= 0 || typeof window === "undefined")
104
+ return undefined;
105
+ const id = window.setInterval(() => setTick((current) => current + 1), intervalMs);
106
+ return () => window.clearInterval(id);
107
+ }, [intervalMs]);
108
+ }
109
+ export function useAbilitySlots(kit, resourceAvailable, options) {
110
+ useEngineHeartbeat(options?.intervalMs ?? 80);
111
+ return kit.snapshot(resourceAvailable);
112
+ }
113
+ export function useAbilitySlot(kit, slotId, resourceAvailable, options) {
114
+ useEngineHeartbeat(options?.intervalMs ?? 80);
115
+ return kit.state(slotId, resourceAvailable);
116
+ }
117
+ export function useEventMeter(meter, options) {
118
+ useEngineHeartbeat(options?.intervalMs ?? 80);
119
+ return { value: meter.value(), fraction: meter.fraction(), tier: meter.tier(), ready: meter.ready() };
120
+ }
package/dist/index.d.ts CHANGED
@@ -2,3 +2,5 @@ export * from "./provider.js";
2
2
  export * from "./hooks.js";
3
3
  export * from "./components.js";
4
4
  export * from "./engineStore.js";
5
+ export * from "./dragLayer.js";
6
+ export * from "./map.js";
package/dist/index.js CHANGED
@@ -2,3 +2,5 @@ export * from "./provider.js";
2
2
  export * from "./hooks.js";
3
3
  export * from "./components.js";
4
4
  export * from "./engineStore.js";
5
+ export * from "./dragLayer.js";
6
+ export * from "./map.js";
package/dist/map.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import { type ReactNode } from "react";
2
+ import type { FogField } from "@jgengine/core/world/fog";
3
+ import { type MapMarker, type MarkerKindStyle, type MarkerSet } from "@jgengine/core/world/markers";
4
+ import { type WorldXZ } from "@jgengine/core/world/minimap";
5
+ export declare function useMarkers(markers: MarkerSet): readonly MapMarker[];
6
+ export declare function useFog(fog: FogField): ReturnType<FogField["cells"]>;
7
+ export interface MinimapProps {
8
+ markers: MarkerSet;
9
+ center: WorldXZ;
10
+ worldRadius: number;
11
+ fog?: FogField;
12
+ size?: number;
13
+ heading?: number;
14
+ rotate?: boolean;
15
+ kindStyles?: Record<string, MarkerKindStyle>;
16
+ background?: string;
17
+ /** World bounds the `background` image spans, so it pans correctly under a player-centered map. */
18
+ mapBounds?: MapBounds;
19
+ className?: string;
20
+ title?: string;
21
+ children?: ReactNode;
22
+ }
23
+ export interface MapBounds {
24
+ minX: number;
25
+ minZ: number;
26
+ maxX: number;
27
+ maxZ: number;
28
+ }
29
+ /**
30
+ * Framed circular minimap: optional baked terrain background, reveal-on-event
31
+ * fog overlay, categorized marker icons, and a facing arrow. Reads a core
32
+ * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
33
+ */
34
+ export declare function Minimap({ markers, center, worldRadius, fog, size, heading, rotate, kindStyles, background, mapBounds, className, title, children, }: MinimapProps): ReactNode;
35
+ export interface CompassProps {
36
+ heading: number;
37
+ center?: WorldXZ;
38
+ markers?: MarkerSet;
39
+ width?: number;
40
+ fov?: number;
41
+ kindStyles?: Record<string, MarkerKindStyle>;
42
+ className?: string;
43
+ }
44
+ /**
45
+ * Horizontal compass strip centered on the player's facing bearing, with the
46
+ * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
47
+ */
48
+ export declare function Compass({ heading, center, markers, width, fov, kindStyles, className, }: CompassProps): ReactNode;
49
+ export interface WorldMapProps {
50
+ markers: MarkerSet;
51
+ bounds: MapBounds;
52
+ player?: WorldXZ;
53
+ heading?: number;
54
+ fog?: FogField;
55
+ background?: string;
56
+ width?: number;
57
+ height?: number;
58
+ kindStyles?: Record<string, MarkerKindStyle>;
59
+ className?: string;
60
+ title?: string;
61
+ onClose?: () => void;
62
+ }
63
+ /**
64
+ * Full-bounds top-down world map (the "press M" overlay): baked terrain
65
+ * background, reveal-on-event fog, all markers with labels, and the player.
66
+ * Rectangular linear projection over the supplied world `bounds`.
67
+ */
68
+ export declare function WorldMap({ markers, bounds, player, heading, fog, background, width, height, kindStyles, className, title, onClose, }: WorldMapProps): ReactNode;
package/dist/map.js ADDED
@@ -0,0 +1,210 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useSyncExternalStore } from "react";
3
+ import { DEFAULT_MARKER_KINDS, markerKindStyle, } from "@jgengine/core/world/markers";
4
+ import { bearingToCardinal, clampToMinimapEdge, compassBearing, projectToMinimap, relativeBearing, } from "@jgengine/core/world/minimap";
5
+ export function useMarkers(markers) {
6
+ return useSyncExternalStore(markers.subscribe, markers.snapshot, markers.snapshot);
7
+ }
8
+ export function useFog(fog) {
9
+ return useSyncExternalStore(fog.subscribe, fog.cells, fog.cells);
10
+ }
11
+ /**
12
+ * Framed circular minimap: optional baked terrain background, reveal-on-event
13
+ * fog overlay, categorized marker icons, and a facing arrow. Reads a core
14
+ * `MarkerSet` / `FogField`; supply your own `kindStyles` palette to reskin.
15
+ */
16
+ export function Minimap({ markers, center, worldRadius, fog, size = 176, heading = 0, rotate = false, kindStyles = DEFAULT_MARKER_KINDS, background, mapBounds, className, title = "Map", children, }) {
17
+ const markerList = useMarkers(markers);
18
+ const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
19
+ const view = {
20
+ center,
21
+ worldRadius,
22
+ size,
23
+ ...(rotate ? { rotate: -heading } : {}),
24
+ };
25
+ const half = size / 2;
26
+ const clipId = `mm-clip-${size}`;
27
+ const sorted = [...markerList].sort((a, b) => markerKindStyle(a.kind, kindStyles).priority - markerKindStyle(b.kind, kindStyles).priority);
28
+ const fogRects = [];
29
+ if (fogCells !== null) {
30
+ const cellPx = (fogCells.cellSize / worldRadius) * half;
31
+ for (let row = 0; row < fogCells.rows; row += 1) {
32
+ for (let col = 0; col < fogCells.cols; col += 1) {
33
+ if (fogCells.revealed[row * fogCells.cols + col])
34
+ continue;
35
+ const worldX = fogCells.minX + (col + 0.5) * fogCells.cellSize;
36
+ const worldZ = fogCells.minZ + (row + 0.5) * fogCells.cellSize;
37
+ const projected = projectToMinimap([worldX, worldZ], view);
38
+ if (projected.distance > worldRadius + fogCells.cellSize)
39
+ continue;
40
+ fogRects.push(_jsx("rect", { x: projected.x - cellPx / 2 - 0.5, y: projected.y - cellPx / 2 - 0.5, width: cellPx + 1, height: cellPx + 1, fill: "#0b0f14", opacity: 0.82 }, `fog-${col}-${row}`));
41
+ }
42
+ }
43
+ }
44
+ return (_jsxs("div", { className: className, "data-minimap": true, style: {
45
+ width: size,
46
+ borderRadius: 14,
47
+ padding: 8,
48
+ background: "linear-gradient(160deg, rgba(24,28,36,0.94), rgba(12,15,20,0.94))",
49
+ border: "1px solid rgba(148,163,184,0.28)",
50
+ boxShadow: "0 10px 30px rgba(0,0,0,0.45)",
51
+ color: "#e2e8f0",
52
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
53
+ }, children: [_jsxs("div", { style: {
54
+ display: "flex",
55
+ justifyContent: "space-between",
56
+ alignItems: "center",
57
+ marginBottom: 6,
58
+ fontSize: 10,
59
+ letterSpacing: 1.4,
60
+ textTransform: "uppercase",
61
+ color: "rgba(203,213,225,0.75)",
62
+ }, children: [_jsx("span", { children: title }), _jsx("span", { style: { color: "rgba(148,163,184,0.6)" }, children: bearingToCardinal(heading) })] }), _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "data-minimap-canvas": true, children: [_jsx("defs", { children: _jsx("clipPath", { id: clipId, children: _jsx("circle", { cx: half, cy: half, r: half - 1 }) }) }), _jsxs("g", { clipPath: `url(#${clipId})`, children: [_jsx("circle", { cx: half, cy: half, r: half - 1, fill: "#1c2733" }), background !== undefined
63
+ ? (() => {
64
+ if (mapBounds === undefined) {
65
+ return (_jsx("image", { href: background, x: 0, y: 0, width: size, height: size, preserveAspectRatio: "xMidYMid slice", opacity: 0.9 }));
66
+ }
67
+ const pxPerWorld = half / worldRadius;
68
+ return (_jsx("image", { href: background, x: half + (mapBounds.minX - center[0]) * pxPerWorld, y: half + (mapBounds.minZ - center[1]) * pxPerWorld, width: (mapBounds.maxX - mapBounds.minX) * pxPerWorld, height: (mapBounds.maxZ - mapBounds.minZ) * pxPerWorld, preserveAspectRatio: "none", opacity: 0.92 }));
69
+ })()
70
+ : null, fogRects, _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.12)" }), sorted.map((marker) => {
71
+ const style = markerKindStyle(marker.kind, kindStyles);
72
+ const projected = projectToMinimap(marker.position, view);
73
+ const at = projected.inside ? projected : clampToMinimapEdge(projected, size);
74
+ return (_jsxs("g", { "data-marker-kind": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 7, fill: "rgba(2,6,12,0.55)" }), _jsx("text", { x: at.x, y: at.y + 3.5, textAnchor: "middle", fontSize: 10, fill: style.color, style: { fontWeight: 700 }, children: style.glyph })] }, marker.id));
75
+ }), _jsx("g", { transform: `translate(${half} ${half}) rotate(${(rotate ? 0 : heading) * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-9 L6,7 L0,3 L-6,7 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 0.75 }) })] }), _jsx("circle", { cx: half, cy: half, r: half - 1, fill: "none", stroke: "rgba(148,163,184,0.4)", strokeWidth: 1.5 }), _jsx("text", { x: half, y: 13, textAnchor: "middle", fontSize: 11, fill: "rgba(226,232,240,0.9)", style: { fontWeight: 700 }, children: "N" })] }), children] }));
76
+ }
77
+ const NO_SUBSCRIBE = () => () => undefined;
78
+ const NULL_CELLS = () => null;
79
+ const EMPTY_MARKERS_VALUE = [];
80
+ const EMPTY_MARKERS = () => EMPTY_MARKERS_VALUE;
81
+ const COMPASS_TICKS = [
82
+ { cardinal: "N", bearing: 0 },
83
+ { cardinal: "NE", bearing: Math.PI / 4 },
84
+ { cardinal: "E", bearing: Math.PI / 2 },
85
+ { cardinal: "SE", bearing: (3 * Math.PI) / 4 },
86
+ { cardinal: "S", bearing: Math.PI },
87
+ { cardinal: "SW", bearing: (5 * Math.PI) / 4 },
88
+ { cardinal: "W", bearing: (3 * Math.PI) / 2 },
89
+ { cardinal: "NW", bearing: (7 * Math.PI) / 4 },
90
+ ];
91
+ /**
92
+ * Horizontal compass strip centered on the player's facing bearing, with the
93
+ * eight cardinals and optional marker pips (bearing to each `MarkerSet` entry).
94
+ */
95
+ export function Compass({ heading, center, markers, width = 340, fov = (Math.PI * 2) / 3, kindStyles = DEFAULT_MARKER_KINDS, className, }) {
96
+ const markerList = useSyncExternalStore(markers?.subscribe ?? NO_SUBSCRIBE, markers?.snapshot ?? EMPTY_MARKERS, markers?.snapshot ?? EMPTY_MARKERS);
97
+ const half = fov / 2;
98
+ const toX = (bearing) => {
99
+ const delta = relativeBearing(bearing, heading);
100
+ if (Math.abs(delta) > half)
101
+ return null;
102
+ return width / 2 + (delta / half) * (width / 2);
103
+ };
104
+ return (_jsxs("div", { className: className, "data-compass": true, style: {
105
+ width,
106
+ height: 34,
107
+ position: "relative",
108
+ overflow: "hidden",
109
+ borderRadius: 8,
110
+ border: "1px solid rgba(148,163,184,0.28)",
111
+ background: "linear-gradient(180deg, rgba(18,22,30,0.92), rgba(10,13,18,0.92))",
112
+ color: "#e2e8f0",
113
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
114
+ }, children: [_jsx("div", { style: {
115
+ position: "absolute",
116
+ left: "50%",
117
+ top: 0,
118
+ bottom: 0,
119
+ width: 1,
120
+ background: "rgba(74,222,128,0.85)",
121
+ } }), COMPASS_TICKS.map((tick) => {
122
+ const x = toX(tick.bearing);
123
+ if (x === null)
124
+ return null;
125
+ const major = tick.cardinal.length === 1;
126
+ return (_jsx("span", { style: {
127
+ position: "absolute",
128
+ left: x,
129
+ top: major ? 7 : 11,
130
+ transform: "translateX(-50%)",
131
+ fontSize: major ? 13 : 9,
132
+ fontWeight: major ? 700 : 500,
133
+ color: major ? "#f8fafc" : "rgba(148,163,184,0.75)",
134
+ }, children: tick.cardinal }, tick.cardinal));
135
+ }), center !== undefined
136
+ ? markerList.map((marker) => {
137
+ const bearing = compassBearing(center, [marker.position[0], marker.position[2]]);
138
+ const x = toX(bearing);
139
+ if (x === null)
140
+ return null;
141
+ const style = markerKindStyle(marker.kind, kindStyles);
142
+ return (_jsx("span", { "data-compass-marker": marker.kind, style: {
143
+ position: "absolute",
144
+ left: x,
145
+ bottom: 2,
146
+ transform: "translateX(-50%)",
147
+ fontSize: 11,
148
+ color: style.color,
149
+ fontWeight: 700,
150
+ }, children: style.glyph }, marker.id));
151
+ })
152
+ : null] }));
153
+ }
154
+ /**
155
+ * Full-bounds top-down world map (the "press M" overlay): baked terrain
156
+ * background, reveal-on-event fog, all markers with labels, and the player.
157
+ * Rectangular linear projection over the supplied world `bounds`.
158
+ */
159
+ export function WorldMap({ markers, bounds, player, heading = 0, fog, background, width = 520, height, kindStyles = DEFAULT_MARKER_KINDS, className, title = "World Map", onClose, }) {
160
+ const markerList = useMarkers(markers);
161
+ const fogCells = useSyncExternalStore(fog?.subscribe ?? NO_SUBSCRIBE, fog?.cells ?? NULL_CELLS, fog?.cells ?? NULL_CELLS);
162
+ const worldW = bounds.maxX - bounds.minX;
163
+ const worldD = bounds.maxZ - bounds.minZ;
164
+ const mapH = height ?? Math.round((width * worldD) / worldW);
165
+ const project = (x, z) => ({
166
+ x: ((x - bounds.minX) / worldW) * width,
167
+ y: ((z - bounds.minZ) / worldD) * mapH,
168
+ });
169
+ return (_jsxs("div", { className: className, "data-world-map": true, style: {
170
+ borderRadius: 16,
171
+ padding: 14,
172
+ background: "linear-gradient(160deg, rgba(20,24,32,0.97), rgba(10,13,18,0.97))",
173
+ border: "1px solid rgba(148,163,184,0.35)",
174
+ boxShadow: "0 24px 60px rgba(0,0,0,0.6)",
175
+ color: "#e2e8f0",
176
+ fontFamily: "ui-sans-serif, system-ui, sans-serif",
177
+ }, children: [_jsxs("div", { style: { display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 10 }, children: [_jsx("span", { style: { fontSize: 13, fontWeight: 700, letterSpacing: 1.5, textTransform: "uppercase" }, children: title }), onClose !== undefined ? (_jsx("button", { type: "button", onClick: onClose, style: {
178
+ border: "1px solid rgba(148,163,184,0.4)",
179
+ borderRadius: 6,
180
+ background: "transparent",
181
+ color: "rgba(226,232,240,0.85)",
182
+ fontSize: 11,
183
+ padding: "3px 9px",
184
+ cursor: "pointer",
185
+ }, children: "Close" })) : null] }), _jsxs("svg", { width: width, height: mapH, viewBox: `0 0 ${width} ${mapH}`, "data-world-map-canvas": true, style: { borderRadius: 10 }, children: [_jsx("rect", { x: 0, y: 0, width: width, height: mapH, fill: "#16202b" }), background !== undefined ? (_jsx("image", { href: background, x: 0, y: 0, width: width, height: mapH, preserveAspectRatio: "none", opacity: 0.95 })) : null, fogCells !== null
186
+ ? (() => {
187
+ const cellW = (fogCells.cellSize / worldW) * width;
188
+ const cellH = (fogCells.cellSize / worldD) * mapH;
189
+ const rects = [];
190
+ for (let row = 0; row < fogCells.rows; row += 1) {
191
+ for (let col = 0; col < fogCells.cols; col += 1) {
192
+ if (fogCells.revealed[row * fogCells.cols + col])
193
+ continue;
194
+ const at = project(fogCells.minX + col * fogCells.cellSize, fogCells.minZ + row * fogCells.cellSize);
195
+ rects.push(_jsx("rect", { x: at.x, y: at.y, width: cellW + 0.5, height: cellH + 0.5, fill: "#0b0f14", opacity: 0.86 }, `wfog-${col}-${row}`));
196
+ }
197
+ }
198
+ return rects;
199
+ })()
200
+ : null, markerList.map((marker) => {
201
+ const at = project(marker.position[0], marker.position[2]);
202
+ const style = markerKindStyle(marker.kind, kindStyles);
203
+ return (_jsxs("g", { "data-world-marker": marker.kind, children: [_jsx("circle", { cx: at.x, cy: at.y, r: 9, fill: "rgba(2,6,12,0.6)", stroke: style.color, strokeWidth: 1.2 }), _jsx("text", { x: at.x, y: at.y + 4, textAnchor: "middle", fontSize: 12, fill: style.color, style: { fontWeight: 700 }, children: style.glyph }), marker.label !== undefined ? (_jsx("text", { x: at.x + 12, y: at.y + 4, fontSize: 11, fill: "rgba(226,232,240,0.85)", children: marker.label })) : null] }, marker.id));
204
+ }), player !== undefined
205
+ ? (() => {
206
+ const at = project(player[0], player[1]);
207
+ return (_jsx("g", { transform: `translate(${at.x} ${at.y}) rotate(${heading * (180 / Math.PI)})`, children: _jsx("path", { d: "M0,-11 L7,9 L0,4 L-7,9 Z", fill: "#4ade80", stroke: "#052e16", strokeWidth: 1 }) }));
208
+ })()
209
+ : null] })] }));
210
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/react",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "React UI layer for JGengine: GameProvider, hooks, and headless primitives over @jgengine/core.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -29,7 +29,7 @@
29
29
  "check-types": "tsgo --noEmit -p tsconfig.json"
30
30
  },
31
31
  "dependencies": {
32
- "@jgengine/core": "^0.6.0"
32
+ "@jgengine/core": "^0.7.0"
33
33
  },
34
34
  "peerDependencies": {
35
35
  "react": "^19.0.0"