@jgengine/editor 0.26.0 → 0.28.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 +174 -0
- package/dist/SimulationInspector.js +3 -2
- package/dist/handlers/terrain.js +19 -1
- package/dist/mcp/fieldSpec.js +1 -1
- package/dist/mcp/tools.js +2 -2
- package/package.json +6 -6
- package/skills/jgengine/api.md +2 -2
- package/skills/jgengine-editor/api.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,180 @@ At publish, rename this heading to the new version and mirror the entries into
|
|
|
26
26
|
`packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
|
|
27
27
|
-->
|
|
28
28
|
|
|
29
|
+
## 0.28.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.28.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.25.0`; `@jgengine/github` is `0.15.0`.
|
|
34
|
+
- `groupStaticObjects` (`@jgengine/shell/world/staticObjectBatches`) takes an explicit `cell` argument (default `STATIC_BATCH_CELL`), and `STATIC_BATCH_CHUNK` is now `STATIC_BATCH_CELL`. `STATIC_BATCH_MIN` is 2. A caller that passed a chunk size to pick the grouping keeps working by passing it as the third argument.
|
|
35
|
+
- `createNearAgents` (`@jgengine/core/gameplay`) `snapshot()`/`restore()` now carry the crowd as well as the agents: `{ agents, crowd }` where `crowd` is a `NavCrowdSnapshot`. A restored near agent resumes the route it was walking instead of re-planning a fresh one, which is what makes an off-screen world round-trip tick-exactly.
|
|
36
|
+
- `OffScreenSnapshot.residents[].staleAt` is `number | null`; `null` is the "never goes stale" sentinel that was `Infinity`. `Infinity` does not survive JSON, so a saved off-screen world decayed a far, routine-less resident on every load.
|
|
37
|
+
- `TrafficSnapshot.counters` and `PoliceResponseState.counters` no longer carry `slowestTickMs`. A wall-clock timing sample is not state; it stays on `stats()` only, so a save document is byte-deterministic.
|
|
38
|
+
- `MaterialMaps.displacement` (`@jgengine/assets`) and `TerrainMaterialMaps.displacement` (`@jgengine/core/world/features`) are optional now. A caller that used to invent a displacement URL can drop it; nothing reads it.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- `setModelSubMeshMerge` (`@jgengine/shell/render/modelMeshMerge`) turns load-time sub-mesh merging off for a game that must keep one node per source mesh.
|
|
43
|
+
- `render/staticBatches` devtools system probe reports the grouped batches, the instances behind them, the draws submitted and the distinct instances drawn after culling, so batching and per-instance culling are visible in `debug_snapshot().systems`. `countStaticBatchDraws` (`@jgengine/shell/world/staticObjectBatches`) reads those counts from a live scene.
|
|
44
|
+
- `freezeStaticTransforms` / `thawStaticTransforms` (`@jgengine/shell/render/staticTransforms`) keep authored scenery out of the per-frame matrix update.
|
|
45
|
+
- `ModelLodConfig` (`@jgengine/core/game/playableGame`) sets a character model's full-rate, half-rate and crowd distances plus the crowd hysteresis.
|
|
46
|
+
- `characterLodTier` / `characterLodPolicy` (`@jgengine/shell/render/characterLod`) resolve a distance to a tier, and the `render/characters` devtools system probe reports rigs by tier, skinned parts, bone pose writes per frame and crowd instances in `debug_snapshot().systems`.
|
|
47
|
+
- Load-time shader warmup for 3D presentations. `createShaderWarmup` (`@jgengine/shell/render/shaderWarmup`) compiles the mounted scene through `WebGLRenderer.compileAsync` in rounds, one per burst of streamed models, and reports a phase, round and program count a loading screen can render. It is bounded: a round is raced against its budget, so a driver that never reports a finished program cannot hold the first frame.
|
|
48
|
+
- `SceneWarmupCover` (`@jgengine/shell/render/SceneWarmupGate`) is the opaque cover the shell puts over the canvas until that warmup finishes. It carries `data-shell-code-loading`, so capture and drive already wait for it, and it clears itself on a failed compile. Game UI and menus mount above it and stay usable.
|
|
49
|
+
- `createPoliceResponse` (`@jgengine/core/world`) is the wanted level over the shared `ai/heatSystem` curve: a crime raises heat only while `sensor/perception` memory holds the suspect in view (the sighting's own `sourceId`/`sourceTick` kept as its owner), decay starts once every witness has been out of sight for `escapeSeconds`, and a caller-supplied tier table decides what police do — patrol, investigate, pursue, roadblock — with pursuer budgets, a last-sighting search area that grows while the trail is cold, and roadblock sites off the authored road data. `state()`/`restore()` round-trip mid-chase and the injected generator is the only source of variety.
|
|
50
|
+
- `policeRoadsFromLaneGraph` (`@jgengine/core/world`) reads cordon sites and an indexed nearby-lane query out of an authored lane graph: `traffic_roadblock` markers projected onto the lane they block, or the graph's intersections when none are authored.
|
|
51
|
+
- `DEFAULT_POLICE_TIERS` and `DEFAULT_POLICE_TUNING` are the starting tier table and curve a game overrides.
|
|
52
|
+
- `createRideSystem` (`@jgengine/core/gameplay`) runs a park's rides: each one cycles load → ride → unload at its own capacity, boards from its own line, wears against its own breakdown curve (chance per completed cycle, repair seconds and money) and reports boards, alights, breakdowns and repairs through a sink, so the game keeps the bodies, the visuals and the wallet. `snapshot()`/`restore()` round-trip a park mid-cycle, `retune(id, patch)` changes a ride's ratings, capacity, timings or breakdown curve in play, and `service(id)` pays the maintenance cost and clears wear.
|
|
53
|
+
- `createWaitQueue` (`@jgengine/core/gameplay`) is a real line: guests join the back, leave from anywhere, and the wait a new arrival faces comes from the queue's own measured boarding throughput rather than a configured constant. `DEFAULT_QUEUE_SEED_SERVICE_SECONDS` only bootstraps the estimate until two boardings have been observed.
|
|
54
|
+
- `createRideChooser`, `rideChoiceOptions`, `rideChoiceCandidates`, `rideQueueCostWeight` and `rideAppeal` (`@jgengine/core/gameplay`) turn a guest's `RideTaste` (intensity comfort, nausea comfort, queue tolerance) plus the park's ride data into `createUtilityBrain` options, each ride costed by the seconds its own line reports — ride choice is one more scored option, not a fixed order.
|
|
55
|
+
- `DEFAULT_RIDE_BREAKDOWN`, `DEFAULT_RIDE_RATING_RANGE` and `DEFAULT_RIDE_FUN_CURVE` are the starting curve and scales a park overrides.
|
|
56
|
+
- `createMissionRuntime` (`@jgengine/core/gameplay`) runs missions as data: a mission is a graph of objectives the machine walks, with named fail states, per-objective timers, conditional branches, per-objective and completion rewards, a `rollout` fact rolled from the injected RNG, and a `next` mission that chains a follow-up. `tick(dt, world)` advances the active mission against caller-supplied actors, counters, facts and survivors; `signal`/`fail` let an authored trigger complete or fail it. `snapshot()`/`restore()` round-trip a run, `stats()` and an optional `devtoolsName` probe expose it, and cost per tick is the active objective's own fail list plus one goal test.
|
|
57
|
+
- Objective kinds are the shared primitives `reach`, `interact`, `survive`, `pursue`, `kill` and `collect`; a fail condition can be `actor-died`, `timer` or `fact`.
|
|
58
|
+
- `escortMission`, `chaseMission` and `defendMission` (`@jgengine/core/gameplay`) are compositions of those kinds, not archetypes: an escort is a `reach` with an `actor-died` guard, a defend is a `survive` with a survivor floor that banks what is left standing as the score, and a chase is a `pursue` with an escape guard.
|
|
59
|
+
- `validateMissionCatalog` (`@jgengine/core/gameplay`) is the mission content gate: duplicate ids, unknown branch/next/timeout targets, unknown fail states and markers, per-kind required fields, unreachable objectives, and the counter-only rule — a mission with a completion path made entirely of `kill`/`collect` objectives is an error. `createMissionRuntime` runs it at construction, so a counter-only mission throws instead of shipping.
|
|
60
|
+
- `registerMissionTriggerActions`, `MISSION_TRIGGER_ACTIONS`, `createMissionTriggerBridge` and `missionMarkers` (`@jgengine/core/gameplay`) start, advance and fail a mission from authored scene markers using the shared `on`/`action` trigger vocabulary; positional objectives read marker positions off the authored document.
|
|
61
|
+
- `applyMissionRewards` (`@jgengine/core/gameplay`) pays a mission through the same appliers quests use (XP, currency, items, unlocks) and rolls named loot tables through the game's loot registry.
|
|
62
|
+
- `createMarket` (`@jgengine/core/gameplay`) prices goods from supply and demand over finite stock on a stated curve: `price = clamp(basePrice × (coverPeriods × demand / max(stock, 1))^elasticity)` between `basePrice × minFactor` and `basePrice × maxFactor`. `sell` decrements the finite stock and refuses a sale it cannot cover with the shortfall named, `restock` lowers the price, `quote`/`priceAfter` read the move without mutating, `recordDemand(unitsSold, unitsUnmet)` folds a period of observed buying into demand, and `priceCurve` replaces the curve. `retune` changes a good's policy in play; `snapshot()`/`restore()` round-trip stock and demand; money is integer minor units.
|
|
63
|
+
- `createTransactionLedger` (`@jgengine/core/gameplay`) keeps per-account, per-currency balances in integer minor units with a journal that records every movement, the balance it produced and the reason it happened. `post` is the one money path — sales, purchases, upkeep and loan payments all carry a caller-owned reason and an optional `ref` — and `totals()` keeps a per-reason count and net beyond the `maxEntries` journal cap. `addCharge` registers a recurring charge (upkeep, rent) settled on its cadence by `chargeDue`, bounded per charge and per advance; a cycle with no funds is counted as missed instead of overdrawing.
|
|
64
|
+
- `buildLoanSchedule` and `createLoanBook` (`@jgengine/core/gameplay`) draw a loan, post the principal, and settle interest (`"loan-interest"`) and principal (`"loan-principal"`) as separate reason-tagged entries. A schedule is built up front in integer minor units — `"amortized"` levels the payment on the outstanding balance, `"flat"` charges interest on the original principal — and the final payment absorbs the rounding, so the principal parts sum to the principal exactly. A payment that comes due with no funds stays in arrears and counts once per advance; after `maxMissed` the loan is defaulted and stops being advanced. `repay` pays principal early, and state is terms plus progress, so the schedule is rebuilt on restore.
|
|
65
|
+
- `createGameSave` (`@jgengine/core/gameplay`) is the one game save document: a `version`, the game/time/playtime metadata a save menu shows, the world's shared RNG seed and cursor, and one section per registered system. Register each stateful system once (`name` + `snapshot()`/`restore()`, optional `reset()`); `capture()` builds the document, `save()`/`touch()` persist it through the same injected `SaveBackend` `createSaveStore` uses, `load()`/`switchSlot()` restore every section and the RNG cursor, and `migrate` upgrades an older payload. Autosave cadence is caller policy, and `digest()`/`digests()` hash the whole state (or one section) for a save/reload determinism test.
|
|
66
|
+
- `gameSaveDigest` (`@jgengine/core/gameplay`) hashes a `GameSaveDocument`'s state — every section, the clock and the RNG cursor — excluding the wall-clock `savedAt`.
|
|
67
|
+
- `GameSaveConfig.devtoolsName` registers a devtools probe reporting the active slot, the save status, the change revision and the system count.
|
|
68
|
+
- `createVisitorPopulation` (`@jgengine/core/sim/population`) counts a crowd by its members: a visitor exists only once the game's `VisitorWorld.admit` has made its body and `VisitorWorld.has` confirms it, so `onSite()` is the member count and `arrivalsToday()` is the sum of the admissions the world accepted. `arrivalRate`, `capacity`, `shouldLeave` and `inside` are caller policy; `adopt` takes over a body a save or a seed made; `rollDay()` closes the day without emptying the park.
|
|
69
|
+
- `createDamageRuntime` (`@jgengine/core/combat/damageRuntime`) announces every damage application as `combat.damage` on the game's own event bus: `target`, `source`, `type`, `amount`, `absorbed`, `remaining`, `lethal`, plus `status` for a status tick. Pass `events: ctx.game.events` and `healthOf` (the target's live health, read before you apply it) so a HUD can name the blow and tell whether the target died; without `healthOf` the event reports `remaining: null` and `lethal: false`. `recentDamage(limit)` and the devtools probe line carry the same stream.
|
|
70
|
+
- `StatusDamageTick.source` (`@jgengine/core/combat/statusRuntime`) names who applied the status that is ticking, so a burn names its arsonist.
|
|
71
|
+
- `clearCameraBoom` (`@jgengine/shell/camera`) answers where a camera can sit without entering world collision: the desired position is rejected when the boom hits a surface, and the camera is placed one `clearance` before it. Pulling in is immediate, easing back out follows `releaseRate`, so a boom reads as a spring arm rather than a hard stop. `sceneCameraQuery(ctx.scene.raycast)` feeds it the existing world solids, object colliders and walls — no new collision layer — and any other hit query can be passed instead.
|
|
72
|
+
- `FirstPersonCameraConfig.collision` (`{ clearance?, armBack? } | false`) keeps the `first` rig's eye out of the trunk or wall the body can reach. On by default; `false` restores the unchecked camera.
|
|
73
|
+
- `createEffectSystem` (`@jgengine/core/combat/effects`) takes `onDamage?(application)`: one `EffectDamageApplication` per target a draining effect lands on, before `onLethal` despawns it. It carries `target`, `source` (the effect's `from`), `effect`, `incoming`, `dealt`, `remaining` (the last pool's current) and `lethal`; restorative applications stay silent.
|
|
74
|
+
- `installCaptureCamera`/`readCaptureCamera`/`clearCaptureCamera` and `CaptureCameraOverride` (`@jgengine/core/world/captureCamera`) are the harness counterpart of `?look=`, and the same install-style seam as `world/spawnOverride`: the runner installs a rig, the shell reads it. `resolveCaptureCameraConfig` (`@jgengine/shell/camera/rigResolve`) reads it above a game's static `camera` block, its runtime `ctx.camera.setRig` selection and its director cinematics, so a game that drives its own camera can no longer photograph that camera instead of a requested `--look` framing. With nothing installed the resolution is exactly `resolveRuntimeCameraConfig`, so every game renders as before.
|
|
75
|
+
- `EFFECT_LIBRARY` and `resolveEffect` (`@jgengine/core/vfx/effectLibrary`) name 24 GPU particle effects — fire, smoke, sparks, dust, splashes, muzzle flash, explosions, magic and weather — each authored as rate, lifetime, size, colour and a procedural sprite texture, so `ctx.particles.play("explosion/core", { position })` renders one by name and the editor authors the same name.
|
|
76
|
+
- `ctx.particles.play(name)` and `ctx.particles.attachEffect(id, name)` (`@jgengine/core/vfx/particleDirector`) play a library effect by name; `effectNames()` lists them.
|
|
77
|
+
- `planParticleBudget` (`@jgengine/core/vfx/particleBudget`) caps the live particle count to a graphics tier and drops whole effects lowest-priority-first when the request does not fit.
|
|
78
|
+
- `GraphicsProfile.particleBudget` is the tier's scene-wide live particle cap (low 1024, medium 2048, high 4096, ultra 8192); `particleCap` stays the per-effect pool clamp.
|
|
79
|
+
- `AuthoredEmitter.effect` (`@jgengine/core/editor/simulation`) authors a named library effect in `editor.scene.json` instead of a full config; `config` is now optional and exactly one of the two is required. `SimulationInspector` gains an effect picker.
|
|
80
|
+
- `foliage/grass-macro` and `ground/dirt-macro` in the shared material library: the same ambientCG scans at a 24 m / 20 m tile, so they carry structure at the scale an RTS camera 50 m out resolves instead of texel noise.
|
|
81
|
+
- `libraryTerrainMaterial(name)` (`@jgengine/assets/catalogs/materialLibrary`) returns the terrain detail material config for a library name, tiling from the entry's authored metres and blending from its terrain policy; a map the archive does not ship is omitted.
|
|
82
|
+
- `libraryBuildingSurface(name, options)` and `librarySurfaceParameters(name)` bridge a library name to `BuildingSurfaceMaterial` and `MaterialSurfaceParameters`, so a facade names its material instead of hand-wiring maps, colour and repeat.
|
|
83
|
+
- The library reports a `displacement` role (`libraryMaterialRoles`) without loading it, and `MaterialMaps.displacement` / `TerrainMaterialMaps.displacement` are optional; ambientCG archives without one (Fingerprints001) no longer publish a phantom displacement URL. `libraryTerrainMaterial` passes it through for callers that do displace.
|
|
84
|
+
- `terrain-macro` dev demo captures one field from 52 m in `?mode=flat|dense|macro` to compare ground materials at RTS scale.
|
|
85
|
+
- `SKELETON_STANDARD`, `mapRigToStandard`, `retargetTracks`, `retargetBoneScales` and the `RIG_SCHEMES` table (`@jgengine/core/anim/skeletonStandard`) are the engine's one humanoid skeleton standard: the CC0 KayKit character rig that `kaykit-adventurers` and `kaykit-skeletons` already share, named slot by slot (`root`, `hips`, `spine`, `chest`, `head`, `upperarm.l`…`toes.r`, `handslot.l/r`). A rig on another naming scheme maps onto those slots through a scheme table — Mixamo (`mixamorig*`) and the Quaternius sci-fi aliens ship as `MIXAMO_SCHEME` and `QUATERNIUS_ALIEN_SCHEME` — and every slot the rig cannot fill comes back in `missing` instead of borrowing a neighbour's bone. `retargetTracks` renames a clip's tracks onto a mapped rig, `retargetBoneScales` gives the per-slot length ratios that match bone proportions, and `runtimeBoneName` is the `.`-stripped spelling the loaded hierarchy and clips both use.
|
|
86
|
+
- `locomotionGraph` (`@jgengine/core/anim/locomotionGraph`) now builds the whole directional locomotion set: `strafeLeft`/`strafeRight` add a `strafe` blend on the new `direction` parameter, `backward` adds a `backward` blend behind the new `facing` parameter, and every graph gets a `turn` blend on the new `turnRate` parameter (pivoting on `walk` when no turn clip is authored). Each state joins the idle/walk/run blend through a crossfaded transition, so a direction change blends instead of popping; supplying none of them leaves the speed-only tree unchanged. `LOCOMOTION_DIRECTION_PARAM`, `LOCOMOTION_FACING_PARAM`, `LOCOMOTION_TURN_RATE_PARAM`, `LOCOMOTION_STRAFE_STATE`, `LOCOMOTION_BACKWARD_STATE` and `LOCOMOTION_TURN_STATE` are exported, and a custom mover feeds the three parameters through the entity blackboard key `ANIM_PARAMS_KEY`.
|
|
87
|
+
- `ModelAnimationStates` gains `strafeLeft`, `strafeRight`, `backward` and `turn`.
|
|
88
|
+
- `classifyClip`/`rolesFromClips` know the `strafe` and `backward` clip roles, so `defaultAnimationForClips` derives the KayKit directional states automatically (`Running_Strafe_Left`, `Running_Strafe_Right`, `Walking_Backwards`) for any rig resolved with `animation: "auto"`.
|
|
89
|
+
- `ragdollConfigFromPoses`, `ragdollBoneOrientations`, `RagdollPose`, `RagdollBodyShape` and `HUMANOID_RAGDOLL_BODIES` (`@jgengine/core/anim/ragdollRig`) turn a rig mapped onto the skeleton standard into a physics ragdoll at the moment of death: one box body per standard slot that has a shape, sized to the character, and one distance link per body to its nearest shaped ancestor, holding the bones' own separation. The bodies start where the character was, so the collapse continues the death pose. `handSlot.l/r` carry no body (prop sockets, not body parts); a slot the rig lacks is skipped and its children link to the nearest present ancestor. Because the engine's `PhysicsWorld` bodies carry translation only, `ragdollBoneOrientations` gives the skeleton its orientation by rotating each bone's death-pose direction-to-child onto the current one.
|
|
90
|
+
- `useRagdoll` (`@jgengine/shell/render/useRagdoll`) arms a ragdoll on a rigged character when `active` goes true and drives every mapped bone's world position and aim from its body each frame. It steps the injected `PhysicsWorld`; deactivating removes the bodies and leaves the rig where the ragdoll put it. Stop the character's `ModelConfig.ik` while it is active, or the two write the same joints.
|
|
91
|
+
- `multiplyQuaternions` and `quaternionFromUnitVectors` (`@jgengine/core/anim/ikSolver`) are the two pure rotation helpers the ragdoll's aim needs.
|
|
92
|
+
- `locomotionGraph({ rootMotion: true })` (and `ModelAnimationStates.rootMotion`) marks the whole locomotion set — idle/walk/run, strafe, backward and turn — as root-motion states, so one authored locomotion graph carries its own travel as `AnimGraphOutput.rootDelta` for the movement owner to consume.
|
|
93
|
+
- `actionLayer` and `withActionLayers` (`@jgengine/core/anim/actionLayer`) build a masked overlay layer from plain data: a resting clip plus a one-shot state per entry in `actions`, each joined by a consumed-trigger transition and returning on `exitTime`. Aim, reload, wave and hit reactions are one builder instead of a hand-written state machine per game. `additive: true` makes it a recoil/breathing overlay — the shell's `makeClipAdditive` makes the rest clip's first frame the zero reference, so the rest state is held at `speed: 0` and contributes no pose while the base keeps animating. `mask` is required and nonempty, because an overlay that names no bones would drive the whole rig; `withActionLayers` rejects a duplicate layer id instead of shadowing the base.
|
|
94
|
+
- `standardBoneMask`, `UPPER_BODY_SLOTS`/`LOWER_BODY_SLOTS` and `UPPER_BODY_MASK`/`LOWER_BODY_MASK` (`@jgengine/core/anim/skeletonStandard`) derive a layer mask from the skeleton standard's own slots, so a rig mapped onto the standard is masked without hand-typing its bone names.
|
|
95
|
+
- `ModelLookAtConfig` (`@jgengine/core/game/playableGame`, `ModelConfig.ik.lookAt`) can aim at more than the camera: `target` takes `"camera"` (default), `{ entity }` offset by `entityEyeHeight` (default 1.5), or `{ point }`. `maxYaw`/`maxPitch` bound the turn in radians and `weight` (default 1) scales how much of the solved turn reaches the bone. An entity target that is not spawned keeps its last known direction instead of snapping the head back to the camera. `applyFootIk`'s sixth argument is now `lookAtTarget` and applies the limits and weight; `FootIkRig` carries `lookAtLimits` and `lookAtWeight`.
|
|
96
|
+
- The shell derives the directional locomotion parameters from the entity's own movement, so a rig with the `strafe`/`backward` states uses them with no game-side code: `direction` (the motion's share along the character's right axis), `facing` (its share along the facing axis) and `turnRate` (its yaw change per second) come from the entity's `position` and `rotationY` each visual sample and override conflicting authored extras. `readModelAnimationParams` gains an optional `ModelMotionStep` argument (`dx`, `dz`, `dyaw`, `seconds`); omitting it leaves direction to the authored extras, which is the previous behaviour. A step past the live `snapDistance` reports no direction and zero speed rather than inventing a lateral.
|
|
97
|
+
- `createMenuTransition` (`@jgengine/core/ui/menuTransition`): closed → enter → open → exit → closed timing over an injected clock, with reduced-motion collapse to zero and `snapshot`/`restore`.
|
|
98
|
+
- `@jgengine/core/ui/menuNav`: `menuNavMove` focus movement over a vertical list, horizontal row or grid with disabled-item skipping, `menuIntentForKey`, `menuIntentForPadButton`, `menuIntentForPadAxis` and `shouldRepeatMenuNav` for held d-pad/stick repeat.
|
|
99
|
+
- `MenuScreen` (`@jgengine/react/menuScreen`): animated enter/exit screen with a translucent scrim, a `backdrop` slot for a live 3D scene, reduced-motion collapse and exit unmount. Animates transform/opacity only.
|
|
100
|
+
- `TitleMenu` (`@jgengine/react/titleMenu`): title-screen block — wordmark, eyebrow, tagline, navigable verb list, footer, control hint and backdrop slot. Games own the wording, verbs and art.
|
|
101
|
+
- `useMenuNav` (`@jgengine/react/menuNav`): keyboard and standard-gamepad focus navigation for any menu — roving `tabIndex`, arrow/WASD/Home/End moves, Enter/Space/A confirm, Escape/Backspace/B/Start back, held-stick repeat, disabled-item skipping, no focus trap (Tab stays native, typing is never intercepted) and keys that work when focus starts outside the menu.
|
|
102
|
+
- `PauseMenu` now navigates with the same keyboard/gamepad rules; Resume is the first item and Escape resumes.
|
|
103
|
+
- Engine demo `menu-framework` (`bun run shoot menu-framework --mode play`) renders a live 3D scene behind the title and pause screens.
|
|
104
|
+
- `credits-panel` — `CreditsPanel` (`@jgengine/react/creditsPanel`): a scrollable, HudTheme-skinned credits screen over the scene with one back affordance reachable by keyboard, gamepad and pointer.
|
|
105
|
+
- Loading screen with tips — `LoadingScreen` (`@jgengine/react/loadingScreen`): progress bar (real 0–1 value or indeterminate), rotating tips, a ready gate any key / A / click accepts, reduced-motion aware, and a scene backdrop.
|
|
106
|
+
- `createTipRotation` (`@jgengine/core/ui/loadingTips`): deterministic, seeded tip order that cycles every tip once, with `snapshot`/`restore`.
|
|
107
|
+
- Save slots wired to the save document — `refreshSaveSlots`, `saveSlotMeta`, `formatSavePlaytime` and `gameSaveSlotSource` (`@jgengine/core/game/saveSlotIndex`) read the slots a `createGameSave` actually wrote and fill a `createSaveSlots` index with the name, saved time and playtime chip a save-select menu shows.
|
|
108
|
+
- `SaveStore.peek(slot)` and `GameSave.peek(slot)`: read a slot's stored document without touching the live session, applying sections or changing status.
|
|
109
|
+
- `SaveSlotWrite.savedAt`: a save payload that knows when it was written can stamp that time instead of the index's clock.
|
|
110
|
+
- `@jgengine/core/settings/accessibilitySettings`: the persisted accessibility ids (`accessibility.textScale`, `accessibility.colorblind`, `accessibility.reducedMotion`, `accessibility.highContrast`, `accessibility.captions`, `controls.holdVsToggle`), `accessibilitySettingRows(store)` for a settings surface, `readAccessibilitySettings(store)`, and `holdToggleActive(previous, { pressed, held }, mode)` for hold-or-toggle actions.
|
|
111
|
+
- The shell's settings menu has an `accessibility` category (text size, colourblind palette, reduce motion, high contrast, captions, hold-or-toggle) unless a game hides it. Text size scales every `--jg-type-*` token through `--jg-text-scale`; reduce motion zeroes the engine's motion scale; high contrast, colourblind and captions are exposed as `data-*` hooks on the HUD root.
|
|
112
|
+
- `SettingsSection`, `SettingsRowControl`, `SettingsSlider`, `SettingsToggle`, `SettingsSelect`, `SettingsKeybindControl` and `SettingsTabs` (`@jgengine/react/settingsBlocks`): the settings rows and click-to-capture controls as composable, HudTheme-tokenized blocks, so a game lays out its own settings screen instead of taking the stock dialog. Rebinding writes into the game's existing rebind/holdings store.
|
|
113
|
+
- `useAccessibilitySettings`, `useHoldVsToggle` and `useAccessibilityRows` (`@jgengine/react/accessibilitySettings`) read the live preferences for any custom layout.
|
|
114
|
+
- `HudThemeRoot` takes `accessibility` and applies text scale and the preference data attributes at the HUD root; the shell passes the player's stored preferences there for both the 3D and HUD-only presentation paths.
|
|
115
|
+
- `MissionTickInput.events` (`@jgengine/core/gameplay`) carries the world events that happened this tick - `kill`, `collect`, `deliver` with a target, the actor, free-form `tags` and a count. An objective's `match` filter (`target`, `by`, `tags`, `withoutTags`) credits a constrained counter only from matching events, so a decoy, a wrong weapon or another killer's kill does not advance it.
|
|
116
|
+
- `failOn` accepts `event` (a matching reported event ends the run) and `left-area` (the watched actor is farther than `radius` from an authored marker), completing the reachable fail states: cargo lost, time out, escort died, left the area. `validateMissionCatalog` rejects a `left-area` fail with no actor or unknown marker and a `match` on a non-counter objective instead of ignoring it.
|
|
117
|
+
- `MissionView` now carries `counted`, `count`, `step`, `steps`, `outcome` and `reason`, so a HUD renders "step 2 of 3", a timer and the fail state from `view()` alone. A counter objective's progress mirrors the caller's counters into `state.progress`, so `view()` and a save both see it.
|
|
118
|
+
- `TerrainDetailMaterialConfig.missing` (`@jgengine/core/world/features`): a terrain detail material built from pulled scans no longer takes the whole view down when those maps are not on disk. `"procedural"` (the default) keeps the authored procedural ground and logs one warning naming the missing map, so a game whose asset packs are pulled rather than committed boots anywhere; `"fail"` keeps the presentation retry screen for a view that genuinely cannot render without the scan, which is what the recovery demo asks for.
|
|
119
|
+
|
|
120
|
+
### Changed
|
|
121
|
+
|
|
122
|
+
- `bun run land` (`scripts/land.ts`) lands a task branch in one command: it refuses `main` or a dirty tree, pushes, opens or reuses the PR into `beta`, runs `--verify`, watches checks, merges and prints `landed <url>`. Exit codes: 0 landed, 1 verify failed, 2 checks failed, 3 could not merge.
|
|
123
|
+
- The CI `test` job is split by path into four matrix shards (`scripts/test-shard.ts`), about 90 s each instead of 5m37s in one job; `ci` still aggregates every shard and no test file is dropped.
|
|
124
|
+
- A loaded model whose meshes share a material now merges to one mesh per material at load, baking each source mesh's transform into the merged vertices. Every instance cloned from that model draws once per material instead of once per sub-mesh, and the instanced scatter/static-batch path harvests the same merged meshes. Skinned meshes, instanced meshes, meshes with morph targets or rigged ancestors, meshes with children, hidden meshes, mirrored meshes, and every model carrying an animation clip are left untouched. A material-assignment `selector.mesh` still resolves a sub-mesh name the merge absorbed.
|
|
125
|
+
- Static authored/`scenery` props batch per model per 128 m cell with a minimum of 2, instead of per model per 64 m cell with a minimum of 3. Props spread thinner than the old minimum in their cell used to fall back to a full individual model draw — Harbor Heat drew 425 of its 488 props that way — and now join one instanced draw per material. Cell size stays configurable because one `InstancedMesh` has one bounding sphere, which three also frustum-tests in the shadow pass: a batch spanning the whole city submits every camera-visible instance to every shadow cascade (Harbor Heat +14% triangles), while 128 m keeps that rejection working (+1.1%).
|
|
126
|
+
- Culling stays per instance: the visibility predicate compacts each batch's drawn range every frame and the batch's bounding sphere is rebuilt from what it draws, so a batch with nothing in view contributes a draw of zero.
|
|
127
|
+
- Static subtrees stop paying three's per-frame matrix pass. `freezeStaticTransforms` (`@jgengine/shell/render/staticTransforms`) bakes a subtree's world matrices once and clears `matrixAutoUpdate`/`matrixWorldAutoUpdate`; `thawStaticTransforms` puts it back for a seam that moves on an event. Batched static props and object/entity marker groups use it.
|
|
128
|
+
- Actor and object marker groups now own their matrix: the per-frame pose write recomposes only when the pose actually changed, so a parked prop or an idle actor leaves its subtree out of that frame's world-matrix pass. Moving actors are unchanged.
|
|
129
|
+
- A postfx prepass no longer re-walks the scene graph. `renderWithCurrentMatrices` (`@jgengine/shell/postfx/shadowPrepass`) draws the stylize normal prepass and the GTAO/DOF prepasses with the matrices the beauty pass built moments earlier in the same frame.
|
|
130
|
+
|
|
131
|
+
Harbor Heat: `updateMatrixWorld` 2.99 ms → 1.66 ms per frame.
|
|
132
|
+
- A rigged model's skinned parts merge into one skinned mesh per skeleton and material at load (`mergeSkinnedModelParts`, `@jgengine/shell/render/modelMeshMerge`). Vertices move into bind space and skin indices are remapped onto the union of the parts' bones, so the posed result is unchanged. A KayKit character's six body parts become one draw and one bone-matrix upload per character.
|
|
133
|
+
- Characters now carry distance tiers. Under 15 m the rig poses every frame, under 40 m every other frame with the accumulated time, and past 25 m a crowd-eligible rig is drawn by the GPU crowd path and stops updating bones. The thresholds live in `ModelConfig.lod` per model; `lod: false` keeps a model at full rate (a player avatar or a cutscene character), and the local player is always full rate. Any animated model with no `ik`, `attachments` or `parts` is crowd-eligible, so a game with no configuration still loses its distant characters' skeletons.
|
|
134
|
+
- `CharacterCrowd` (`@jgengine/shell/render/CharacterCrowd`) draws those far characters: one baked rig per kind, one instanced draw per material, each member's clip, gait phase and speed sampled on the GPU. Entity markers decide the tier, so a character only leaves its rig once the batch for its kind is baked.
|
|
135
|
+
|
|
136
|
+
Harbor Heat at the spawn view: 426 skinned meshes 71, bone pose writes per frame 2,894 41, draws 2,537 1,387, `updateMatrixWorld` 1.47 1.36 ms, frame 25.1 16.7 ms.
|
|
137
|
+
- Shadow casters under a 0.5 m world radius clear `castShadow` as they stream in (`cullSmallShadowCasters`, `@jgengine/shell/render/shadowCasters`). A prop that small is a couple of shadow-map texels wide, so the depth pass spent a draw on a smudge; the authored flag comes back when the mesh leaves the scene. Only clears, so a mesh the game keeps out of the pass stays out. The `render/shadows` devtools system probe reports casters and how many were skipped.
|
|
138
|
+
- Cascaded shadow maps refresh the far cascades on a cadence instead of every frame (`scheduleCascadeRefresh`): with three cascades the nearest redraws every frame, the middle every 2nd and the far every 4th; with four, the two nearest every frame and the rest every 2nd/4th. A skipped cascade keeps the matrix and map from its last refresh, so the pair stays consistent and the shadows do not slide.
|
|
139
|
+
- Cascaded shadow reach now comes from the graphics tier when a game does not author `shadowMaxFar`: the tier's shadow-map resolution sets the reach (512 → 100 m, 1024 → 150 m, 2048/4096 → 200 m), clamped by that tier's draw distance. An authored `shadowMaxFar` still wins, so existing games keep the distance they chose.
|
|
140
|
+
|
|
141
|
+
Harbor Heat's shadow pass self time at `--quality full`: 2.88 → 1.37 ms per frame, `WebGLShadowMap.render` 11.0% → 5.3% of the sampled window; the material patch hook every draw pays dropped out of the top self-time entries (0.83 ms → under 0.3 ms per frame). Frame time is unchanged within the capture noise floor at that view, where the post-processing chain dominates.
|
|
142
|
+
- `EntityDiedEvent.source` and `DeathReason` name what killed the entity: a `player_kill`'s `source` is the killer's instance id, and an environment death carries `attacker` for a non-player entity. `deathReasonFromEffect` fills both from the effect's `from`. Reading an existing environment reason keeps working; a rule that matched `kind` still matches.
|
|
143
|
+
- `ctx.scene.entity.effect` (including telegraph-bound and projectile effects) now raises exactly one `combat.damage` per target on `ctx.game.events`, with the same shape `createDamageRuntime` emits: `type` is the effect id, `remaining`/`lethal` come from the last pool in the catalog's `receive` order. `createDamageRuntime` keeps its own emission, so a game using both paths gets one event per blow, not two.
|
|
144
|
+
- `.claude/skills/jgengine-combat` says which damage path to use: the runtime for typed/zoned/armored damage, the effect path for catalog `receive` pools.
|
|
145
|
+
- The dev runner installs the resolved `?look=`/`--cam=` rig as the capture camera, and the shell's camera rig and presentation both resolve through `resolveCaptureCameraConfig`.
|
|
146
|
+
- `metricsPathFor` (`scripts/shot-metrics`) is the `--inspect` metrics path for a shot. `--out` without an image extension used to swap nothing, so the metrics JSON was written straight over the screenshot; the metrics path now always differs from the shot path.
|
|
147
|
+
- `WorldParticles` allocates the tier's `particleBudget` across the standing emitters and queued bursts with `planParticleBudget`, and reports `cap`/`budgetParticles`/`droppedIds` on its `particles` diagnostics probe.
|
|
148
|
+
|
|
149
|
+
### Fixed
|
|
150
|
+
|
|
151
|
+
- `settingsBoundary.test.ts` builds its fixture under `.scratch/` inside the checkout instead of `%TEMP%`, so `@jgengine/*` resolves through the workspace and the browser test passes in a fresh worktree with no `dist`; CI was hiding this because it builds first.
|
|
152
|
+
- The skill installer treats an unreadable `.jgengine-version.json` as missing and says which file it ignored, so a half-written or hand-edited marker is rewritten on the next install instead of blocking it; refusal messages now name the metadata file.
|
|
153
|
+
- Skill and doctor fixtures write a realistic next-minor version instead of `99.0.0`, so the CI log no longer reports impossible install versions.
|
|
154
|
+
- `SettingsMenu` keeps Escape closing the dialog while a keybind capture is ending. The capture already consumes Escape at the window, so the extra "wait for capture state" guard only dropped a close request that arrived before the next render, which failed `touchHudReachability` on every `beta` run since #2100.
|
|
155
|
+
- `RoutineTimeline.nextChangeAfter` (`@jgengine/core/sim/routine`) no longer answers with the time it was given when a stop time is fractional. `time` arrives as a whole number of cycles plus a stop time, so its low bits are already rounded; a boundary that rounding put a hair behind `time` read as a change still to come, and the answer repeated, so anything that stepped by it looped forever. Boundaries are compared in absolute time within a few ulps of `time`, so every answer is strictly later. `phaseAt` uses the same tolerance, so a phase that starts exactly on a rounded boundary reports the new phase instead of the walk that led to it.
|
|
156
|
+
- `createOffScreenWorld.refreshCheap` stops catching up instead of spinning if a schedule ever again fails to name a later change: it decays needs to `now`, holds the phase and retries one cycle later.
|
|
157
|
+
- A type token now honors the player's text scale: `--jg-type-*` are `calc(max(floor, size) * var(--jg-text-scale, 1))`, so raising text size resizes every engine block rather than only a menu's own labels.
|
|
158
|
+
|
|
159
|
+
## 0.27.0
|
|
160
|
+
|
|
161
|
+
### Migrate
|
|
162
|
+
|
|
163
|
+
- **Bump lockstep SDK packages to `^0.27.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.24.0`; `@jgengine/github` is `0.14.0`.
|
|
164
|
+
|
|
165
|
+
### Added
|
|
166
|
+
|
|
167
|
+
- `rollLoot` (`@jgengine/core/loot/lootGen`) rolls a named item from a caller `LootCatalog`: bases with mount slots, parts, affix pools, weighted rarity tiers, manufacturer personalities and a `levelScale` curve. Stats are `base × rarity.statScale × levelScale`, then part stats/multipliers, maker `bias`/`scale`, then additive and multiplicative affixes; additive affix rolls scale with level, multiplicative ones do not.
|
|
168
|
+
- `createLootRoller` wraps a validated catalog with roll counts per rarity, `snapshot()`/`restore()`/`reset()` and an optional devtools probe; `assertLootCatalog` rejects duplicate ids, unresolvable pools, empty item slots and a non-ascending level curve at construction.
|
|
169
|
+
- `validateLootItem` returns coded issues (`unknown-base`, `manufacturer-base`, `missing-slot-part`, `part-category`, `unknown-affix`, `empty-name`, …) and `compareLootItems` returns sorted per-stat deltas plus a weighted score with caller-named `lowerIsBetter` stats.
|
|
170
|
+
- `lootWeaponDefinition` (`@jgengine/core/loot/lootWeapon`) projects a rolled item onto a `WeaponDefinition` through a caller stat map, so a generated gun runs in `createWeaponSystem` after `assertWeaponDefinition`.
|
|
171
|
+
- `defineXpCurve` (`@jgengine/core/gameplay`) resolves one authored growth rule (`flat`, `linear`, `power`, `geometric`, `table`) plus a `maxLevel` cap into per-level thresholds, lifetime totals, a plain-words rule string and a `leveling` track.
|
|
172
|
+
- `createSkillTree` spends and refunds points over authored nodes with `cost`, `requires`, `excludes`, passives, actives and multi-node `synergies`; `resolved()` returns the stat modifiers and actions, and state snapshots/restores as `{ points, ranks }`.
|
|
173
|
+
- `gearScore` / `compareGearSets` score a whole loadout from stat weights, level, rarity and slot coverage.
|
|
174
|
+
- `lootStatContributions` and `scoreLootStats` expose the weight policy `compareLootItems` already used.
|
|
175
|
+
- `createEquipment` (`@jgengine/core/inventory/equipment`) wears gear by slot: slot defs declare accepted kinds and the slots they block, item defs declare the slots an item may occupy, a filled slot swaps and hands back the replaced item, and a two-handed item and the slot it blocks cannot both be filled. `state()` / `restore()` round-trip the worn set; a devtools probe reports filled slots.
|
|
176
|
+
- `characterLoad` and `canCarry` (`@jgengine/core/inventory/carryLoad`) weigh carried inventories plus worn gear against a capacity and hand back the shared encumbrance bands (tier, `moveMultiplier`, sprint/jump gates, `remaining`), so a stash left out of `carried` adds no weight.
|
|
177
|
+
- `fitShapedItem` (`@jgengine/core/inventory/shapedGrid`) places a shaped item at its first fit or names why not: `too-large` (with both sizes), `no-space`, `budget-exceeded`.
|
|
178
|
+
- `InventorySet.snapshot()` / `restore()` / `reset()` (`@jgengine/core/inventory/inventoryModel`) save and reload every declared inventory as detached plain data, so a bag and a stash never share live slots.
|
|
179
|
+
- `splitStack` takes an optional stack `limit`, rejecting `over-stack` instead of merging past the max stack.
|
|
180
|
+
- `createLaneGraph` and `laneGraphFromDocument` (`@jgengine/core/world`) read an authored lane graph out of the scene document: `traffic_lane` paths are one directed span each, `road` paths (the street bake) become a lane per direction split where streets cross, `traffic_intersection` markers carry signal phases and right-of-way in `meta`, and `traffic_parking` markers place kerbside spots.
|
|
181
|
+
- `createTraffic` (`@jgengine/core/world`) runs cars on that graph: IDM car following and braking, one car at a time through each intersection box behind a signal phase or first-come right-of-way, lane changes across authored neighbours, parking that reserves a spot, deterministic injected variety, `snapshot`/`restore`, and a devtools probe.
|
|
182
|
+
- `Traffic.setDemand(count)` is the fleet target; the time-of-day curve stays the caller's policy.
|
|
183
|
+
- `applyVehicleParts` (`@jgengine/core/world`) composes data-driven tuning parts onto a `KinematicVehicleTuning` — stat multipliers/addends applied in install order plus an optional tire grip curve — and returns the composed tuning, the installed ids, and a `VehicleFeel` (top speed, drive, brakes, turn rate, handbrake grip, and the grip curve's peak/slide). `withPart` swaps a part within its slot and `feelOf` reads the numbers out of any tuning.
|
|
184
|
+
- `createVehicleDamage` (`@jgengine/core/world`) is fixed-zone crash damage wired to handling and visuals: impacts accumulate into discrete stages, each stage costs grip/drive/brakes/top speed/drag and pulls the steering (`effect`), and each zone shows one bounded dent (`deformation`) that never exceeds its authored `deform`. Parts can detach at a set stage, a total impulse can disable the vehicle, and `snapshot`/`restore` resume mid-crash. `damagedTuning` and `damagedAxis` fold the effect into a tuning or an input, and `modifiers()` carries it as per-tick overrides.
|
|
185
|
+
- `KinematicVehicleTuning.weightTransfer` (`@jgengine/core/world`) adds load-transfer grip: longitudinal transfer under braking/traction and lateral transfer under cornering cut the tire grip curve (tire load sensitivity), so braking into a corner holds less slide than coasting through it. Each tick reports `weightTransfer` and `loadGrip`; omitting the block keeps the previous flat-grip model exactly.
|
|
186
|
+
- `KinematicVehicleModifiers.gripScale` scales the grip curve for one tick, mirroring `VehicleDynamicsModifiers.gripScale` so a damage layer drives either sim.
|
|
187
|
+
- `decal` scene kind (`@jgengine/core/world/decalKind`) and its renderer (`@jgengine/shell/scene/DecalRenderer`): dirt, cracks, paint and blood decals authored as placed markers, draped on the live terrain or standing on an authored tilt, with a deterministic procedural texture per style+seed.
|
|
188
|
+
- `TerrainMaterialLayer.material` (`@jgengine/core/world/terraform`): a material library name whose scanned color map paints that terrain layer. The runtime samples up to four layer maps at each layer's `tiling` and blends them through the authored mask weights (`createTerrainDetailMaterial`'s `layerMaps`), so painted terrain blends between authored layers instead of tiling one flat map.
|
|
189
|
+
- `grass_field` volumes now paint a ground-cover mat under their blades (`groundCover` rule, `@jgengine/shell/terrain/GrassCover`), so an authored patch reads as turf rather than bare ground with stubble. `GrassEnvironmentConfig.groundCover` does the same for `grass()` patches.
|
|
190
|
+
- `ModelConfig.wind` (`@jgengine/core/game/playableGame`) and `applyModelWind` (`@jgengine/shell/render/modelWind`): per-model foliage sway with height-ramped travel, shared gust clock and per-model strength/speed/direction.
|
|
191
|
+
|
|
192
|
+
### Fixed
|
|
193
|
+
|
|
194
|
+
- The player shell re-renders only for the ctx slices it reads — the possessed entity and the touch control mode — instead of on every `ctx` write. Subscribing the shell root to the raw ctx version reconciled the canvas, the whole actor tree and the HUD on each per-frame entity pose write, which is what `subscribeMembership` and the markers' imperative `useFrame` pose reads exist to avoid (#625). Scrap Signal: 86.6 ms → 16.7 ms per frame at the same 621 draws; Harbor Heat: 102.8 ms → 46.2 ms at the same ~7.2k draws.
|
|
195
|
+
- The `ocean()` environment feature passes the live ground to its shader, so the sea shades shallow→deep and its shoreline foam band lands where the bed meets the surface.
|
|
196
|
+
|
|
197
|
+
### Numbers
|
|
198
|
+
|
|
199
|
+
- Test catalog (60/25/10/4/1 rarity weights) over 10,000 drops at level 20, fixed seed: common 59.41% (target 60%), uncommon 25.66% (25%), rare 9.58% (10%), epic 4.20% (4%), legendary 1.15% (1%); every tier inside its 4σ binomial bound. All 10,000 items validated with zero issues and composed names (e.g. `Voss Rare Long Keen Lightframe of Reach`, `Tallow Long Lightframe`, `Voss Snub Lightframe of Steadying`).
|
|
200
|
+
- Level curve `[[1,1],[15,1.6],[30,2.4],[60,4]]`: a level-60 roll's base and additive affix values are 4× the same seed at level 1; maker personalities move the same roll (`Voss` +power/−cadence, `Tallow` the reverse).
|
|
201
|
+
- A rolled epic projects to a hitscan `WeaponDefinition` (rate, damage, integer magazine capacity, spread, range), passes `assertWeaponDefinition`, and its first `tick` fires one shot and one impact through `createDamageRuntime` with the rolled capacity spent.
|
|
202
|
+
|
|
29
203
|
## 0.26.0
|
|
30
204
|
|
|
31
205
|
### Migrate
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState } from "react";
|
|
3
|
+
import { effectNames } from "@jgengine/core/vfx/effectLibrary";
|
|
3
4
|
import { useStoreSelector } from "./useStoreSelector.js";
|
|
4
5
|
const modes = ["clear", "rain", "snow", "mixed", "dust"];
|
|
5
6
|
function NumberInput({ label, value, onChange, min = 0, step = 0.1 }) {
|
|
@@ -38,7 +39,7 @@ export function SimulationInspector({ session, creatorPolicy }) {
|
|
|
38
39
|
return _jsxs("section", { "aria-label": "Simulation authoring", className: "space-y-4 p-3 text-xs text-neutral-300", children: [_jsx("h3", { className: "text-sm font-semibold text-neutral-100", children: "Weather & simulation" }), _jsx("p", { children: "Metres \u00B7 game seconds \u00B7 wind on world X/Z. Save stores authored inputs; play state saves separately." }), error !== null ? _jsx("p", { role: "alert", className: "text-red-300", children: error }) : null, _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Ambient weather" }), _jsxs("label", { className: "flex justify-between", children: ["Mode", _jsx("select", { "aria-label": "Weather mode", value: weather.ambient?.mode ?? "clear", onChange: (event) => setWeather({ ...weather, ambient: { ...weather.ambient, mode: event.target.value, intensity: weather.ambient?.intensity ?? 1 } }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) })] }), _jsx(NumberInput, { label: "Weather intensity", value: weather.ambient?.intensity ?? 0, onChange: (intensity) => setWeather({ ...weather, ambient: { mode: "clear", ...weather.ambient, intensity } }) }), _jsx(NumberInput, { label: "Temperature offset \u00B0C", min: -100, value: weather.ambient?.temperatureOffset ?? 0, onChange: (temperatureOffset) => setWeather({ ...weather, ambient: { mode: "clear", intensity: 0, ...weather.ambient, temperatureOffset } }) }), _jsx(NumberInput, { label: "Wind speed m/s", value: weather.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, wind: { ...weather.wind, speed } }) }), _jsx(NumberInput, { label: "Wind X direction", min: -1, value: weather.wind?.direction?.[0] ?? 1, onChange: (x) => setWeather({ ...weather, wind: { ...weather.wind, direction: [x, weather.wind?.direction?.[1] ?? 0] } }) }), _jsx(NumberInput, { label: "Wind Z direction", min: -1, value: weather.wind?.direction?.[1] ?? 0, onChange: (z) => setWeather({ ...weather, wind: { ...weather.wind, direction: [weather.wind?.direction?.[0] ?? 1, z] } }) })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Named profiles" }), (weather.profiles ?? []).map((profile, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("label", { children: ["Profile id", _jsx("input", { "aria-label": `Profile ${index + 1} id`, value: profile.id, onChange: (event) => {
|
|
39
40
|
const nextId = event.target.value;
|
|
40
41
|
setWeather({ ...weather, initialProfileId: weather.initialProfileId === profile.id ? nextId : weather.initialProfileId, profiles: weather.profiles.map((row, i) => i === index ? { ...row, id: nextId } : row), schedule: weather.schedule?.map((row) => row.profileId === profile.id ? { ...row, profileId: nextId } : row) });
|
|
41
|
-
} })] }), _jsx("select", { "aria-label": `Profile ${profile.id} mode`, value: profile.mode, onChange: (event) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, mode: event.target.value } : row) }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) }), _jsx(NumberInput, { label: `Profile ${profile.id} intensity`, value: profile.intensity, onChange: (intensity) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, intensity } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: weather.profiles.filter((_, i) => i !== index), initialProfileId: weather.initialProfileId === profile.id ? undefined : weather.initialProfileId, schedule: weather.schedule?.filter((row) => row.profileId !== profile.id) }), children: "Remove profile" })] }, profile.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: [...(weather.profiles ?? []), { id: id("profile", weather.profiles ?? []), mode: "rain", intensity: 0.5 }] }), children: "Add weather profile" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Schedule" }), (weather.schedule ?? []).map((transition, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("select", { "aria-label": `Transition ${index + 1} profile`, value: transition.profileId, onChange: (event) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, profileId: event.target.value } : row) }), children: weather.profiles?.map((profile) => _jsx("option", { children: profile.id }, profile.id)) }), _jsx(NumberInput, { label: `Transition ${index + 1} at seconds`, value: transition.atSeconds, onChange: (atSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, atSeconds } : row) }) }), _jsx(NumberInput, { label: `Transition ${index + 1} duration seconds`, value: transition.transitionSeconds ?? 0, onChange: (transitionSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, transitionSeconds } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, schedule: weather.schedule.filter((_, i) => i !== index) }), children: "Remove transition" })] }, index)), _jsx("button", { type: "button", disabled: !weather.profiles?.length, onClick: () => setWeather({ ...weather, schedule: [...(weather.schedule ?? []), { atSeconds: (weather.schedule?.at(-1)?.atSeconds ?? 0) + 10, profileId: weather.profiles[0].id, transitionSeconds: 2 }] }), children: "Add transition" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Local wind zones" }), (weather.zones ?? []).map((zone, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: zone.id }), _jsx(NumberInput, { label: `${zone.id} X metres`, min: -1e6, value: zone.center[0], onChange: (x) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [x, row.center[1]] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} Z metres`, min: -1e6, value: zone.center[1], onChange: (z) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [row.center[0], z] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} radius metres`, value: zone.radius, onChange: (radius) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radius } : row) }) }), _jsx(NumberInput, { label: `${zone.id} speed m/s`, value: zone.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, wind: { ...row.wind, speed } } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: zone.radial ?? false, onChange: (event) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radial: event.target.checked } : row) }) }), " Radial wind"] }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: weather.zones.filter((_, i) => i !== index) }), children: "Remove wind zone" })] }, zone.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: [...(weather.zones ?? []), { id: id("wind", weather.zones ?? []), center: [0, 0], radius: 10, falloff: 5, wind: { direction: [1, 0], speed: 3 } }] }), children: "Add wind zone" })] }), _jsxs("fieldset", { hidden: !allowForces, className: "space-y-2", children: [_jsx("legend", { children: "Spatial forces" }), (simulation?.forces ?? []).map((field, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [[0, 1, 2].map((axis) => _jsx(NumberInput, { label: `Force ${index + 1} ${["X", "Y", "Z"][axis]} metres`, min: -1e6, value: field.center[axis], onChange: (value) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, center: row.center.map((entry, j) => j === axis ? value : entry) } : row) }) }, axis)), field.shape.kind === "sphere" ? _jsx(NumberInput, { label: `Force ${index + 1} radius metres`, value: field.shape.radius, onChange: (radius) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, shape: { kind: "sphere", radius } } : row) }) }) : _jsx("span", { children: "Box bounds are available in advanced editing." }), _jsx(NumberInput, { label: `Force ${index + 1} attraction m/s²`, min: -1e6, value: field.strength, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, strength } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} vortex m/s²`, min: -1e6, value: field.vortex?.strength ?? 0, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], ...row.vortex, strength } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} lift m/s²`, min: -1e6, value: field.vortex?.lift ?? 0, onChange: (lift) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], strength: 0, ...row.vortex, lift } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} falloff`, value: field.attenuation ?? 1, onChange: (attenuation) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, attenuation } : row) }) }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: simulation.forces.filter((_, i) => i !== index) }), children: "Remove force" })] }, index)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: [...(simulation?.forces ?? []), { center: [0, 0, 0], shape: { kind: "sphere", radius: 10 }, strength: 0, attenuation: 1, vortex: { axis: [0, 1, 0], strength: 2, lift: 1 }, mask: 1 }] }), children: "Add spatial force" })] }), _jsxs("fieldset", { hidden: !allowEmitters, className: "space-y-2", children: [_jsx("legend", { children: "Named effects" }), (simulation?.emitters ?? []).map((emitter, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: emitter.id }), _jsx("select", { "aria-label": `${emitter.id} appearance`, value: emitter.options?.render?.shape ?? "point", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, render: { shape: event.target.value } } } : row) }), children: ["point", "streak", "flake", "flame", "smoke", "ribbon", "ripple"].map((shape) => _jsx("option", { children: shape }, shape)) }), _jsx(NumberInput, { label: `${emitter.id} particles/second`, value: emitter.config.rate ?? 0, onChange: (rate) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, rate } } : row) }) }), _jsx(NumberInput, { label: `${emitter.id} particle capacity`, step: 1, value: emitter.config.max ?? 512, onChange: (max) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, max } } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${emitter.id} ${axis.toUpperCase()} metres`, min: -1e6, value: emitter.position[axis], onChange: (value) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: emitter.options?.active !== false, onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, active: event.target.checked } } : row) }) }), " Spawning"] }), _jsxs("label", { children: ["Fire binding", _jsxs("select", { "aria-label": `${emitter.id} fire area`, value: emitter.fireAreaId ?? "", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, fireAreaId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Independent" }), simulation?.fires?.map((area) => _jsx("option", { children: area.id }, area.id))] })] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: simulation.emitters.filter((_, i) => i !== index) }), children: "Remove emitter" })] }, emitter.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: [...(simulation?.emitters ?? []), { id: id("emitter", simulation?.emitters ?? []), position: { x: 0, y: 1, z: 0 }, config: { rate: 8, max: 64, lifetime: { min: 1, max: 2 }, seed: "authored" }, options: { render: { shape: "smoke" } } }] }), children: "Add named emitter" })] }), _jsxs("fieldset", { hidden: !allowFire, className: "space-y-2", children: [_jsx("legend", { children: "Fire areas" }), (simulation?.fires ?? []).map((area, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: area.id }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${area.id} ${axis.toUpperCase()} metres`, min: -1e6, value: area.position[axis], onChange: (value) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsx(NumberInput, { label: `${area.id} cell size metres`, value: area.config.cellSize, onChange: (cellSize) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, cellSize } } : row) }) }), _jsx(NumberInput, { label: `${area.id} fuel consumed/second`, value: area.config.burnRate ?? 0.25, onChange: (burnRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, burnRate } } : row) }) }), _jsx(NumberInput, { label: `${area.id} rain cooling heat/second`, value: area.extinguishRate ?? 0, onChange: (extinguishRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, extinguishRate } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: (area.ignitions?.length ?? 0) > 0, onChange: (event) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, ignitions: event.target.checked ? [{ col: 0, row: 0 }] : [] } : row) }) }), " Ignite first cell on play"] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: simulation.fires.filter((_, i) => i !== index), emitters: simulation?.emitters?.map((emitter) => emitter.fireAreaId === area.id ? { ...emitter, fireAreaId: undefined } : emitter) }), children: "Remove fire area" })] }, area.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: [...(simulation?.fires ?? []), { id: id("fire", simulation?.fires ?? []), position: { x: 0, y: 0, z: 0 }, config: { cols: 4, rows: 4, cellSize: 1 }, ignitions: [] }] }), children: "Add fire area" })] }), _jsxs("fieldset", { hidden: !allowFlocks, className: "space-y-2", children: [_jsx("legend", { children: "Flock habitats" }), (simulation?.habitats ?? []).map((habitat, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("span", { children: [habitat.id, " \u00B7 ", habitat.species] }), _jsxs("select", { "aria-label": `${habitat.id} role`, value: habitat.role, onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, role: event.target.value } : row) }), children: [_jsx("option", { children: "cosmetic" }), _jsx("option", { children: "gameplay" })] }), _jsx(NumberInput, { label: `${habitat.id} bird count`, step: 1, value: habitat.count, onChange: (count) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, count } : row) }) }), _jsx(NumberInput, { label: `${habitat.id} radius metres`, value: habitat.radius, onChange: (radius) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, radius } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${habitat.id} ${axis.toUpperCase()} metres`, min: -1e6, value: habitat.position[axis], onChange: (value) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("select", { "aria-label": `${habitat.id} route`, value: habitat.routeId ?? "", onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, routeId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Stay in habitat" }), session.getState().document.paths.filter((path) => path.points.length > 1).map((path) => _jsx("option", { value: path.id, children: path.label ?? path.id }, path.id))] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, habitats: simulation.habitats.filter((_, i) => i !== index) }), children: "Remove habitat" })] }, habitat.id)), _jsxs("label", { children: ["Game species", _jsx("input", { "aria-label": "Game species", value: species, onChange: (event) => setSpecies(event.target.value) })] }), _jsx("button", { type: "button", disabled: !species.trim(), onClick: () => update({ ...simulation, habitats: [...(simulation?.habitats ?? []), { id: id("habitat", simulation?.habitats ?? []), species: species.trim(), position: { x: 0, y: 4, z: 0 }, radius: 8, count: 8, seed: "authored", role: "cosmetic", steering: { maxSpeed: 4, separationRadius: 1, neighborRadius: 5 } }] }), children: "Add flock habitat" })] }), _jsx("button", { type: "button", onClick: () => setAdvanced(JSON.stringify(simulation ?? {}, null, 2)), children: "Edit complete simulation data" }), advanced !== null ? _jsxs("div", { className: "space-y-2", children: [_jsx("textarea", { "aria-label": "Simulation JSON", className: "h-60 w-full bg-neutral-900 p-2 font-mono", value: advanced, onChange: (event) => setAdvanced(event.target.value) }), _jsx("button", { type: "button", onClick: () => { try {
|
|
42
|
+
} })] }), _jsx("select", { "aria-label": `Profile ${profile.id} mode`, value: profile.mode, onChange: (event) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, mode: event.target.value } : row) }), children: modes.map((mode) => _jsx("option", { children: mode }, mode)) }), _jsx(NumberInput, { label: `Profile ${profile.id} intensity`, value: profile.intensity, onChange: (intensity) => setWeather({ ...weather, profiles: weather.profiles.map((row, i) => i === index ? { ...row, intensity } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: weather.profiles.filter((_, i) => i !== index), initialProfileId: weather.initialProfileId === profile.id ? undefined : weather.initialProfileId, schedule: weather.schedule?.filter((row) => row.profileId !== profile.id) }), children: "Remove profile" })] }, profile.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, profiles: [...(weather.profiles ?? []), { id: id("profile", weather.profiles ?? []), mode: "rain", intensity: 0.5 }] }), children: "Add weather profile" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Schedule" }), (weather.schedule ?? []).map((transition, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("select", { "aria-label": `Transition ${index + 1} profile`, value: transition.profileId, onChange: (event) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, profileId: event.target.value } : row) }), children: weather.profiles?.map((profile) => _jsx("option", { children: profile.id }, profile.id)) }), _jsx(NumberInput, { label: `Transition ${index + 1} at seconds`, value: transition.atSeconds, onChange: (atSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, atSeconds } : row) }) }), _jsx(NumberInput, { label: `Transition ${index + 1} duration seconds`, value: transition.transitionSeconds ?? 0, onChange: (transitionSeconds) => setWeather({ ...weather, schedule: weather.schedule.map((row, i) => i === index ? { ...row, transitionSeconds } : row) }) }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, schedule: weather.schedule.filter((_, i) => i !== index) }), children: "Remove transition" })] }, index)), _jsx("button", { type: "button", disabled: !weather.profiles?.length, onClick: () => setWeather({ ...weather, schedule: [...(weather.schedule ?? []), { atSeconds: (weather.schedule?.at(-1)?.atSeconds ?? 0) + 10, profileId: weather.profiles[0].id, transitionSeconds: 2 }] }), children: "Add transition" })] }), _jsxs("fieldset", { hidden: !allowWeather, className: "space-y-2", children: [_jsx("legend", { children: "Local wind zones" }), (weather.zones ?? []).map((zone, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: zone.id }), _jsx(NumberInput, { label: `${zone.id} X metres`, min: -1e6, value: zone.center[0], onChange: (x) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [x, row.center[1]] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} Z metres`, min: -1e6, value: zone.center[1], onChange: (z) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, center: [row.center[0], z] } : row) }) }), _jsx(NumberInput, { label: `${zone.id} radius metres`, value: zone.radius, onChange: (radius) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radius } : row) }) }), _jsx(NumberInput, { label: `${zone.id} speed m/s`, value: zone.wind?.speed ?? 0, onChange: (speed) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, wind: { ...row.wind, speed } } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: zone.radial ?? false, onChange: (event) => setWeather({ ...weather, zones: weather.zones.map((row, i) => i === index ? { ...row, radial: event.target.checked } : row) }) }), " Radial wind"] }), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: weather.zones.filter((_, i) => i !== index) }), children: "Remove wind zone" })] }, zone.id)), _jsx("button", { type: "button", onClick: () => setWeather({ ...weather, zones: [...(weather.zones ?? []), { id: id("wind", weather.zones ?? []), center: [0, 0], radius: 10, falloff: 5, wind: { direction: [1, 0], speed: 3 } }] }), children: "Add wind zone" })] }), _jsxs("fieldset", { hidden: !allowForces, className: "space-y-2", children: [_jsx("legend", { children: "Spatial forces" }), (simulation?.forces ?? []).map((field, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [[0, 1, 2].map((axis) => _jsx(NumberInput, { label: `Force ${index + 1} ${["X", "Y", "Z"][axis]} metres`, min: -1e6, value: field.center[axis], onChange: (value) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, center: row.center.map((entry, j) => j === axis ? value : entry) } : row) }) }, axis)), field.shape.kind === "sphere" ? _jsx(NumberInput, { label: `Force ${index + 1} radius metres`, value: field.shape.radius, onChange: (radius) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, shape: { kind: "sphere", radius } } : row) }) }) : _jsx("span", { children: "Box bounds are available in advanced editing." }), _jsx(NumberInput, { label: `Force ${index + 1} attraction m/s²`, min: -1e6, value: field.strength, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, strength } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} vortex m/s²`, min: -1e6, value: field.vortex?.strength ?? 0, onChange: (strength) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], ...row.vortex, strength } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} lift m/s²`, min: -1e6, value: field.vortex?.lift ?? 0, onChange: (lift) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, vortex: { axis: [0, 1, 0], strength: 0, ...row.vortex, lift } } : row) }) }), _jsx(NumberInput, { label: `Force ${index + 1} falloff`, value: field.attenuation ?? 1, onChange: (attenuation) => update({ ...simulation, forces: simulation.forces.map((row, i) => i === index ? { ...row, attenuation } : row) }) }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: simulation.forces.filter((_, i) => i !== index) }), children: "Remove force" })] }, index)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, forces: [...(simulation?.forces ?? []), { center: [0, 0, 0], shape: { kind: "sphere", radius: 10 }, strength: 0, attenuation: 1, vortex: { axis: [0, 1, 0], strength: 2, lift: 1 }, mask: 1 }] }), children: "Add spatial force" })] }), _jsxs("fieldset", { hidden: !allowEmitters, className: "space-y-2", children: [_jsx("legend", { children: "Named effects" }), (simulation?.emitters ?? []).map((emitter, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: emitter.id }), _jsxs("label", { children: ["Effect", _jsxs("select", { "aria-label": `${emitter.id} effect`, value: emitter.effect ?? "", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? (event.target.value === "" ? { ...row, effect: undefined, config: row.config ?? { rate: 8, max: 64, lifetime: { min: 1, max: 2 }, seed: row.id } } : { ...row, effect: event.target.value, config: undefined }) : row) }), children: [_jsx("option", { value: "", children: "Custom config" }), effectNames().map((name) => _jsx("option", { children: name }, name))] })] }), emitter.effect === undefined ? _jsxs(_Fragment, { children: [_jsx("select", { "aria-label": `${emitter.id} appearance`, value: emitter.options?.render?.shape ?? "point", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, render: { shape: event.target.value } } } : row) }), children: ["point", "streak", "flake", "flame", "smoke", "ribbon", "ripple"].map((shape) => _jsx("option", { children: shape }, shape)) }), _jsx(NumberInput, { label: `${emitter.id} particles/second`, value: emitter.config?.rate ?? 0, onChange: (rate) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, rate } } : row) }) }), _jsx(NumberInput, { label: `${emitter.id} particle capacity`, step: 1, value: emitter.config?.max ?? 512, onChange: (max) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, config: { ...row.config, max } } : row) }) })] }) : _jsx("span", { children: emitter.effect }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${emitter.id} ${axis.toUpperCase()} metres`, min: -1e6, value: emitter.position[axis], onChange: (value) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: emitter.options?.active !== false, onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, options: { ...row.options, active: event.target.checked } } : row) }) }), " Spawning"] }), _jsxs("label", { children: ["Fire binding", _jsxs("select", { "aria-label": `${emitter.id} fire area`, value: emitter.fireAreaId ?? "", onChange: (event) => update({ ...simulation, emitters: simulation.emitters.map((row, i) => i === index ? { ...row, fireAreaId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Independent" }), simulation?.fires?.map((area) => _jsx("option", { children: area.id }, area.id))] })] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: simulation.emitters.filter((_, i) => i !== index) }), children: "Remove emitter" })] }, emitter.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, emitters: [...(simulation?.emitters ?? []), { id: id("emitter", simulation?.emitters ?? []), position: { x: 0, y: 1, z: 0 }, effect: effectNames()[0] }] }), children: "Add named emitter" })] }), _jsxs("fieldset", { hidden: !allowFire, className: "space-y-2", children: [_jsx("legend", { children: "Fire areas" }), (simulation?.fires ?? []).map((area, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsx("span", { children: area.id }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${area.id} ${axis.toUpperCase()} metres`, min: -1e6, value: area.position[axis], onChange: (value) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsx(NumberInput, { label: `${area.id} cell size metres`, value: area.config.cellSize, onChange: (cellSize) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, cellSize } } : row) }) }), _jsx(NumberInput, { label: `${area.id} fuel consumed/second`, value: area.config.burnRate ?? 0.25, onChange: (burnRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, config: { ...row.config, burnRate } } : row) }) }), _jsx(NumberInput, { label: `${area.id} rain cooling heat/second`, value: area.extinguishRate ?? 0, onChange: (extinguishRate) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, extinguishRate } : row) }) }), _jsxs("label", { children: [_jsx("input", { type: "checkbox", checked: (area.ignitions?.length ?? 0) > 0, onChange: (event) => update({ ...simulation, fires: simulation.fires.map((row, i) => i === index ? { ...row, ignitions: event.target.checked ? [{ col: 0, row: 0 }] : [] } : row) }) }), " Ignite first cell on play"] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: simulation.fires.filter((_, i) => i !== index), emitters: simulation?.emitters?.map((emitter) => emitter.fireAreaId === area.id ? { ...emitter, fireAreaId: undefined } : emitter) }), children: "Remove fire area" })] }, area.id)), _jsx("button", { type: "button", onClick: () => update({ ...simulation, fires: [...(simulation?.fires ?? []), { id: id("fire", simulation?.fires ?? []), position: { x: 0, y: 0, z: 0 }, config: { cols: 4, rows: 4, cellSize: 1 }, ignitions: [] }] }), children: "Add fire area" })] }), _jsxs("fieldset", { hidden: !allowFlocks, className: "space-y-2", children: [_jsx("legend", { children: "Flock habitats" }), (simulation?.habitats ?? []).map((habitat, index) => _jsxs("div", { className: "space-y-1 border-l border-neutral-700 pl-2", children: [_jsxs("span", { children: [habitat.id, " \u00B7 ", habitat.species] }), _jsxs("select", { "aria-label": `${habitat.id} role`, value: habitat.role, onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, role: event.target.value } : row) }), children: [_jsx("option", { children: "cosmetic" }), _jsx("option", { children: "gameplay" })] }), _jsx(NumberInput, { label: `${habitat.id} bird count`, step: 1, value: habitat.count, onChange: (count) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, count } : row) }) }), _jsx(NumberInput, { label: `${habitat.id} radius metres`, value: habitat.radius, onChange: (radius) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, radius } : row) }) }), ["x", "y", "z"].map((axis) => _jsx(NumberInput, { label: `${habitat.id} ${axis.toUpperCase()} metres`, min: -1e6, value: habitat.position[axis], onChange: (value) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, position: { ...row.position, [axis]: value } } : row) }) }, axis)), _jsxs("select", { "aria-label": `${habitat.id} route`, value: habitat.routeId ?? "", onChange: (event) => update({ ...simulation, habitats: simulation.habitats.map((row, i) => i === index ? { ...row, routeId: event.target.value || undefined } : row) }), children: [_jsx("option", { value: "", children: "Stay in habitat" }), session.getState().document.paths.filter((path) => path.points.length > 1).map((path) => _jsx("option", { value: path.id, children: path.label ?? path.id }, path.id))] }), _jsx("button", { type: "button", onClick: () => update({ ...simulation, habitats: simulation.habitats.filter((_, i) => i !== index) }), children: "Remove habitat" })] }, habitat.id)), _jsxs("label", { children: ["Game species", _jsx("input", { "aria-label": "Game species", value: species, onChange: (event) => setSpecies(event.target.value) })] }), _jsx("button", { type: "button", disabled: !species.trim(), onClick: () => update({ ...simulation, habitats: [...(simulation?.habitats ?? []), { id: id("habitat", simulation?.habitats ?? []), species: species.trim(), position: { x: 0, y: 4, z: 0 }, radius: 8, count: 8, seed: "authored", role: "cosmetic", steering: { maxSpeed: 4, separationRadius: 1, neighborRadius: 5 } }] }), children: "Add flock habitat" })] }), _jsx("button", { type: "button", onClick: () => setAdvanced(JSON.stringify(simulation ?? {}, null, 2)), children: "Edit complete simulation data" }), advanced !== null ? _jsxs("div", { className: "space-y-2", children: [_jsx("textarea", { "aria-label": "Simulation JSON", className: "h-60 w-full bg-neutral-900 p-2 font-mono", value: advanced, onChange: (event) => setAdvanced(event.target.value) }), _jsx("button", { type: "button", onClick: () => { try {
|
|
42
43
|
update(JSON.parse(advanced));
|
|
43
44
|
}
|
|
44
45
|
catch (reason) {
|
package/dist/handlers/terrain.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createTerrainSnapshot, editableTerrainFromSnapshot, } from "@jgengine/core/world/terraform";
|
|
2
2
|
import { resolveScatter, resolveScatterRegion, scatterRegionEstimate, scatterRegionFromPath, SCATTER_PATH_KIND, } from "@jgengine/core/world/scatterRegion";
|
|
3
3
|
import { TERRAIN_MATERIALS } from "../uiStore.js";
|
|
4
|
+
import { libraryMaterialAsset } from "@jgengine/assets/catalogs/materialLibrary";
|
|
4
5
|
function brushError(request) {
|
|
5
6
|
if (![request.x, request.z, request.radius ?? 8, request.strength ?? 1].every(Number.isFinite))
|
|
6
7
|
return "brush coordinates, radius and strength must be finite";
|
|
@@ -153,7 +154,24 @@ export const terrainHandlers = {
|
|
|
153
154
|
if (new Set(request.layers.map((layer) => layer.id)).size !== request.layers.length || request.layers.some((layer) => layer.id.length === 0 || layer.surface.length === 0)) {
|
|
154
155
|
return { ok: false, error: "terrain layers require unique nonempty ids and nonempty surfaces" };
|
|
155
156
|
}
|
|
156
|
-
|
|
157
|
+
const layers = [];
|
|
158
|
+
for (const layer of request.layers) {
|
|
159
|
+
if (layer.material === undefined || layer.material.length === 0) {
|
|
160
|
+
layers.push({ ...layer, materialUrl: undefined });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
let url;
|
|
164
|
+
try {
|
|
165
|
+
url = libraryMaterialAsset(layer.material).textures?.color?.url;
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return { ok: false, error: `unknown material library name "${layer.material}"` };
|
|
169
|
+
}
|
|
170
|
+
if (url === undefined)
|
|
171
|
+
return { ok: false, error: `material "${layer.material}" has no color map` };
|
|
172
|
+
layers.push({ ...layer, materialUrl: url });
|
|
173
|
+
}
|
|
174
|
+
ctx.session.dispatch({ type: "setTerrainLayers", layers });
|
|
157
175
|
return { ok: true, result: { layers: ctx.session.getState().document.terrain?.layers ?? [] } };
|
|
158
176
|
},
|
|
159
177
|
blend_terrain: (ctx, request) => {
|
package/dist/mcp/fieldSpec.js
CHANGED
|
@@ -72,7 +72,7 @@ export const RPC_FIELD_SCHEMAS = {
|
|
|
72
72
|
auto_paint: [req(s("surface")), n("minSlope"), n("maxSlope"), n("minHeight"), n("maxHeight")],
|
|
73
73
|
terrain_materials: [],
|
|
74
74
|
terrain_layers: [],
|
|
75
|
-
set_terrain_layers: [req(oa("layers", [req(s("id")), req(s("surface")), n("roughness"), n("tiling"), b("triplanar"), s("tint"), n("opacity")]))],
|
|
75
|
+
set_terrain_layers: [req(oa("layers", [req(s("id")), req(s("surface")), n("roughness"), n("tiling"), b("triplanar"), s("tint"), n("opacity"), s("material")]))],
|
|
76
76
|
blend_terrain: [req(s("surface")), req(n("x")), req(n("z")), n("radius"), n("strength"), s("shape", SHAPE)],
|
|
77
77
|
convert_scatter: [req(s("pathId"))],
|
|
78
78
|
generate_streets: [s("volumeId"), v3("center"), n("halfX"), n("halfZ"), s("seed"), s("mode", ["net", "circuit"]), s("kind"), o("params")],
|
package/dist/mcp/tools.js
CHANGED
|
@@ -31,7 +31,7 @@ const TOOL_DESCRIPTIONS = [
|
|
|
31
31
|
{ name: "select", description: "Select editor object ids." },
|
|
32
32
|
{ name: "camera_goto", description: "Focus the editor camera on an id or world x/z. Optional distance/pitch/yaw/height reposition the orbit camera (pitch is degrees above the horizon; 90 = straight-down aerial) so an aerial composes in one call." },
|
|
33
33
|
{ name: "camera_frame", description: "Frame the whole editor document. Optional distance/pitch/yaw/height compose an aerial; when pitch is set without distance the region is auto-fit into view so the camera never buries in terrain." },
|
|
34
|
-
{ name: "get_simulation", description: "Read authored weather profiles/schedules, wind zones, named emitters, fire areas and flock habitats. Distances are metres, time is game seconds, wind is XZ metres/second." },
|
|
34
|
+
{ name: "get_simulation", description: "Read authored weather profiles/schedules, wind zones, named emitters (a library effect name or a full config), fire areas and flock habitats. Distances are metres, time is game seconds, wind is XZ metres/second." },
|
|
35
35
|
{ name: "set_simulation", description: "Replace validated authored simulation inputs atomically as one undo step. Pass null to clear. Runtime heat, particles and accumulation never overwrite authored inputs." },
|
|
36
36
|
{ name: "scene_summary", description: "Compact summary of the editor document and bounds." },
|
|
37
37
|
{ name: "export_document", description: "Export the editor document as JSON text." },
|
|
@@ -64,7 +64,7 @@ const TOOL_DESCRIPTIONS = [
|
|
|
64
64
|
{ name: "fill_terrain", description: "Fill the whole terrain with one material, or null to clear all painted surfaces." },
|
|
65
65
|
{ name: "auto_paint", description: "Paint a material into every cell matching a slope/height rule (e.g. rock on steep, snow up high)." },
|
|
66
66
|
{ name: "terrain_materials", description: "List the terrain paint palette (material id, label, color)." },
|
|
67
|
-
{ name: "terrain_layers", description: "List the terrain material layer stack (id, surface, roughness/tiling/triplanar/tint/opacity)." },
|
|
67
|
+
{ name: "terrain_layers", description: "List the terrain material layer stack (id, surface, roughness/tiling/triplanar/tint/opacity/material — a material library name paints the layer with its scanned map)." },
|
|
68
68
|
{ name: "set_terrain_layers", description: "Replace the reorderable terrain material layer stack; a params-only edit keeps painted blends." },
|
|
69
69
|
{ name: "blend_terrain", description: "Blend-paint a material layer's weight at x/z (weighted multi-layer blend); auto-adds the layer." },
|
|
70
70
|
{ name: "convert_scatter", description: "Detach a foliage/scatter region into individually-editable placed prop markers, removing the region." },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jgengine/editor",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.0",
|
|
4
4
|
"description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"test": "bun test src"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@jgengine/assets": "^0.
|
|
35
|
-
"@jgengine/core": "^0.
|
|
36
|
-
"@jgengine/navbake": "^0.
|
|
37
|
-
"@jgengine/react": "^0.
|
|
38
|
-
"@jgengine/shell": "^0.
|
|
34
|
+
"@jgengine/assets": "^0.28.0",
|
|
35
|
+
"@jgengine/core": "^0.28.0",
|
|
36
|
+
"@jgengine/navbake": "^0.28.0",
|
|
37
|
+
"@jgengine/react": "^0.28.0",
|
|
38
|
+
"@jgengine/shell": "^0.28.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"@react-three/drei": "^10.0.0",
|
package/skills/jgengine/api.md
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
8
8
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
9
|
-
- `VERSION` (const): const VERSION: "0.
|
|
9
|
+
- `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
10
10
|
|
|
11
11
|
## @jgengine/core/authoring
|
|
12
12
|
|
|
@@ -285,7 +285,7 @@
|
|
|
285
285
|
|
|
286
286
|
- `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
|
|
287
287
|
- `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
|
|
288
|
-
- `VERSION` (const): const VERSION: "0.
|
|
288
|
+
- `VERSION` (const): const VERSION: "0.28.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
|
|
289
289
|
|
|
290
290
|
## @jgengine/core/runtime/adapter
|
|
291
291
|
|
|
@@ -343,7 +343,7 @@
|
|
|
343
343
|
|
|
344
344
|
## @jgengine/core/editor/simulation
|
|
345
345
|
|
|
346
|
-
- `AuthoredEmitter` (interface): interface AuthoredEmitter — A named emitter authored in metres
|
|
346
|
+
- `AuthoredEmitter` (interface): interface AuthoredEmitter — A named emitter authored in metres: either a library effect name or a full config.
|
|
347
347
|
- `AuthoredFireArea` (interface): interface AuthoredFireArea — A bounded fire grid; fuel and damage policy remain game content.
|
|
348
348
|
- `AuthoredFlockHabitat` (interface): interface AuthoredFlockHabitat — An authored habitat selects a game species and optionally follows a document path.
|
|
349
349
|
- `EditorSimulation` (interface): interface EditorSimulation — Authored simulation inputs. Runtime particles, heat, accumulation and actors save separately.
|