@jgengine/shell 0.22.0 → 0.23.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 +96 -0
- package/dist/GamePlayerShell.js +18 -3
- package/dist/GameUiPreview.js +2 -1
- package/dist/Shell3dPresentation.js +13 -8
- package/dist/ShellChrome.d.ts +1 -1
- package/dist/ShellChrome.js +4 -2
- package/dist/ShellHudPresentation.js +2 -1
- package/dist/ShellNotices.d.ts +26 -0
- package/dist/ShellNotices.js +51 -0
- package/dist/audio/AudioComponents.d.ts +3 -1
- package/dist/audio/AudioComponents.js +5 -2
- package/dist/audio/listenerAnchor.d.ts +15 -0
- package/dist/audio/listenerAnchor.js +17 -0
- package/dist/audio/playableAudio.d.ts +9 -0
- package/dist/audio/playableAudio.js +11 -0
- package/dist/commandSink.d.ts +1 -1
- package/dist/commandSink.js +8 -3
- package/dist/defineGame.js +3 -1
- package/dist/devtools/DevtoolsOverlay.js +1 -0
- package/dist/devtools/agentBridge.d.ts +21 -0
- package/dist/devtools/agentBridge.js +95 -3
- package/dist/devtools/devtoolsReports.d.ts +32 -0
- package/dist/devtools/devtoolsReports.js +26 -0
- package/dist/devtools/simStepper.d.ts +21 -0
- package/dist/devtools/simStepper.js +37 -0
- package/dist/drivers/FrameDriver.js +26 -14
- package/dist/drivers/HudOnlyDriver.js +16 -5
- package/dist/environment/EnvironmentScene.js +19 -7
- package/dist/environment/RoadJunctions.d.ts +3 -1
- package/dist/environment/RoadJunctions.js +9 -2
- package/dist/environment/RoadRibbons.d.ts +3 -1
- package/dist/environment/RoadRibbons.js +16 -3
- package/dist/environment/terrainGroundColor.js +3 -3
- package/dist/materialOverride.js +4 -2
- package/dist/playSurfaceFocus.d.ts +16 -0
- package/dist/playSurfaceFocus.js +42 -0
- package/dist/postfx/PostProcessing.d.ts +3 -1
- package/dist/postfx/PostProcessing.js +14 -6
- package/dist/registry.d.ts +5 -1
- package/dist/render/CascadedShadows.d.ts +2 -0
- package/dist/render/CascadedShadows.js +4 -0
- package/dist/render/EntityPreview.js +1 -1
- package/dist/render/EnvironmentLighting.d.ts +26 -7
- package/dist/render/EnvironmentLighting.js +40 -27
- package/dist/render/SceneLighting.js +2 -2
- package/dist/render/modelLoad.d.ts +7 -0
- package/dist/render/modelLoad.js +13 -0
- package/dist/render/modelRender.d.ts +1 -0
- package/dist/render/modelRender.js +3 -0
- package/dist/render/modelSurface.d.ts +39 -0
- package/dist/render/modelSurface.js +206 -0
- package/dist/render/useModelInstance.d.ts +1 -1
- package/dist/render/useModelInstance.js +2 -2
- package/dist/scatter/scatterModels.js +3 -1
- package/dist/scene/AuthoredScene.d.ts +11 -3
- package/dist/scene/AuthoredScene.js +17 -9
- package/dist/scene/CityRenderer.js +86 -24
- package/dist/scene/cityJunctionSurface.d.ts +7 -0
- package/dist/scene/cityJunctionSurface.js +34 -0
- package/dist/settings/settingsController.js +1 -1
- package/dist/terrain/index.d.ts +4 -0
- package/dist/terrain/index.js +4 -0
- package/dist/terrain/surfaceRibbonMaterial.d.ts +62 -0
- package/dist/terrain/surfaceRibbonMaterial.js +227 -0
- package/dist/terrain/terrainDetailMaterial.d.ts +33 -10
- package/dist/terrain/terrainDetailMaterial.js +149 -63
- package/dist/terrain/terrainPaintColor.d.ts +12 -3
- package/dist/terrain/terrainPaintColor.js +46 -1
- package/dist/terrain/terrainQuality.d.ts +5 -0
- package/dist/terrain/terrainQuality.js +7 -0
- package/dist/terrain/terrainSurfaceShader.d.ts +16 -0
- package/dist/terrain/terrainSurfaceShader.js +266 -0
- package/dist/terrain/terrainSurfaces.d.ts +59 -0
- package/dist/terrain/terrainSurfaces.js +199 -0
- package/dist/world/StaticObjectModels.d.ts +1 -1
- package/dist/world/StaticObjectModels.js +1 -1
- package/dist/world/WorldScene.js +1 -1
- package/dist/world/staticObjectBatches.d.ts +5 -1
- package/dist/world/staticObjectBatches.js +9 -5
- package/dist/world/worldBarSamples.d.ts +13 -1
- package/dist/world/worldBarSamples.js +40 -8
- package/package.json +5 -5
- package/skills/jgengine/api.md +18 -18
- package/skills/jgengine-ui/SKILL.md +4 -0
- package/skills/jgengine-ui/api.md +233 -37
- package/skills/jgengine-ui/capabilities.md +52 -0
- package/skills/jgengine-ui/reference-primitives.md +4 -4
- package/skills/jgengine-world/api.md +27 -14
- package/skills/jgengine-world/capabilities.md +4 -0
- package/skills/jgengine-world/reference.md +23 -8
package/CHANGELOG.md
CHANGED
|
@@ -26,6 +26,102 @@ 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.23.0
|
|
30
|
+
|
|
31
|
+
### Migrate
|
|
32
|
+
|
|
33
|
+
- **Bump lockstep SDK packages to `^0.23.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.20.0`; `@jgengine/github` is `0.10.0`.
|
|
34
|
+
|
|
35
|
+
### Added
|
|
36
|
+
|
|
37
|
+
- `player_pose` agent-bridge verb (`@jgengine/shell`, `window.__jgengineAgent`, `drive --rpc '{"method":"player_pose"}'`) returns the local player's position and heading, and `debug_snapshot` carries the same `player` field, so a drive can prove movement from position deltas instead of screenshots.
|
|
38
|
+
- `sim_advance` agent-bridge verb (`@jgengine/shell`, `drive --rpc '{"method":"sim_advance","seconds":30}'`) steps the mounted sim driver — input, movement, `onTick`, behaviors, bound actions — in 0.05 s frames without rendering, so a headless drive can reach waves, shifts and timers that never arrive at software-rendered frame rates. Capped at 120 s per call.
|
|
39
|
+
- `time_scale` agent-bridge verb sets the game clock's timescale and reports `{ timescale, now }`.
|
|
40
|
+
- `GameContextObjectEntry.scenery` (`@jgengine/core`): an object kind placed once and left alone. Its runtime placements now render through the shared instanced static-object batches that editor-authored props already used.
|
|
41
|
+
- `BUILDING_STYLE_PROBABILITIES` and `resolveBuildingProbabilities` (`@jgengine/core/world/buildings`): each `BuildingStyle` now carries facade-part probabilities, so an industrial yard shows few windows and roof plant, a ruin loses awnings and clotheslines, coastal fronts gain awnings. `building({ probabilities })` overrides any part.
|
|
42
|
+
- `debug_summary` agent-bridge verb and `debug_snapshot` `detail: "summary"`: fps, frame/sim ms, draw calls, triangles, entities, warnings and top culprit in about 1 KB. `drive --rpc '{"method":"debug_snapshot"}'` prints the summary; `"detail":"full"` keeps the full snapshot.
|
|
43
|
+
- `modelLoadStarts()` (`@jgengine/shell/render/modelLoad`) counts model streaming bursts for capture quiescence checks.
|
|
44
|
+
- `jgengine` warns when it is not the CLI the caller meant: a global install run inside a jgengine checkout, a project-local `jgengine` shadowed, or a CLI built for an older SDK than the project installs. Harness runs print the CLI version and harness revision.
|
|
45
|
+
- `withGraphicsQualityOverride` and `parseGraphicsQuality` (`@jgengine/core/settings/graphicsSettings`) pin a graphics tier for one session without persisting it.
|
|
46
|
+
- The player shell reads `?quality=<tier>` and pins that tier for the page. `bun run shoot`/`drive --quality draft` use it (low tier, half size) for fast iteration shots; `--quality full` stays the default for PR evidence.
|
|
47
|
+
- `jgengine doctor` flags a silent game: one that neither declares `defineGame({ audio })` cues nor calls `ctx.game.audio.play`/`loop`. Four reference games shipped without a single sound; the fix text points at procedural `SoundDef` synth patches, which need no asset files.
|
|
48
|
+
- `drive --key mouse0:<ms>` (also `mouse1`, `mouse2`) holds that mouse button over the play canvas, matching the shell's pointer binding codes, so hold-to-fire weapons can be driven headlessly. Keyboard codes are unchanged.
|
|
49
|
+
- `drive --right-click-at x,y` sends a native right click in CSS viewport pixels, so RTS move/attack/gather orders and context actions can be driven headlessly; `--click-at` stays a left click.
|
|
50
|
+
- `ctx.scene.entity.selection` (`@jgengine/core`): the player's selected entities as a reactive `SelectionSet`. The shell pointer (`pointer.select` click / marquee) now writes it instead of private component state, so game HUDs can show the selected unit's card or a "3 units selected" summary and can set the selection themselves (select-all, control groups); the selection ring follows either way. Despawned ids drop out.
|
|
51
|
+
- `LightingConfig.iblAmbientScale` (default 0.5) and `LightingConfig.fallbackKey` (default true); `balanceLightingForIbl` in `@jgengine/core/render/lightingBalance` applies them.
|
|
52
|
+
- `DirectionalLightingConfig.shadowRadius` (PCF penumbra in texels, default 1.25).
|
|
53
|
+
- `DEFAULT_AO` in `@jgengine/core/render/postProcessing` and `mergePostProcessing` in `@jgengine/core/render/lookPreset`.
|
|
54
|
+
- Loaded GLB models get a default painted finish: flat-colour, vertex-colour and palette-atlas materials have their exported roughness/metalness/`envMapIntensity` normalized and compile with one shared object-space detail shader (mottling, wear, roughness break-up, micro-normal, cavity). Normal-mapped, cutout and transparent materials are untouched.
|
|
55
|
+
- `GraphicsProfile.surfaceDetail` (`"off" | "lite" | "full"`, `@jgengine/core/settings/graphicsProfile`): `"lite"` on low, `"full"` on medium and up.
|
|
56
|
+
- `ModelConfig.surfaceDetail: false` opts one model out; `defineGame({ surfaceDetail: false })` opts a game out.
|
|
57
|
+
- `createSurfaceRibbonMaterial`, `ribbonSurfaceFrame`, `resolvePathSurface` and `useSurfaceRibbonMaterial` (`@jgengine/shell/terrain`) shade ground ribbons with the terrain's procedural surface functions, plus wear and a noise-warped soft edge.
|
|
58
|
+
- `road({ surface })` (`@jgengine/core/world/features`) picks the carriageway surface; `false` keeps flat colour.
|
|
59
|
+
- `AuthoredScene`/`AuthoredPaths` `pathSurfaceDetail`/`surfaceDetail` props and path `meta.surface`, `meta.edgeSoftness`, `meta.wear`, `meta.surfaceDetail`.
|
|
60
|
+
- `EditableTerrain.gridBounds` (`@jgengine/core/world/terraform`) exposes the world extent of the paint/height cell grid.
|
|
61
|
+
- Painted terrain renders each layer's `surface` as its own procedural ground (grass, dirt, gravel, rock, sand, mud, snow, road) with albedo variation, roughness and micro-normals, blended along noise-warped, height-aware edges, in play and in the editor viewport. No game config or assets needed.
|
|
62
|
+
- `TerrainDetailConfig.surfaceStrength` (0..1, default 1) scales the per-surface response; `detail: false` opts a terrain out to the flat vertex-colour ground.
|
|
63
|
+
- `@jgengine/shell/terrain` exports `packTerrainPaintWeights`, `createTerrainPaintTextures`, `terrainSurfaceClass`, `createTerrainDetailMaterial` and `TerrainQualityContext`/`useTerrainQuality`.
|
|
64
|
+
- `ctx.game.notices` (`@jgengine/core/game/notices`, `createNoticeFeed`): a player-facing notice feed with info/success/warn/deny tones, stacking dedupe (repeats show ×N and refresh their lifetime), a cap, TTL, `onPush`, `retune` and `snapshot`/`restore`. Pure state on the context clock, so headless and hosted runs are unaffected.
|
|
65
|
+
- The shell mounts `NoticeStack` (`@jgengine/react/notices`) for every game: animated toned cards below top HUD bars, 14px text on `--jg-*` vars with fallbacks, no pointer capture. Configure with `notices: { placement, hidden, refusals, cues, cap, ttlMs }` or remove with `notices: false`.
|
|
66
|
+
- Refusal feedback: `validate` may return `{ reason, message }`. `reason` stays the machine code; a `message` is shown automatically as a deduped deny notice with the deny cue when the local player's command is refused, locally or from a host. `ctx.game.commands` emits a `command.refused` event for every refusal; `attachRefusalNotices` is the headless hookup.
|
|
67
|
+
- UI sound cues: `useUiCue()` / `playUiCue` (`@jgengine/react/uiCues`) play `click`, `hover`, `open`, `close`, `confirm`, `deny`, `notify`. Soft procedural defaults (`@jgengine/core/audio/uiCues`) mix on a new "ui" bus with a "UI volume" setting under master volume. A game sound named `ui.<cue>` replaces one; `audio.uiCues` overrides or silences cues (`false` for all).
|
|
68
|
+
- `notice_push` and `run_command` agent-bridge verbs for `drive --rpc`.
|
|
69
|
+
- `ui-feedback` dev probe (apps/dev) exercising refusals, the feed and cues.
|
|
70
|
+
- `HudTheme` gains a type scale with a 12px floor, spacing, motion (durations and easings), elevation (layered shadows and a lit top rim), and hover/press/focus/disabled state tokens; `hudThemeVars` emits them as `--jg-type-*`, `--jg-space-*`, `--jg-motion-*`, `--jg-ease*`, `--jg-elevation-*`, `--jg-highlight`, `--jg-hover-*`, `--jg-press-*`, `--jg-focus-ring`, and `--jg-disabled-opacity`. `@jgengine/react/hudTokens` reads them in inline styles (`hudType`, `hudSpace`, `hudTransition`, `hudElevation`, `hudFont`, `HUD_FOCUS_RING`).
|
|
71
|
+
- Engine stylesheet `@jgengine/react/styles.css` (also `ENGINE_STYLESHEET` / `useEngineStyles` in `@jgengine/react/engineStyles`): every `jg-*` keyframe the shared blocks and registry use, `[data-jg-*]` interaction states, quiet hover/press/focus defaults for plain HUD buttons, and reduced-motion handling for `prefers-reduced-motion` and `[data-reduced-motion="true"]`. The `jgengine create` template imports it.
|
|
72
|
+
- `HudThemeRoot`: the shell wraps every `GameUI` in it, applying `defaultHudTheme` and the stylesheet. `defineGame({ hudTheme })` swaps the root theme; `hudTheme: false` opts out.
|
|
73
|
+
- Primitives `HudButton`, `HudPanel` (open/close transition via `usePresence`), and `TweenNumber` / `useTweenedValue` with an up/down delta flash; `useReducedMotion` in `@jgengine/react/accessibility`.
|
|
74
|
+
- `HudInteractProvider` / `useHudInteract`, plus `cue` and `onInteract` on the primitives, windows, modals, and `ActionButton`: one seam for a UI sound system.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- Static-object batches group same-model props per 64 m cell from 3 placements (was 24 m cells and more than 8), so sparse city dressing such as palms, lamps and benches shares draws. Harbor Heat's opening drops from 5,310 to 4,650 draw calls.
|
|
79
|
+
- `building()` districts with a non-generic `style` generate a different facade mix than before (previously a style changed colours only). `generic` is unchanged.
|
|
80
|
+
- `bun run shoot/drive` and the scaffolded/bundled `shoot.mjs`/`drive.mjs` parse one shared flag table: `--click-at`, `--right-click-at`, `--width`, `--height`, `--port`, `--size`, `--settle`, `--param`, `--probe`, `--reload`, key chords and mouse holds work in both; unknown flags fail instead of being ignored or taken as a game id; dev-runner-only flags are refused by name in the portable harness.
|
|
81
|
+
- Play-mode capture readiness waits until staged `--state`/`--run` commands have ticked, their entities mounted and model streaming stopped, and never declares ready over "Loading game view…". A rejected staged command fails the capture.
|
|
82
|
+
- Play-mode captures of a game with a start menu and no `capture.play` click its start action and report what they clicked.
|
|
83
|
+
- Monorepo tests, type-checks and `bun run gen` resolve `@jgengine/*` to package source through the `paths` in `tsconfig.base.json` (`bun run gen:source-paths` keeps every workspace tsconfig in sync), so a fresh checkout or worktree is testable with no build. Published packages are unchanged: `exports` still point at `dist`, and the built output is byte-identical.
|
|
84
|
+
- The export manifest is computed from the files each package's build emits, not from `dist` on disk, so it can no longer drift with a stale or partial build. `bun run ship:preflight` now fails with the fix (`bun run gen`) when any generated artifact is stale.
|
|
85
|
+
- `bun run agent:worktree -- <name>` produces a clean, ready worktree locally and in cloud sessions: it links dependencies, `Games` and the dev runner's ignored assets from the main checkout instead of installing and building. `agent:bootstrap` only installs; pass `--build` to also build dist.
|
|
86
|
+
- The procedural IBL probe has a zenith/horizon/ground gradient and an HDR sun disc plus a broad lobe, with the horizon leaning toward the ambient colour, at intensity 0.45 (was a 2-colour gradient at 0.35), so forms get a lit and a shaded side from IBL alone.
|
|
87
|
+
- Under IBL (every look but `"flat"`), authored ambient is halved, and lighting with no directional light gets a soft shadowless key light (intensity 0.6, tinted by the ambient colour).
|
|
88
|
+
- A post chain that omits `ao` now runs `DEFAULT_AO` on medium tier and above; `ao: false` still opts out. `NEUTRAL_POST_PROCESSING` no longer sets `ao: false`.
|
|
89
|
+
- Styled look presets deep-merge their post stages under a partial game `postProcessing` instead of being dropped whole.
|
|
90
|
+
- Shadows use `PCFShadowMap` instead of `PCFSoftShadowMap`, which three r182 compiles as unfiltered basic shadows (stair-stepped edges), with a default radius of 1.25 texels on configured and cascaded lights.
|
|
91
|
+
- GTAO samples are tiered: 8 on medium, 12 on high (was 16), 16 on ultra.
|
|
92
|
+
- A model `material.rim` override now chains the material's live program cache key instead of a snapshot.
|
|
93
|
+
- Authored paths now render as dirt (or their `meta.surface`/kind) with wheel ruts and edges that fade into the terrain, instead of flat-colour ribbons with hard cut edges.
|
|
94
|
+
- `road()` carriageways, curbs, sidewalks and welded junctions, and city studio ground (streets, block land, sidewalks, drives, parks), shade with matching procedural asphalt, gravel, concrete and dirt. City volumes opt out with `meta.surfaceDetail: false`.
|
|
95
|
+
- Terrain noise and micro-normal GLSL moved to shared exports in `terrainSurfaceShader.ts` so terrain and ribbons compile the same code.
|
|
96
|
+
- A terrain without `detail` now renders with default procedural detail instead of flat vertex colour.
|
|
97
|
+
- Slope rock no longer fades with `detail.strength`; it follows `surfaceStrength`.
|
|
98
|
+
- The textured terrain path's normal map is applied in view space (it was added to the view-space normal as a world-space offset).
|
|
99
|
+
- The `low` graphics tier takes a cheap terrain surface path without micro-normals.
|
|
100
|
+
- The shell plays UI cues for every engine HUD primitive (`HudButton`, `HudPanel`, `Window`, `ModalHost`, `ActionButton`): activate → `click` (or the primitive's `cue` when it names a cue, e.g. `cue="confirm"`), button/action hover → `hover`, panel open/close → `open`/`close`. Off with `notices: false` or `notices.cues: false`; `audio.uiCues` silences single cues. `uiCueForInteraction` (`@jgengine/react/uiCues`) exposes the mapping.
|
|
101
|
+
- `CommandRejection`, `CommandValidationError`, `CommandResult`, `RunCommandResult` and `TransportRunCommandResult` carry an optional `message`; the ws hosts forward it. Results without one are unchanged.
|
|
102
|
+
- `defaultHudTheme` is the engine's default look: a system UI font stack led by Inter, layered translucent panels with a lit rim and hairline borders, and gradient slots.
|
|
103
|
+
- `Window` / `PanelHost` render on `HudPanel` with enter and exit transitions and a `HudButton` close; `ModalHost` fades its backdrop and lifts its dialog, with an exit; `ConfirmDialog` / `PauseMenu` use `HudPanel` and `HudButton`; `HudFrame` and `ActionButton` read the font, elevation, focus, and motion tokens.
|
|
104
|
+
- Text under 12px in `@jgengine/react` and `registry/jgengine` now uses the type scale's `xs` step (12px floor).
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- Games on the `rts` and `topDown` camera rigs hear positional sound again. The spatial-audio listener sat at the camera, 35–50 m above the ground, so the default 24 m falloff silenced every ground event even at screen centre; for these rigs it now sits where the camera looks at the ground. Chase, first-person and other rigs keep the camera-anchored listener.
|
|
109
|
+
- The bundled `jgengine drive` and `jgengine shoot` harnesses let Chrome pick a free debug port instead of guessing one from the clock, so they no longer attach to another Chrome that holds the same port. An interrupted drive or shoot (SIGINT/SIGTERM) now closes its Chrome instead of leaving it running.
|
|
110
|
+
- `jgengine doctor` flags component folders reached through a `tsconfig` path alias (such as a shared `@/components/ui/*` registry) that game code imports but `src/index.css` never scans with `@source`. Their Tailwind classes were silently not generated, so those HUD blocks rendered tiny and unstyled; the fix text names the exact `@source` line to add.
|
|
111
|
+
- `jgengine doctor` now flags a 3D game that sets neither a `look` preset nor `postProcessing`; such a game renders the unstyled `"neutral"` look with no tone-map grade, AO or bloom. Its fix text no longer claims cinematic is the default. Write `look: "neutral"` when unstyled output is deliberate.
|
|
112
|
+
- `jgengine create` scaffolds name `look: "cinematic"` explicitly, so a new 3D game starts graded instead of on the unstyled default.
|
|
113
|
+
- Generated city districts (`@jgengine/shell` `CityRenderer`) fade their developed ground to transparent over its outer 9 m, so it melts into the terrain or authored paint beyond it instead of ending in a hard, stair-stepped green rim.
|
|
114
|
+
- City districts (`@jgengine/shell` `CityRenderer`) draw a crossing of gravel lanes as gravel instead of a dark asphalt slab; a crossing that touches any paved street stays asphalt.
|
|
115
|
+
- The published CLI and scaffold browser driver restore game keyboard focus before key holds and wait for a rendered frame under the held key. Menu clicks and slow headless rendering no longer silently swallow movement inputs; failed frame sampling releases held keys.
|
|
116
|
+
- After a click on a control that then unmounts (a start screen's "Play", a menu's last button), `@jgengine/shell` returns focus to the play surface instead of leaving it on `<body>`, so gameplay keys work without an extra click on the canvas.
|
|
117
|
+
- `sim_advance` (`@jgengine/shell` agent bridge) yields to the browser every ten simulated seconds, so models for entities spawned mid-advance mount as in play; a 30 s advance through Beacon Bastion's first wave previously left every raider with health bars but no model.
|
|
118
|
+
- Generated city districts no longer lay a checkerboard over their ground. The developed-ground fabric drew one flat quad per 2.5 m cell with a per-cell random roll that flipped neighbours between zone bands; it is now one shared-vertex sheet coloured from smooth noise, so yards and lots blend.
|
|
119
|
+
- Authored terrain paint (paths, clearings, painted layers) now blends smoothly between neighbouring cells in the runtime ground and the editor sculpt view, instead of snapping each vertex to its 2 m cell and drawing grid-aligned stair-step edges. Unpainted cells blend toward the caller's base ground color, so painted edges fade into the terrain palette.
|
|
120
|
+
- A terrain detail `material` no longer overrides the ground's authored roughness with its roughness map. The map now varies roughness around the authored value, so scanned ground maps (roughness near 0.45) stop giving dry terrain a wet specular sheen; games no longer need to lower `material.strength` to stay matte.
|
|
121
|
+
- A terrain without a `waterLevel` no longer paints the detail shader's beach sand band. Previously the band defaulted to water at height 0, so any flat terrain near 0 (a park, a meadow) rendered as beach; six reference games set `detail.waterLevel` far below the world only to avoid it. Set `waterLevel` on the terrain or `detail.waterLevel` to keep a shoreline.
|
|
122
|
+
- Atomic vitals bars (`HealthBar`, `ManaBar`, … in `@jgengine/react`) cap their frame at a quarter of the bar height, so thin bars (e.g. `height: "4px"`) show their fill instead of only the two 2px frame lines.
|
|
123
|
+
- Built-in world health bars (`worldHealthBars`, `@jgengine/shell`) span about 1.4 m at the entity's on-screen scale (28–112 px) instead of a fixed 112 px, so top-down and RTS cameras no longer paint bars ten times wider than the units under them.
|
|
124
|
+
|
|
29
125
|
## 0.22.0
|
|
30
126
|
|
|
31
127
|
### Migrate
|
package/dist/GamePlayerShell.js
CHANGED
|
@@ -11,6 +11,7 @@ import { actionContextStack, activeActionCodes } from "@jgengine/core/game/contr
|
|
|
11
11
|
import { useDisplayProfile } from "@jgengine/react/display";
|
|
12
12
|
import { RotateDeviceScreen } from "@jgengine/react/rotateDevice";
|
|
13
13
|
import { createSettingsStore } from "@jgengine/core/settings/settingsModel";
|
|
14
|
+
import { parseGraphicsQuality, withGraphicsQualityOverride } from "@jgengine/core/settings/graphicsSettings";
|
|
14
15
|
import { applyBindingOverrides, clearBindingOverride, loadBindingOverrides, saveBindingOverride, } from "@jgengine/core/input/bindingOverrides";
|
|
15
16
|
import { orientationGateActive, orientationHintActive, resolveOrientationRequirement, } from "@jgengine/core/ui/orientation";
|
|
16
17
|
import { devtools } from "@jgengine/core/devtools/devtools";
|
|
@@ -19,11 +20,13 @@ import { armTextureErrors } from "@jgengine/core/devtools/textureErrors";
|
|
|
19
20
|
import { readUrlFlag, subscribeUrlChange, writeUrlParam } from "@jgengine/core/devtools/urlFlags";
|
|
20
21
|
import { createAudioEngine } from "./audio/audioEngine.js";
|
|
21
22
|
import { attachAudioEventWire } from "./audio/audioWire.js";
|
|
23
|
+
import { resolvePlayableAudio } from "./audio/playableAudio.js";
|
|
22
24
|
import { installAgentBridge } from "./devtools/agentBridge.js";
|
|
23
25
|
import { attachPlaytestProbe } from "./devtools/playtestProbe.js";
|
|
24
26
|
import { withDevtoolsLatency } from "./devtools/latencyInstrumentation.js";
|
|
25
27
|
import { resolveRigKind } from "./camera/rigResolve.js";
|
|
26
28
|
import { contextModels } from "./render/resolveModel.js";
|
|
29
|
+
import { configureModelSurface } from "./render/modelSurface.js";
|
|
27
30
|
import { OrientationHint } from "./touch/OrientationHint.js";
|
|
28
31
|
import { useTouchStyle, useTouchJoystickVariant, useGraphicsSettings } from "./settings/appliedSettings.js";
|
|
29
32
|
import { logRuntimeError, } from "./diagnostics/RuntimeDiagnostics.js";
|
|
@@ -38,6 +41,7 @@ import { observableShellTracker, attachShellInputPublication } from "./shellInpu
|
|
|
38
41
|
import { attachShellControlSuspension } from "./shellControlSuspension.js";
|
|
39
42
|
import { LazyShell3dPresentation } from "./lazyShell3dPresentation.js";
|
|
40
43
|
import { createPresentationDiagnosticOwnership, createPlaySurfaceFocusOwnership, PresentationRecovery } from "./presentationRecovery.js";
|
|
44
|
+
import { installPlayFocusReclaim } from "./playSurfaceFocus.js";
|
|
41
45
|
const DEV_USER_ID = "dev-player";
|
|
42
46
|
/** The query param that mirrors the devtools overlay into the URL — present = open. */
|
|
43
47
|
const DEBUG_PARAM = "debug";
|
|
@@ -76,15 +80,20 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
76
80
|
devtoolsEnabled,
|
|
77
81
|
isDevtoolsOpen: () => devtoolsOpenRef.current,
|
|
78
82
|
setDevtoolsOpen,
|
|
83
|
+
getContext: () => ctxRef.current,
|
|
79
84
|
}), [playable, devtoolsEnabled]);
|
|
80
85
|
const [posterFrozen, setPosterFrozen] = useState(false);
|
|
81
86
|
const posterSettledRef = useRef(false);
|
|
82
87
|
const [ctx, setCtx] = useState(null);
|
|
88
|
+
const ctxRef = useRef(null);
|
|
89
|
+
ctxRef.current = ctx;
|
|
83
90
|
const [diagnostics, setDiagnostics] = useState([]);
|
|
84
91
|
const diagnosticSequence = useRef(0);
|
|
85
92
|
const presentationDiagnostics = useRef(createPresentationDiagnosticOwnership());
|
|
86
93
|
const [remotePlayers, setRemotePlayers] = useState([]);
|
|
87
94
|
const wrapperRef = useRef(null);
|
|
95
|
+
// A start/menu button that unmounts on click drops focus to <body>, where gameplay keys never arrive.
|
|
96
|
+
useEffect(() => (poster ? undefined : installPlayFocusReclaim(document, () => wrapperRef.current)), [poster]);
|
|
88
97
|
const recoveredFocus = useRef(createPlaySurfaceFocusOwnership());
|
|
89
98
|
const yawRef = useRef(0);
|
|
90
99
|
const pitchRef = useRef(0);
|
|
@@ -93,7 +102,12 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
93
102
|
const primaryClickRef = useRef(false);
|
|
94
103
|
const pointerAxisRef = useRef(null);
|
|
95
104
|
const f2HeldRef = useRef(false);
|
|
96
|
-
const settingsStore = useMemo(() =>
|
|
105
|
+
const settingsStore = useMemo(() => {
|
|
106
|
+
const store = createSettingsStore();
|
|
107
|
+
// `?quality=<tier>` pins the graphics tier for this page only (capture draft shots); never persisted.
|
|
108
|
+
const pinned = typeof window === "undefined" ? null : parseGraphicsQuality(new URLSearchParams(window.location.search).get("quality"));
|
|
109
|
+
return pinned === null ? store : withGraphicsQualityOverride(store, pinned, playable.graphics);
|
|
110
|
+
}, []);
|
|
97
111
|
const [bindingOverrides, setBindingOverrides] = useState(() => loadBindingOverrides(playable.game.name));
|
|
98
112
|
// Render-time reset (no stale-keybinds frame): reload persisted overrides once per playable change.
|
|
99
113
|
const [overridesPlayable, setOverridesPlayable] = useState(playable);
|
|
@@ -118,6 +132,7 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
118
132
|
tracker.rebind(activeBindingMap);
|
|
119
133
|
}
|
|
120
134
|
const graphics = useGraphicsSettings(settingsStore, playable.shadows ?? true, playable.graphics);
|
|
135
|
+
useLayoutEffect(() => configureModelSurface(playable.surfaceDetail !== false, graphics.profile.surfaceDetail), [playable.surfaceDetail, graphics.profile.surfaceDetail]);
|
|
121
136
|
const trackPointerAxis = (event) => {
|
|
122
137
|
const rect = wrapperRef.current?.getBoundingClientRect();
|
|
123
138
|
if (rect === undefined)
|
|
@@ -184,10 +199,10 @@ export function GamePlayerShell({ playable, multiplayer: rawMultiplayer = null,
|
|
|
184
199
|
gateRef.current = orientationGate;
|
|
185
200
|
const orientationGateEl = orientationGate ? (_jsx(RotateDeviceScreen, { requiredOrientation: orientationRequirement.required ?? "landscape", title: orientationRequirement.required === "portrait" ? "Turn your phone upright" : "Turn your phone sideways", description: `${playable.game.name} is built for ${orientationRequirement.required ?? "landscape"} play.` })) : orientationHint && orientationRequirement.preferred !== null ? (_jsx(OrientationHint, { wanted: orientationRequirement.preferred })) : null;
|
|
186
201
|
const audioEngine = useMemo(() => createAudioEngine({
|
|
187
|
-
sounds: playable.
|
|
202
|
+
sounds: resolvePlayableAudio(playable).sounds,
|
|
188
203
|
maxVoices: playable.audio?.maxVoices,
|
|
189
204
|
voiceOverflow: playable.audio?.voiceOverflow,
|
|
190
|
-
buses: playable.
|
|
205
|
+
buses: resolvePlayableAudio(playable).buses,
|
|
191
206
|
music: playable.audio?.music,
|
|
192
207
|
musicBus: playable.audio?.musicBus,
|
|
193
208
|
}), [playable]);
|
package/dist/GameUiPreview.js
CHANGED
|
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { createGameContext } from "@jgengine/core/runtime/gameContext";
|
|
4
4
|
import { GameProvider } from "@jgengine/react/provider";
|
|
5
|
+
import { HudThemeRoot } from "@jgengine/react/hudThemeRoot";
|
|
5
6
|
import { HudViewportProvider } from "@jgengine/react/hudViewport";
|
|
6
7
|
import { contextModels } from "./render/resolveModel.js";
|
|
7
8
|
const PREVIEW_USER_ID = "ui-preview";
|
|
@@ -61,5 +62,5 @@ export function GameUiPreview({ playable, scenario = defaultUiScenario, }) {
|
|
|
61
62
|
if (ctx === null)
|
|
62
63
|
return _jsx("div", { className: "h-full w-full bg-neutral-900" });
|
|
63
64
|
const GameUI = playable.GameUI;
|
|
64
|
-
return (_jsx("div", { "data-ui-preview-ready": true, className: "relative h-full w-full overflow-hidden", style: { background: "linear-gradient(180deg, #2a3d33 0%, #1a2320 55%, #141b18 100%)" }, children: _jsx(GameProvider, { context: ctx, children: _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, children: _jsx(GameUI, {}) }) }) }));
|
|
65
|
+
return (_jsx("div", { "data-ui-preview-ready": true, className: "relative h-full w-full overflow-hidden", style: { background: "linear-gradient(180deg, #2a3d33 0%, #1a2320 55%, #141b18 100%)" }, children: _jsx(GameProvider, { context: ctx, children: _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, children: _jsx(HudThemeRoot, { theme: playable.hudTheme, children: _jsx(GameUI, {}) }) }) }) }));
|
|
65
66
|
}
|
|
@@ -3,7 +3,7 @@ import { Canvas } from "@react-three/fiber";
|
|
|
3
3
|
import { useMemo, useEffect, useRef, useState, useSyncExternalStore, } from "react";
|
|
4
4
|
import { useFrame, useThree } from "@react-three/fiber";
|
|
5
5
|
import { contextVerbInput, } from "@jgengine/core/interaction/contextMenu";
|
|
6
|
-
import {
|
|
6
|
+
import { isMarquee, screenRect, selectWithinRect, } from "@jgengine/core/scene/selection";
|
|
7
7
|
import { DEFAULT_PICKUP_RADIUS } from "@jgengine/core/game/worldItem";
|
|
8
8
|
import { CAMERA_FRUSTUM_DEFAULTS } from "@jgengine/core/game/playableGame";
|
|
9
9
|
import { pixelPerfectFrustum, snapPixelPerfectPosition } from "@jgengine/core/game/pixelPerfect";
|
|
@@ -11,6 +11,7 @@ import { BUILT_IN_SETTING_CATEGORIES, } from "@jgengine/core/settings/settingsMo
|
|
|
11
11
|
import { playControlsActive } from "@jgengine/core/game/controlGate";
|
|
12
12
|
import { sky as resolveSkyDescriptor } from "@jgengine/core/world/features";
|
|
13
13
|
import { resolveGameLook } from "@jgengine/core/render/lookPreset";
|
|
14
|
+
import { balanceLightingForIbl } from "@jgengine/core/render/lightingBalance";
|
|
14
15
|
import { GameViewportProvider } from "@jgengine/react/gameViewport";
|
|
15
16
|
import { GameProvider } from "@jgengine/react/provider";
|
|
16
17
|
import { SettingsProvider } from "@jgengine/react/settings";
|
|
@@ -40,6 +41,7 @@ import { WorldItems } from "./world/WorldItems.js";
|
|
|
40
41
|
import { TouchControlsDock, TouchPlaySurface, touchDockClearance } from "./touch/TouchControlsOverlay.js";
|
|
41
42
|
import { SettingsRuntime } from "./settings/SettingsRuntime.js";
|
|
42
43
|
import { SettingsChrome } from "./settings/SettingsChrome.js";
|
|
44
|
+
import { resolvePlayableAudio } from "./audio/playableAudio.js";
|
|
43
45
|
import { AudioSettingsBridge } from "./settings/appliedSettings.js";
|
|
44
46
|
import { TextureFiltering } from "./render/TextureFiltering.js";
|
|
45
47
|
import { FrameRateLimiter } from "./drivers/FrameRateLimiter.js";
|
|
@@ -53,6 +55,7 @@ import { localPlayers } from "@jgengine/core/runtime/localPlayers";
|
|
|
53
55
|
import { createShellKeyHandlers, ShellDebugOverlays, ShellGameUiChrome } from "./ShellChrome.js";
|
|
54
56
|
import { CombatPresentation } from "./CombatPresentation.js";
|
|
55
57
|
import { WorldParticles } from "./world/WorldParticles.js";
|
|
58
|
+
import { TerrainQualityContext } from "./terrain/terrainQuality.js";
|
|
56
59
|
import { detectKtx2Support } from "./render/modelLoad.js";
|
|
57
60
|
import { resolvePresentationEffects, resolveWorldOverlayBars, } from "./presentationResolve.js";
|
|
58
61
|
const PRIMARY_CLICK_MOVE_THRESHOLD_PX = 6;
|
|
@@ -82,7 +85,8 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
82
85
|
const frameTally = useRef({ frames: 0 }).current;
|
|
83
86
|
const WorldOverlay = playable.WorldOverlay;
|
|
84
87
|
const pointerService = useMemo(() => createPointerService(), []);
|
|
85
|
-
|
|
88
|
+
// The player's entity selection lives on ctx so game HUDs can read (and write) it.
|
|
89
|
+
const selection = ctx.scene.entity.selection;
|
|
86
90
|
const controlledEntityId = ctx.player.possession.active(ctx.player.userId);
|
|
87
91
|
const cameraOverride = useSyncExternalStore(ctx.camera.subscribe, ctx.camera.rig, ctx.camera.rig);
|
|
88
92
|
const configuredCamera = playable.camera?.followEntityId !== undefined
|
|
@@ -124,6 +128,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
124
128
|
const authoredSky = backdropSky ?? worldSky;
|
|
125
129
|
const lighting = resolvedLook.lighting;
|
|
126
130
|
const sunFromLighting = lighting?.directional?.[0]?.position;
|
|
131
|
+
const sceneLighting = useMemo(() => (cinematicLook ? balanceLightingForIbl(lighting) : lighting), [cinematicLook, lighting]);
|
|
127
132
|
const effectiveSky = useMemo(() => authoredSky === undefined || authoredSky.sun !== undefined || authoredSky.timeOfDay || sunFromLighting === undefined
|
|
128
133
|
? authoredSky
|
|
129
134
|
: { ...authoredSky, sun: bearingFromDirection(sunFromLighting) }, [authoredSky, sunFromLighting]);
|
|
@@ -132,7 +137,8 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
132
137
|
const pointerDownRef = useRef(null);
|
|
133
138
|
const marqueeStartRef = useRef(null);
|
|
134
139
|
const [marquee, setMarquee] = useState(null);
|
|
135
|
-
const
|
|
140
|
+
const selectionKey = useSyncExternalStore(ctx.subscribe, () => selection.list().join("\n"), () => selection.list().join("\n"));
|
|
141
|
+
const selectedIds = useMemo(() => new Set(selectionKey === "" ? [] : selectionKey.split("\n")), [selectionKey]);
|
|
136
142
|
const [contextMenu, setContextMenu] = useState(null);
|
|
137
143
|
const localXY = (event) => {
|
|
138
144
|
const rect = wrapperRef.current?.getBoundingClientRect();
|
|
@@ -142,7 +148,6 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
142
148
|
};
|
|
143
149
|
const commitSelection = (ids) => {
|
|
144
150
|
selection.replace(ids);
|
|
145
|
-
setSelectedIds(new Set(ids));
|
|
146
151
|
};
|
|
147
152
|
const finishMarquee = (rect) => {
|
|
148
153
|
const candidates = [];
|
|
@@ -344,7 +349,7 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
344
349
|
controlsActive: () => playControlsActive(ctx),
|
|
345
350
|
exitPointerLockOnDevtools: true,
|
|
346
351
|
});
|
|
347
|
-
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.
|
|
352
|
+
return (_jsx(SettingsProvider, { store: settingsStore, children: _jsxs(PlayerFovProvider, { config: playable.camera, orthographic: orthographic, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: resolvePlayableAudio(playable).buses }), _jsx(SettingsRuntime, { variant: settingsVariant, surface: settingsSurface, actions: settingsActions, input: playable.game.input ?? {}, buses: resolvePlayableAudio(playable).buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: hideCategories, fovEnabled: fovControlEnabled, graphics: playable.graphics, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer &&
|
|
348
353
|
touchScheme !== null &&
|
|
349
354
|
(touchScheme.joystick !== null || touchScheme.buttons.length > 0), overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, ...(poster && posterFrozen ? { "data-poster-ready": "" } : {}), className: "relative h-full w-full bg-neutral-950 outline-none", style: {
|
|
350
355
|
"--jg-hud-dock-clearance": `${dockMounted ? touchDockClearance(touchScheme, touchScale) : 0}px`,
|
|
@@ -373,9 +378,9 @@ export function Shell3dPresentation({ playable, ctx, multiplayer, tracker, point
|
|
|
373
378
|
fov: playable.camera?.frustum?.fov ?? CAMERA_FRUSTUM_DEFAULTS.fov,
|
|
374
379
|
near: playable.camera?.frustum?.near ?? CAMERA_FRUSTUM_DEFAULTS.near,
|
|
375
380
|
far: playable.camera?.frustum?.far ?? CAMERA_FRUSTUM_DEFAULTS.far,
|
|
376
|
-
}, shadows: graphics.shadows, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor:
|
|
377
|
-
|
|
378
|
-
|
|
381
|
+
}, shadows: graphics.shadows ? "percentage" : false, dpr: [Math.min(1, graphics.dpr), graphics.dpr], gl: { preserveDrawingBuffer: true, powerPreference: "high-performance" }, style: { touchAction: "none" }, children: [_jsx(TextureFiltering, { anisotropy: graphics.profile.anisotropy }), graphics.frameRateLimit > 0 && !poster ? _jsx(FrameRateLimiter, { fps: graphics.frameRateLimit }) : null, graphics.showFps ? _jsx(FpsProbe, { tally: frameTally }) : null, orthographic && playable.camera?.pixelPerfect !== undefined ? (_jsx(PixelPerfectCamera, { ...playable.camera.pixelPerfect })) : null, backgroundColor !== undefined ? _jsx("color", { attach: "background", args: [backgroundColor] }) : null, cinematicLook ? (_jsx(EnvironmentLighting, { source: playable.environmentSource, skyColor: sceneLighting?.hemisphere?.skyColor ?? "#87b5e0", groundColor: sceneLighting?.hemisphere?.groundColor ?? "#3d4a38", horizonTint: sceneLighting?.ambient?.color, sunDirection: sceneLighting?.directional?.[0]?.position, sunColor: sceneLighting?.directional?.[0]?.color ?? "#fff2d6" })) : null, sceneLighting !== undefined ? (_jsx(ConfiguredLighting, { lighting: sceneLighting, profile: graphics.profile })) : effectiveSky === undefined ? (_jsxs(_Fragment, { children: [_jsx("ambientLight", { intensity: cinematicLook ? 0.3 : 0.55 }), _jsx("directionalLight", { position: [10, 16, 6], intensity: 1.3 })] })) : null, effectiveSky !== undefined ? (effectiveSky.timeOfDay ? (_jsx(TimeOfDayDaylight, { sky: effectiveSky, clock: ctx.time, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) })) : (_jsx(SkyDaylight, { sky: effectiveSky, bands: effectiveSky === worldSky ? biomeBands : undefined, lights: skyEmitsLights(resolveSkyLightOwnership(lighting !== undefined)) }))) : null, _jsx(BackdropFog, { fog: backdrop?.fog }), _jsx(GameProvider, { context: ctx, children: _jsx(TerrainQualityContext.Provider, { value: graphics.quality, children: _jsxs(BuildingRenderBudget, { drawDistance: graphics.profile.drawDistance, visibility: playable.visibility, cullingDisabled: splitScreen, children: [_jsxs(CullingProvider, { config: playable.visibility, drawDistance: graphics.profile.drawDistance, children: [_jsx(WorldView, { entitySprites: playable.entitySprites, entityModels: playable.entityModels, objectModels: playable.objectModels, objectStyles: playable.objectStyles, environment: AutoEnvironment, assets: playable.game.assets, renderEntity: playable.renderEntity, renderObject: playable.renderObject, selectedIds: selectedIds, hideLocalActor: firstPerson, editorLayers: playable.editorLayers }), bars !== null ? (_jsx(WorldEntityBars, { statId: bars.statId, roles: bars.roles, resolveRole: resolveEntityRole, ...(bars.maxSamples === undefined ? {} : { maxSamples: bars.maxSamples }), ...(bars.tickMs === undefined ? {} : { tickMs: bars.tickMs }), ...(bars.maxDistance === undefined ? {} : { maxDistance: bars.maxDistance }), ...(bars.occlude === undefined ? {} : { occlude: bars.occlude }) })) : null, nameplates !== null ? (_jsx(WorldNameplates, { statId: nameplates.statId, roles: nameplates.roles, resolveRole: resolveEntityRole, ...(nameplates.maxSamples === undefined ? {} : { maxSamples: nameplates.maxSamples }), ...(nameplates.tickMs === undefined ? {} : { tickMs: nameplates.tickMs }), ...(nameplates.maxDistance === undefined ? {} : { maxDistance: nameplates.maxDistance }), occlude: nameplates.occlude, ...(nameplates.resolveName === undefined ? {} : { resolveName: nameplates.resolveName }), ...(nameplates.showHealth === undefined ? {} : { showHealth: nameplates.showHealth }) })) : null, WorldOverlay !== undefined ? (_jsx("group", { userData: CAMERA_TRANSPARENT_USERDATA, children: _jsx(WorldOverlay, { ctx: ctx }) })) : null] }), _jsx(WorldItems, { config: playable.worldItem }), _jsx(WorldParticles, { quality: graphics.quality, particleCap: graphics.profile.particleCap, diagnostics: devtoolsEnabled }), _jsx(CombatPresentation, { effects: effects }), devtoolsEnabled ? _jsx(CollisionDebugWorld, {}) : null, _jsx(AudioListener, { engine: audioEngine, anchor: playable.camera?.rig === "rts" || playable.camera?.rig === "topDown" ? "ground" : "camera" }), _jsx(EntityAudioEmitters, { engine: audioEngine, entitySounds: playable.entitySounds }), _jsx(ObjectAudioEmitters, { engine: audioEngine, objectSounds: playable.objectSounds }), _jsx(GameCameraRig, { yawRef: yawRef, pitchRef: pitchRef, config: cameraConfig, viewmodel: playable.viewmodel, pointerControls: pointerUsesLeft, panKeysEnabled: rtsPanKeysEnabled, director: ctx.camera, onDragChange: (dragging) => {
|
|
382
|
+
cameraDraggingRef.current = dragging;
|
|
383
|
+
} }), _jsx(PointerProbe, { service: pointerService })] }) }) }), _jsx(RemotePlayers, { rows: remotePlayers }), _jsx(FrameDriver, { ctx: ctx, playable: playable, tracker: tracker, yawRef: yawRef, pitchRef: pitchRef, primaryClickRef: primaryClickRef, pointerAxisRef: pointerAxisRef, analogRef: analogRef, gateRef: gateRef, onRuntimeError: reportRuntimeError, multiplayer: multiplayer, serverIdRef: serverIdRef, pointerService: pointerService, pointerAim: pointer?.aim === true, pingCommand: pointer?.pingCommand, poster: poster, onPosterSettled: onPosterSettled, authoritativeFrameRef: authoritativeFrameRef }), _jsx(GamepadSource, { tracker: tracker, bindings: inputBindings ?? {}, analogRef: analogRef, input: ctx.input, feel: playable.gamepad, seats: localPlayers(ctx), onSeatJoin: (slot) => {
|
|
379
384
|
try {
|
|
380
385
|
playable.loop.onNewPlayer(ctx, { userId: slot.userId, isNew: true });
|
|
381
386
|
}
|
package/dist/ShellChrome.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
|
3
3
|
import { type RuntimeDiagnostic } from "./diagnostics/RuntimeDiagnostics.js";
|
|
4
4
|
import type { ShellMultiplayer } from "./multiplayer.js";
|
|
5
5
|
import type { PlayableGame } from "./registry.js";
|
|
6
|
-
/** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport. @internal */
|
|
6
|
+
/** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport → HUD theme root. @internal */
|
|
7
7
|
export declare function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }: {
|
|
8
8
|
ctx: GameContext;
|
|
9
9
|
playable: PlayableGame;
|
package/dist/ShellChrome.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { lazy, Suspense, useEffect, useState } from "react";
|
|
3
|
+
import { HudThemeRoot } from "@jgengine/react/hudThemeRoot";
|
|
3
4
|
import { HudViewportProvider } from "@jgengine/react/hudViewport";
|
|
4
5
|
import { GameProvider } from "@jgengine/react/provider";
|
|
5
6
|
import { DiagnosticOverlay, GameUiErrorBoundary, } from "./diagnostics/RuntimeDiagnostics.js";
|
|
6
7
|
import { DevtoolsRuntime } from "./devtools/DevtoolsRuntime.js";
|
|
7
8
|
import { GamePhaseStamp } from "./GamePhaseStamp.js";
|
|
9
|
+
import { ShellNotices, ShellUiCues } from "./ShellNotices.js";
|
|
8
10
|
import { SettingsPlayControlGate } from "./settings/SettingsRuntime.js";
|
|
9
11
|
const DevtoolsOverlay = lazy(() => import("./devtools/DevtoolsOverlay.js").then((module) => ({ default: module.DevtoolsOverlay })));
|
|
10
|
-
/** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport. @internal */
|
|
12
|
+
/** Shared GameUI mount: error boundary → GameProvider → phase stamp → HudViewport → HUD theme root. @internal */
|
|
11
13
|
export function ShellGameUiChrome({ ctx, playable, GameUI, uiScale, orientationGate, onRuntimeError, }) {
|
|
12
|
-
return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(SettingsPlayControlGate, { ctx: ctx }), _jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : _jsx(GameUI, {}) })] }) }));
|
|
14
|
+
return (_jsx(GameUiErrorBoundary, { onRuntimeError: onRuntimeError, children: _jsxs(GameProvider, { context: ctx, children: [_jsx(SettingsPlayControlGate, { ctx: ctx }), _jsx(GamePhaseStamp, {}), _jsx(HudViewportProvider, { platforms: playable.platforms, config: playable.hudFit, userScale: uiScale, children: orientationGate ? null : (_jsx(ShellUiCues, { ctx: ctx, enabled: playable.notices !== false && playable.notices?.cues !== false, children: _jsx(HudThemeRoot, { theme: playable.hudTheme, children: _jsx(GameUI, {}) }) })) }), orientationGate ? null : _jsx(ShellNotices, { ctx: ctx, config: playable.notices, scale: uiScale })] }) }));
|
|
13
15
|
}
|
|
14
16
|
/** Devtools + diagnostic overlays shared by HUD and 3D presentation paths. @internal */
|
|
15
17
|
export function ShellDebugOverlays({ ctx, playable, multiplayer, diagnostics, devtoolsEnabled, devtoolsOpen, hideDiagnostics = false, }) {
|
|
@@ -6,6 +6,7 @@ import { SettingsProvider } from "@jgengine/react/settings";
|
|
|
6
6
|
import { GameViewportProvider } from "@jgengine/react/gameViewport";
|
|
7
7
|
import { HudOnlyDriver } from "./drivers/HudOnlyDriver.js";
|
|
8
8
|
import { createShellKeyHandlers, ShellDebugOverlays, ShellGameUiChrome } from "./ShellChrome.js";
|
|
9
|
+
import { resolvePlayableAudio } from "./audio/playableAudio.js";
|
|
9
10
|
import { AudioSettingsBridge } from "./settings/appliedSettings.js";
|
|
10
11
|
import { SettingsRuntime } from "./settings/SettingsRuntime.js";
|
|
11
12
|
import { SettingsChrome } from "./settings/SettingsChrome.js";
|
|
@@ -49,7 +50,7 @@ export function ShellHudPresentation({ playable, ctx, multiplayer, serverIdRef,
|
|
|
49
50
|
description: action.description,
|
|
50
51
|
run: () => action.run(ctx),
|
|
51
52
|
}));
|
|
52
|
-
return (_jsxs(SettingsProvider, { store: settingsStore, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: playable.
|
|
53
|
+
return (_jsxs(SettingsProvider, { store: settingsStore, children: [_jsx(AudioSettingsBridge, { store: settingsStore, engine: audioEngine, buses: resolvePlayableAudio(playable).buses }), _jsx(SettingsRuntime, { variant: settingsConfig.variant ?? "panel", surface: settingsDisabled ? false : settingsConfig.surface ?? false, actions: settingsActions, input: playable.game.input ?? {}, buses: resolvePlayableAudio(playable).buses, extra: settingsConfig.extra ?? [], categories: settingsConfig.categories ?? [], hide: settingsDisabled ? BUILT_IN_SETTING_CATEGORIES : settingsConfig.hide ?? [], fovEnabled: false, graphics: playable.graphics, hideBindings: settingsConfig.hideBindings ?? [], touchStyle: coarsePointer && touchScheme !== null && (touchScheme.joystick !== null || touchScheme.buttons.length > 0), overrides: bindingOverrides, rebind: rebindAction, resetBinding: resetActionBinding, children: _jsx("div", { ref: wrapperRef, tabIndex: 0, className: "relative h-full w-full bg-neutral-950 outline-none", onKeyDown: keys.onKeyDown, onKeyUp: keys.onKeyUp, onBlur: keys.onBlur, onPointerDown: onPointerResumeAudio, onPointerMove: trackPointerAxis, onPointerLeave: deactivatePointerAxis, onPointerCancel: deactivatePointerAxis, children: _jsxs(GameViewportProvider, { platforms: playable.platforms, children: [_jsx(HudOnlyDriver, { ctx: ctx, multiplayer: multiplayer, serverIdRef: serverIdRef, playable: playable, tracker: tracker, pointerAxisRef: pointerAxisRef, gateRef: gateRef, onRuntimeError: reportRuntimeError }), !orientationGate &&
|
|
53
54
|
coarsePointer &&
|
|
54
55
|
touchScheme !== null &&
|
|
55
56
|
touchScheme.gestures !== null &&
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { NoticePresentationConfig } from "@jgengine/core/game/playableGame";
|
|
3
|
+
import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
4
|
+
/**
|
|
5
|
+
* Wire `ctx.game.notices` for the player: refused commands with a `message` become deny notices, each new
|
|
6
|
+
* notice plays its tone's UI cue, and the default stack renders it. Returns the detach function.
|
|
7
|
+
* @internal
|
|
8
|
+
*/
|
|
9
|
+
export declare function attachShellNotices(ctx: GameContext, config?: NoticePresentationConfig): () => void;
|
|
10
|
+
/** The shell-mounted notice feed; `playable.notices: false` removes it. @internal */
|
|
11
|
+
export declare function ShellNotices({ ctx, config, scale, }: {
|
|
12
|
+
ctx: GameContext;
|
|
13
|
+
config: NoticePresentationConfig | false | undefined;
|
|
14
|
+
scale: number;
|
|
15
|
+
}): import("react").JSX.Element | null;
|
|
16
|
+
/**
|
|
17
|
+
* Plays the UI cue for every engine HUD primitive interaction below it (button activate/hover, panel
|
|
18
|
+
* open/close), so `HudButton`, `HudPanel`, windows and action slots sound without per-button wiring.
|
|
19
|
+
* Off with `playable.notices: false` or `notices.cues: false`; `audio.uiCues` silences individual cues.
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare function ShellUiCues({ ctx, enabled, children }: {
|
|
23
|
+
ctx: GameContext;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
children?: ReactNode;
|
|
26
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useCallback, useEffect } from "react";
|
|
3
|
+
import { NOTICE_TONE_CUES, uiCueSoundId } from "@jgengine/core/audio/uiCues";
|
|
4
|
+
import { attachRefusalNotices } from "@jgengine/core/game/notices";
|
|
5
|
+
import { HudInteractProvider } from "@jgengine/react/hudInteract";
|
|
6
|
+
import { NoticeStack } from "@jgengine/react/notices";
|
|
7
|
+
import { playUiCue, uiCueForInteraction } from "@jgengine/react/uiCues";
|
|
8
|
+
/**
|
|
9
|
+
* Wire `ctx.game.notices` for the player: refused commands with a `message` become deny notices, each new
|
|
10
|
+
* notice plays its tone's UI cue, and the default stack renders it. Returns the detach function.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function attachShellNotices(ctx, config = {}) {
|
|
14
|
+
const notices = ctx.game.notices;
|
|
15
|
+
notices.retune(config);
|
|
16
|
+
const detachRefusals = config.refusals === false
|
|
17
|
+
? () => undefined
|
|
18
|
+
: attachRefusalNotices(ctx.game.events, notices, { localUserId: () => ctx.player.userId });
|
|
19
|
+
const detachCues = config.cues === false
|
|
20
|
+
? () => undefined
|
|
21
|
+
: notices.onPush((notice) => ctx.game.audio.play(uiCueSoundId(NOTICE_TONE_CUES[notice.tone])));
|
|
22
|
+
return () => {
|
|
23
|
+
detachRefusals();
|
|
24
|
+
detachCues();
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
/** The shell-mounted notice feed; `playable.notices: false` removes it. @internal */
|
|
28
|
+
export function ShellNotices({ ctx, config, scale, }) {
|
|
29
|
+
const enabled = config !== false;
|
|
30
|
+
const settings = config === false ? undefined : config;
|
|
31
|
+
useEffect(() => (enabled ? attachShellNotices(ctx, settings) : undefined), [ctx, enabled, settings]);
|
|
32
|
+
if (!enabled || settings?.hidden === true)
|
|
33
|
+
return null;
|
|
34
|
+
return _jsx(NoticeStack, { feed: ctx.game.notices, placement: settings?.placement ?? "top", scale: scale });
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Plays the UI cue for every engine HUD primitive interaction below it (button activate/hover, panel
|
|
38
|
+
* open/close), so `HudButton`, `HudPanel`, windows and action slots sound without per-button wiring.
|
|
39
|
+
* Off with `playable.notices: false` or `notices.cues: false`; `audio.uiCues` silences individual cues.
|
|
40
|
+
* @internal
|
|
41
|
+
*/
|
|
42
|
+
export function ShellUiCues({ ctx, enabled, children }) {
|
|
43
|
+
const onInteract = useCallback((event) => {
|
|
44
|
+
const cue = uiCueForInteraction(event);
|
|
45
|
+
if (cue !== null)
|
|
46
|
+
playUiCue(ctx, cue);
|
|
47
|
+
}, [ctx]);
|
|
48
|
+
if (!enabled)
|
|
49
|
+
return _jsx(_Fragment, { children: children });
|
|
50
|
+
return _jsx(HudInteractProvider, { onInteract: onInteract, children: children });
|
|
51
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AudioEngine } from "./audioEngine.js";
|
|
2
|
-
|
|
2
|
+
import { type ListenerAnchor } from "./listenerAnchor.js";
|
|
3
|
+
export declare function AudioListener({ engine, anchor }: {
|
|
3
4
|
engine: AudioEngine;
|
|
5
|
+
anchor?: ListenerAnchor;
|
|
4
6
|
}): null;
|
|
5
7
|
export declare function EntityAudioEmitters({ engine, entitySounds, }: {
|
|
6
8
|
engine: AudioEngine;
|
|
@@ -2,14 +2,17 @@ import { useFrame, useThree } from "@react-three/fiber";
|
|
|
2
2
|
import { useEffect, useRef } from "react";
|
|
3
3
|
import { Quaternion, Vector3 } from "three";
|
|
4
4
|
import { useGameContext } from "@jgengine/react/provider";
|
|
5
|
+
import { groundFocusListener } from "./listenerAnchor.js";
|
|
5
6
|
import { createListenerVelocityTrack, stepListenerVelocity } from "./loopParams.js";
|
|
6
|
-
export function AudioListener({ engine }) {
|
|
7
|
+
export function AudioListener({ engine, anchor = "camera" }) {
|
|
7
8
|
const camera = useThree((state) => state.camera);
|
|
9
|
+
const ctx = useGameContext();
|
|
8
10
|
const trackRef = useRef(createListenerVelocityTrack());
|
|
9
11
|
useFrame((_, dt) => {
|
|
10
12
|
const direction = camera.getWorldDirection(new Vector3());
|
|
11
13
|
const up = camera.up.clone().applyQuaternion(camera.getWorldQuaternion(new Quaternion()));
|
|
12
|
-
const
|
|
14
|
+
const eye = { x: camera.position.x, y: camera.position.y, z: camera.position.z };
|
|
15
|
+
const position = anchor === "ground" ? groundFocusListener(eye, direction, ctx.world.groundHeightAt(eye.x, eye.z)) : eye;
|
|
13
16
|
engine.setListenerPose({
|
|
14
17
|
position,
|
|
15
18
|
forward: { x: direction.x, y: direction.y, z: direction.z },
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Where the spatial-audio listener sits: at the camera, or where an overhead camera looks at the ground. @internal */
|
|
2
|
+
export type ListenerAnchor = "camera" | "ground";
|
|
3
|
+
type Point = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
z: number;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* The point an overhead camera looks at on the ground, lifted slightly, or the camera position when
|
|
10
|
+
* the view does not reach the ground. Overhead rigs sit tens of metres up, so a camera-anchored
|
|
11
|
+
* listener hears every ground event at the edge of (or past) its falloff even when it is centre-screen.
|
|
12
|
+
* @internal
|
|
13
|
+
*/
|
|
14
|
+
export declare function groundFocusListener(camera: Point, forward: Point, groundY: number): Point;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Furthest the listener moves from an overhead camera along its view ray. */
|
|
2
|
+
const MAX_FOCUS_DISTANCE = 400;
|
|
3
|
+
/** Listener height above the ground focus, so ground emitters are not exactly coincident with it. */
|
|
4
|
+
const FOCUS_LIFT = 2;
|
|
5
|
+
/**
|
|
6
|
+
* The point an overhead camera looks at on the ground, lifted slightly, or the camera position when
|
|
7
|
+
* the view does not reach the ground. Overhead rigs sit tens of metres up, so a camera-anchored
|
|
8
|
+
* listener hears every ground event at the edge of (or past) its falloff even when it is centre-screen.
|
|
9
|
+
* @internal
|
|
10
|
+
*/
|
|
11
|
+
export function groundFocusListener(camera, forward, groundY) {
|
|
12
|
+
const height = camera.y - groundY;
|
|
13
|
+
if (forward.y >= -0.05 || height <= 0)
|
|
14
|
+
return { ...camera };
|
|
15
|
+
const t = Math.min(MAX_FOCUS_DISTANCE, height / -forward.y);
|
|
16
|
+
return { x: camera.x + forward.x * t, y: groundY + FOCUS_LIFT, z: camera.z + forward.z * t };
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AudioBusDef, SoundDef } from "@jgengine/core/audio/audioFalloff";
|
|
2
|
+
import type { PlayableGame } from "../registry.js";
|
|
3
|
+
/** A playable's sound catalog and buses with the default UI cues merged in. @internal */
|
|
4
|
+
export interface ResolvedPlayableAudio {
|
|
5
|
+
sounds: Record<string, SoundDef>;
|
|
6
|
+
buses: Record<string, AudioBusDef> | undefined;
|
|
7
|
+
}
|
|
8
|
+
/** Resolve once per playable so the engine, the volume bridge and the settings rows share one bus list. @internal */
|
|
9
|
+
export declare function resolvePlayableAudio(playable: PlayableGame): ResolvedPlayableAudio;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { withUiCueSounds } from "@jgengine/core/audio/uiCues";
|
|
2
|
+
const cache = new WeakMap();
|
|
3
|
+
/** Resolve once per playable so the engine, the volume bridge and the settings rows share one bus list. @internal */
|
|
4
|
+
export function resolvePlayableAudio(playable) {
|
|
5
|
+
let resolved = cache.get(playable);
|
|
6
|
+
if (resolved === undefined) {
|
|
7
|
+
resolved = withUiCueSounds(playable.audio?.sounds, playable.audio?.buses, playable.audio?.uiCues);
|
|
8
|
+
cache.set(playable, resolved);
|
|
9
|
+
}
|
|
10
|
+
return resolved;
|
|
11
|
+
}
|
package/dist/commandSink.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export declare function localCommandSink(ctx: GameContext): CommandSink;
|
|
|
11
11
|
/** Sends commands to the authoritative host over the transport; the result replicates back through world sync.
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
|
-
export declare function remoteCommandSink(backend: Pick<LiveGameBackend, "transport">, serverId: string): CommandSink;
|
|
14
|
+
export declare function remoteCommandSink(backend: Pick<LiveGameBackend, "transport">, serverId: string, events?: Pick<GameContext["game"]["events"], "emit">): CommandSink;
|
|
15
15
|
/**
|
|
16
16
|
* The sink a server-authoritative shell should dispatch gameplay commands through: remote when the game opts into
|
|
17
17
|
* `authority: "server"` and a server is joined. Before join it discards commands rather than mutating a replica.
|
package/dist/commandSink.js
CHANGED
|
@@ -11,10 +11,15 @@ export function localCommandSink(ctx) {
|
|
|
11
11
|
/** Sends commands to the authoritative host over the transport; the result replicates back through world sync.
|
|
12
12
|
* @internal
|
|
13
13
|
*/
|
|
14
|
-
export function remoteCommandSink(backend, serverId) {
|
|
14
|
+
export function remoteCommandSink(backend, serverId, events) {
|
|
15
15
|
return {
|
|
16
16
|
run(name, input) {
|
|
17
|
-
void backend.transport.runCommand({ serverId, command: name, input }).
|
|
17
|
+
void backend.transport.runCommand({ serverId, command: name, input }).then((result) => {
|
|
18
|
+
// Host refusals reach the local notice feed the same way local ones do.
|
|
19
|
+
if (!result.ok && result.message !== undefined) {
|
|
20
|
+
events?.emit("command.refused", { command: name, reason: result.reason, message: result.message, actor: null });
|
|
21
|
+
}
|
|
22
|
+
}).catch(() => undefined);
|
|
18
23
|
},
|
|
19
24
|
};
|
|
20
25
|
}
|
|
@@ -25,7 +30,7 @@ export function remoteCommandSink(backend, serverId) {
|
|
|
25
30
|
*/
|
|
26
31
|
export function resolveCommandSink(ctx, opts) {
|
|
27
32
|
if (opts.serverAuthoritative && opts.backend !== null && opts.serverId !== null) {
|
|
28
|
-
return remoteCommandSink(opts.backend, opts.serverId);
|
|
33
|
+
return remoteCommandSink(opts.backend, opts.serverId, ctx.game.events);
|
|
29
34
|
}
|
|
30
35
|
if (opts.serverAuthoritative)
|
|
31
36
|
return { run() { } };
|