@jgengine/shell 0.17.0 → 0.18.1

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +130 -2
  2. package/dist/GamePlayerShell.js +10 -4
  3. package/dist/JoinGate.d.ts +17 -0
  4. package/dist/JoinGate.js +14 -0
  5. package/dist/Shell3dPresentation.d.ts +8 -1
  6. package/dist/Shell3dPresentation.js +34 -5
  7. package/dist/ShellChrome.d.ts +2 -0
  8. package/dist/ShellChrome.js +6 -0
  9. package/dist/TerritoryOverlay.d.ts +21 -0
  10. package/dist/TerritoryOverlay.js +9 -0
  11. package/dist/audio/AudioComponents.js +8 -1
  12. package/dist/audio/audioEngine.d.ts +7 -1
  13. package/dist/audio/audioEngine.js +67 -5
  14. package/dist/camera/cameraRigs.js +7 -0
  15. package/dist/defineGame.js +21 -6
  16. package/dist/devtools/KeysPanel.js +3 -3
  17. package/dist/drivers/FrameDriver.d.ts +5 -1
  18. package/dist/drivers/FrameDriver.js +61 -24
  19. package/dist/drivers/HudOnlyDriver.js +9 -4
  20. package/dist/environment/EnvironmentScene.js +1 -1
  21. package/dist/input/gamepadMerge.d.ts +10 -0
  22. package/dist/input/gamepadMerge.js +12 -0
  23. package/dist/input/gamepadSource.d.ts +12 -0
  24. package/dist/input/gamepadSource.js +107 -0
  25. package/dist/materialOverride.d.ts +4 -1
  26. package/dist/materialOverride.js +10 -1
  27. package/dist/postfx/PostProcessing.d.ts +10 -7
  28. package/dist/postfx/PostProcessing.js +33 -15
  29. package/dist/postfx/gradeShader.js +13 -0
  30. package/dist/render/CascadedShadows.d.ts +11 -0
  31. package/dist/render/CascadedShadows.js +19 -8
  32. package/dist/render/EnvironmentLighting.d.ts +4 -1
  33. package/dist/render/EnvironmentLighting.js +45 -9
  34. package/dist/render/SceneLighting.d.ts +16 -2
  35. package/dist/render/SceneLighting.js +56 -6
  36. package/dist/render/SceneModels.js +45 -9
  37. package/dist/render/modelLoad.d.ts +10 -0
  38. package/dist/render/modelLoad.js +28 -0
  39. package/dist/render/modelRender.d.ts +3 -0
  40. package/dist/render/modelRender.js +6 -2
  41. package/dist/render/testFixtures/proceduralSpriteAtlas.d.ts +15 -0
  42. package/dist/render/testFixtures/proceduralSpriteAtlas.js +30 -0
  43. package/dist/render/testFixtures/proceduralTileLayer.d.ts +13 -0
  44. package/dist/render/testFixtures/proceduralTileLayer.js +30 -0
  45. package/dist/render/useFootIk.d.ts +9 -0
  46. package/dist/render/useFootIk.js +89 -0
  47. package/dist/render/useModelAnimation.d.ts +2 -0
  48. package/dist/render/useModelAnimation.js +163 -107
  49. package/dist/scene/CityRenderer.js +661 -236
  50. package/dist/scene/cityWindowMaterial.d.ts +8 -6
  51. package/dist/scene/cityWindowMaterial.js +80 -21
  52. package/dist/settings/appliedSettings.d.ts +3 -1
  53. package/dist/settings/appliedSettings.js +5 -2
  54. package/dist/terrain/terrainDetailMaterial.js +7 -3
  55. package/dist/useShellMultiplayerSync.d.ts +8 -1
  56. package/dist/useShellMultiplayerSync.js +25 -5
  57. package/dist/visibility/CullingProvider.d.ts +2 -1
  58. package/dist/visibility/CullingProvider.js +7 -2
  59. package/dist/world/GridWorldScene.js +4 -0
  60. package/dist/world/TileLayerRenderer.d.ts +9 -0
  61. package/dist/world/TileLayerRenderer.js +64 -0
  62. package/dist/world/WorldParticles.d.ts +31 -0
  63. package/dist/world/WorldParticles.js +128 -0
  64. package/dist/world/WorldScene.d.ts +2 -1
  65. package/dist/world/WorldScene.js +46 -4
  66. package/dist/world/entityPose.d.ts +4 -0
  67. package/dist/world/entityPose.js +9 -0
  68. package/dist/world/tileLayerInstances.d.ts +11 -0
  69. package/dist/world/tileLayerInstances.js +28 -0
  70. package/dist/worldSync.d.ts +5 -1
  71. package/dist/worldSync.js +25 -4
  72. package/package.json +4 -4
  73. package/skills/ce-handoff/SKILL.md +1 -1
  74. package/skills/game-design/SKILL.md +11 -1
  75. package/skills/jgengine/SKILL.md +2 -0
  76. package/skills/jgengine/api.md +98 -22
  77. package/skills/jgengine/capabilities.md +28 -0
  78. package/skills/jgengine-ui/SKILL.md +8 -0
  79. package/skills/jgengine-ui/api.md +127 -16
  80. package/skills/jgengine-ui/capabilities.md +21 -0
  81. package/skills/jgengine-ui/reference.md +3 -0
  82. package/skills/jgengine-world/SKILL.md +6 -0
  83. package/skills/jgengine-world/api.md +312 -12
  84. package/skills/jgengine-world/capabilities.md +102 -0
  85. package/skills/level-design/SKILL.md +1 -1
  86. package/skills/jgengine-ui/references/visual-scorecard.md +0 -73
@@ -1,12 +1,14 @@
1
1
  import * as THREE from "three";
2
2
  /**
3
3
  * Instanced facade material for city massing walls: a `MeshStandardMaterial` whose fragment stage
4
- * paints procedural window strips on the side faces of every instanced unit box — floor bands from
5
- * the district's real `floorHeight`, window columns on a fixed pitch, and a per-cell hash that
6
- * dims/lights individual windows so no two facades read identical. Ground floors (plus any buried
7
- * foundation, via the `instanceGroundOffset` attribute) stay solid wall so street level reads as
8
- * storefront/lobby rather than floating glass. Same `onBeforeCompile` idiom as the grass/terrain
9
- * materials; instance colors keep tinting the wall exactly like the plain massing material.
4
+ * paints a real facade on the side faces of every instanced unit box — floor bands from the
5
+ * district's `floorHeight`, spandrels between floors, framed panes with mullions, a per-cell hash
6
+ * that lights a minority of windows (emissive), and a ground floor of plinth, storefront glazing
7
+ * and fascia so street level never repeats floor 30. Two instanced attributes drive it:
8
+ * `instanceGroundOffset` (buried foundation to skip) and `instanceFacade` (`x` = per-building seed,
9
+ * `y` = facade kind — 0 punched openings, 1 curtain wall, 2 open parking deck). Same
10
+ * `onBeforeCompile` idiom as the grass/terrain materials; instance colors keep tinting the wall
11
+ * exactly like the plain massing material.
10
12
  * @internal — created by `CityRenderer` for the banded-piece InstancedMesh.
11
13
  */
12
14
  export declare function createCityWindowMaterial(options: {
@@ -1,12 +1,14 @@
1
1
  import * as THREE from "three";
2
2
  /**
3
3
  * Instanced facade material for city massing walls: a `MeshStandardMaterial` whose fragment stage
4
- * paints procedural window strips on the side faces of every instanced unit box — floor bands from
5
- * the district's real `floorHeight`, window columns on a fixed pitch, and a per-cell hash that
6
- * dims/lights individual windows so no two facades read identical. Ground floors (plus any buried
7
- * foundation, via the `instanceGroundOffset` attribute) stay solid wall so street level reads as
8
- * storefront/lobby rather than floating glass. Same `onBeforeCompile` idiom as the grass/terrain
9
- * materials; instance colors keep tinting the wall exactly like the plain massing material.
4
+ * paints a real facade on the side faces of every instanced unit box — floor bands from the
5
+ * district's `floorHeight`, spandrels between floors, framed panes with mullions, a per-cell hash
6
+ * that lights a minority of windows (emissive), and a ground floor of plinth, storefront glazing
7
+ * and fascia so street level never repeats floor 30. Two instanced attributes drive it:
8
+ * `instanceGroundOffset` (buried foundation to skip) and `instanceFacade` (`x` = per-building seed,
9
+ * `y` = facade kind — 0 punched openings, 1 curtain wall, 2 open parking deck). Same
10
+ * `onBeforeCompile` idiom as the grass/terrain materials; instance colors keep tinting the wall
11
+ * exactly like the plain massing material.
10
12
  * @internal — created by `CityRenderer` for the banded-piece InstancedMesh.
11
13
  */
12
14
  export function createCityWindowMaterial(options) {
@@ -25,10 +27,12 @@ export function createCityWindowMaterial(options) {
25
27
  .replace("#include <common>", `
26
28
  #include <common>
27
29
  attribute float instanceGroundOffset;
30
+ attribute vec2 instanceFacade;
28
31
  varying vec3 vCityLocal;
29
32
  varying vec3 vCityScale;
30
33
  varying vec3 vCityNormal;
31
34
  varying float vCityGround;
35
+ varying vec2 vCityFacade;
32
36
  `)
33
37
  .replace("#include <begin_vertex>", `
34
38
  #include <begin_vertex>
@@ -36,6 +40,7 @@ vCityLocal = position;
36
40
  vCityScale = vec3(length(instanceMatrix[0].xyz), length(instanceMatrix[1].xyz), length(instanceMatrix[2].xyz));
37
41
  vCityNormal = normal;
38
42
  vCityGround = instanceGroundOffset;
43
+ vCityFacade = instanceFacade;
39
44
  `);
40
45
  shader.fragmentShader = shader.fragmentShader
41
46
  .replace("#include <common>", `
@@ -46,6 +51,8 @@ varying vec3 vCityLocal;
46
51
  varying vec3 vCityScale;
47
52
  varying vec3 vCityNormal;
48
53
  varying float vCityGround;
54
+ varying vec2 vCityFacade;
55
+ float cityLit = 0.0;
49
56
  float cityHash(vec2 cell) {
50
57
  return fract(sin(dot(cell, vec2(127.1, 311.7))) * 43758.5453);
51
58
  }
@@ -55,25 +62,77 @@ float cityHash(vec2 cell) {
55
62
  {
56
63
  // Side faces only — roofs and undersides stay plain wall.
57
64
  if (abs(vCityNormal.y) < 0.5) {
58
- float along = abs(vCityNormal.x) > 0.5 ? (vCityLocal.z + 0.5) * vCityScale.z : (vCityLocal.x + 0.5) * vCityScale.x;
59
- float faceWidth = abs(vCityNormal.x) > 0.5 ? vCityScale.z : vCityScale.x;
65
+ bool sideX = abs(vCityNormal.x) > 0.5;
66
+ float along = sideX ? (vCityLocal.z + 0.5) * vCityScale.z : (vCityLocal.x + 0.5) * vCityScale.x;
67
+ float faceWidth = sideX ? vCityScale.z : vCityScale.x;
60
68
  float up = (vCityLocal.y + 0.5) * vCityScale.y - vCityGround;
61
- float pitch = 2.4;
62
- float floorT = fract(up / uFloorHeight);
63
- float colT = fract(along / pitch);
64
- float floorIndex = floor(up / uFloorHeight);
65
- float colIndex = floor(along / pitch);
66
- bool window = up > uFloorHeight * 1.05 && floorT > 0.32 && floorT < 0.78 && colT > 0.2 && colT < 0.8
67
- && along > 0.7 && along < faceWidth - 0.7;
68
- if (window) {
69
- float cell = cityHash(vec2(floorIndex, colIndex) + vec2(vCityScale.x, vCityScale.z));
70
- vec3 pane = uWindowColor * mix(0.55, 1.25, cell);
71
- diffuseColor.rgb = mix(diffuseColor.rgb, pane, 0.88);
69
+ float fh = uFloorHeight;
70
+ float seed = vCityFacade.x;
71
+ float kind = vCityFacade.y;
72
+ float face = (sideX ? 2.0 : 0.0) + (vCityNormal.x + vCityNormal.z > 0.0 ? 1.0 : 0.0);
73
+ vec3 wall = diffuseColor.rgb;
74
+ float floorT = fract(up / fh);
75
+ float floorIndex = floor(up / fh);
76
+ // The facade is a procedural stripe pattern with no mip chain, so once a floor band shrinks
77
+ // toward a pixel it aliases into speckled static. Measure the on-screen footprint of one metre
78
+ // of facade and dissolve the detail into its own average before that happens.
79
+ float facadeFootprint = fwidth(up) + fwidth(along);
80
+ float detail = 1.0 - smoothstep(fh * 0.08, fh * 0.45, facadeFootprint);
81
+ if (kind > 1.5) {
82
+ // Open parking deck: shadowed slots between column bays, solid spandrel below each level.
83
+ float bay = fract(along / 6.0);
84
+ bool slot = up > fh * 0.55 && floorT > 0.22 && floorT < 0.82
85
+ && along > 1.4 && along < faceWidth - 1.4 && bay > 0.14;
86
+ diffuseColor.rgb = slot ? wall * 0.16 : wall * (floorT < 0.18 ? 0.86 : 1.0);
87
+ } else if (up > fh * 1.02) {
88
+ float curtain = step(0.5, kind);
89
+ float pitch = mix(2.45, 1.75, curtain);
90
+ float glassW = mix(0.46, 0.7, curtain);
91
+ float margin = mix(0.9, 0.55, curtain);
92
+ float sillT = mix(0.36, 0.24, curtain);
93
+ float headT = mix(0.8, 0.87, curtain);
94
+ float colIndex = floor(along / pitch);
95
+ float colC = abs(fract(along / pitch) - 0.5) * 2.0;
96
+ bool inBand = along > margin && along < faceWidth - margin;
97
+ if (inBand && floorT > sillT && floorT < headT && colC < glassW) {
98
+ float cell = cityHash(vec2(floorIndex, colIndex) + vec2(seed * 41.0, face * 7.0));
99
+ float lit = step(0.9, cell);
100
+ vec3 pane = uWindowColor * mix(0.34, 0.86, cell);
101
+ pane = mix(pane, vec3(1.0, 0.9, 0.7), lit * 0.5);
102
+ // Pane frame + a transom across tall curtain-wall bays: the grid that reads as glazing.
103
+ float frame = step(glassW - 0.14, colC) + curtain * step(abs(floorT - (sillT + headT) * 0.5), 0.022);
104
+ diffuseColor.rgb = mix(pane, wall * mix(0.7, 0.5, curtain), clamp(frame, 0.0, 1.0));
105
+ cityLit = lit * (1.0 - clamp(frame, 0.0, 1.0)) * 0.3;
106
+ } else if (inBand && (floorT <= sillT || floorT >= headT)) {
107
+ diffuseColor.rgb = wall * mix(0.9, 0.78, curtain);
108
+ // Sill lip under each opening catches the light and breaks the flat band.
109
+ if (colC < glassW && floorT > sillT - 0.055) diffuseColor.rgb = wall * mix(1.08, 0.9, curtain);
110
+ }
111
+ } else {
112
+ // Ground floor: plinth, pier-and-glass shopfront, fascia band under the first slab.
113
+ if (up < 0.55) {
114
+ diffuseColor.rgb = wall * 0.62;
115
+ } else if (faceWidth > 5.0) {
116
+ float pier = abs(fract(along / 3.1) - 0.5) * 2.0;
117
+ bool store = up > 0.75 && up < fh * 0.74 && pier < 0.78
118
+ && along > 1.1 && along < faceWidth - 1.1;
119
+ if (store) {
120
+ diffuseColor.rgb = uWindowColor * 0.17;
121
+ cityLit = 0.1;
122
+ } else if (up > fh * 0.78 && up < fh * 0.96) {
123
+ diffuseColor.rgb = wall * 0.75;
124
+ }
125
+ }
72
126
  }
73
- // A thin darker plinth line right above grade grounds the facade.
74
- if (up > 0.0 && up < 0.35) diffuseColor.rgb *= 0.8;
127
+ vec3 distant = mix(wall, uWindowColor * 0.5, 0.42);
128
+ diffuseColor.rgb = mix(distant, diffuseColor.rgb, detail);
129
+ cityLit *= detail;
75
130
  }
76
131
  }
132
+ `)
133
+ .replace("#include <emissivemap_fragment>", `
134
+ #include <emissivemap_fragment>
135
+ totalEmissiveRadiance += mix(uWindowColor, vec3(1.0, 0.86, 0.6), 0.6) * cityLit;
77
136
  `);
78
137
  };
79
138
  material.customProgramCacheKey = () => `jgengine-city-windows`;
@@ -1,5 +1,6 @@
1
1
  import type { AudioBusDef } from "@jgengine/core/audio/audioFalloff";
2
2
  import { type GraphicsQuality, type SettingsStore } from "@jgengine/core/settings/settingsModel";
3
+ import { type GraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
3
4
  import { type TouchJoystickVariant, type TouchStyle } from "@jgengine/core/input/touchScheme";
4
5
  import type { AudioEngine } from "../audio/audioEngine.js";
5
6
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
@@ -9,11 +10,12 @@ export declare function useTouchStyle(store: SettingsStore, fallback: TouchStyle
9
10
  /** Shell-internal: binds the SettingsStore joystick-variant choice (fixed vs floating) to the touch renderer. @internal */
10
11
  export declare function useTouchJoystickVariant(store: SettingsStore): TouchJoystickVariant;
11
12
  export declare function useSettingsRevision(store: SettingsStore): number;
12
- export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean): {
13
+ export declare function useGraphicsSettings(store: SettingsStore, shadowsDefault: boolean, overrides?: Partial<Record<GraphicsQuality, Partial<GraphicsProfile>>>): {
13
14
  shadows: boolean;
14
15
  dpr: number;
15
16
  uiScale: number;
16
17
  quality: GraphicsQuality;
18
+ profile: GraphicsProfile;
17
19
  };
18
20
  export declare function AudioSettingsBridge({ store, engine, buses, }: {
19
21
  store: SettingsStore;
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
2
2
  import { busVolumeSettingId, DEFAULT_GRAPHICS_QUALITY, DEFAULT_MASTER_VOLUME, DEFAULT_UI_SCALE, GRAPHICS_QUALITY_DPR, SETTING_IDS, UI_SCALE_MAX, UI_SCALE_MIN, } from "@jgengine/core/settings/settingsModel";
3
+ import { resolveGraphicsProfile } from "@jgengine/core/settings/graphicsProfile";
3
4
  import { DEFAULT_TOUCH_JOYSTICK_VARIANT, TOUCH_JOYSTICK_VARIANTS, TOUCH_STYLES, } from "@jgengine/core/input/touchScheme";
4
5
  /** Sentinel Controls value meaning "defer to the game's suggested touch skin". */
5
6
  export const TOUCH_STYLE_AUTO = "auto";
@@ -26,16 +27,18 @@ export function useSettingsRevision(store) {
26
27
  const getVersion = useCallback(() => versionRef.current, []);
27
28
  return useSyncExternalStore(subscribe, getVersion, getVersion);
28
29
  }
29
- export function useGraphicsSettings(store, shadowsDefault) {
30
+ export function useGraphicsSettings(store, shadowsDefault, overrides) {
30
31
  useSettingsRevision(store);
31
32
  const rawQuality = store.get(SETTING_IDS.graphicsQuality, DEFAULT_GRAPHICS_QUALITY);
32
33
  const quality = GRAPHICS_QUALITY_DPR[rawQuality] !== undefined ? rawQuality : "high";
34
+ const profile = resolveGraphicsProfile(quality, overrides?.[quality]);
33
35
  const rawUiScale = store.get(SETTING_IDS.graphicsUiScale, DEFAULT_UI_SCALE);
34
36
  return {
35
37
  shadows: store.get(SETTING_IDS.graphicsShadows, shadowsDefault),
36
- dpr: GRAPHICS_QUALITY_DPR[quality],
38
+ dpr: profile.renderScale,
37
39
  uiScale: Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, rawUiScale)),
38
40
  quality,
41
+ profile,
39
42
  };
40
43
  }
41
44
  export function AudioSettingsBridge({ store, engine, buses, }) {
@@ -48,6 +48,8 @@ export function createTerrainDetailMaterial(detail, textures) {
48
48
  uRockColor: { value: new THREE.Color(detail.rockColor) },
49
49
  uSandColor: { value: new THREE.Color(detail.sandColor) },
50
50
  uSnowColor: { value: new THREE.Color(detail.snowColor) },
51
+ uSweepDry: { value: new THREE.Vector3(...detail.sweeps.dry) },
52
+ uSweepWet: { value: new THREE.Vector3(...detail.sweeps.wet) },
51
53
  uRockSlopeStart: { value: detail.rockSlopeStart },
52
54
  uSnowHeight: { value: detail.snowHeight },
53
55
  uWaterLevel: { value: detail.waterLevel },
@@ -126,6 +128,8 @@ varying vec3 vJgWorldNormal;
126
128
  uniform vec3 uRockColor;
127
129
  uniform vec3 uSandColor;
128
130
  uniform vec3 uSnowColor;
131
+ uniform vec3 uSweepDry;
132
+ uniform vec3 uSweepWet;
129
133
  uniform float uRockSlopeStart;
130
134
  uniform float uSnowHeight;
131
135
  uniform float uWaterLevel;
@@ -149,9 +153,9 @@ float jgFleck = jgVNoise(jgWp / max(0.05, uDetailScale * 0.024));
149
153
  float jgFleckMid = jgVNoise(jgWp / max(0.12, uDetailScale * 0.07) + vec2(51.3, 7.9));
150
154
  vec3 jgBase = diffuseColor.rgb;
151
155
  jgBase *= mix(1.0 - 0.2 * uStrength, 1.0 + 0.16 * uStrength, jgFine);
152
- // Meadow patchwork: hue-shifted sun-dried sweeps and cooler lush pockets, not just darker spots.
153
- jgBase = mix(jgBase, jgBase * vec3(1.14, 1.05, 0.76), smoothstep(0.5, 0.8, jgMacro) * 0.55 * uStrength);
154
- jgBase = mix(jgBase, jgBase * vec3(0.78, 1.0, 0.86), smoothstep(0.52, 0.85, jgPatch) * 0.5 * uStrength);
156
+ // Biome patchwork: hue-shifted dry sweeps and cooler wet pockets, tinted per-world via detail.sweeps.
157
+ jgBase = mix(jgBase, jgBase * uSweepDry, smoothstep(0.5, 0.8, jgMacro) * 0.55 * uStrength);
158
+ jgBase = mix(jgBase, jgBase * uSweepWet, smoothstep(0.52, 0.85, jgPatch) * 0.5 * uStrength);
155
159
  // Rolling large-scale light/dark sweeps keep far hills from flattening into one tone.
156
160
  jgBase *= 1.0 + (jgFbm(jgWp / (uMacroScale * 2.3) + vec2(9.1, 73.4)) - 0.5) * 0.22 * uStrength;
157
161
  jgBase *= 1.0 + (jgFleck - 0.5) * 0.48 * uStrength * jgNear;
@@ -1,9 +1,16 @@
1
1
  import { type Dispatch, type SetStateAction } from "react";
2
2
  import type { GameContext } from "@jgengine/core/runtime/gameContext";
3
3
  import type { PresencePoseRow } from "@jgengine/core/runtime/transport";
4
+ import { type AuthoritativeFrameHandler } from "./worldSync.js";
4
5
  import type { ShellMultiplayer } from "./multiplayer.js";
5
6
  import type { PlayableGame } from "./registry.js";
6
7
  /** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
7
8
  export declare function useShellMultiplayerSync(ctx: GameContext | null, multiplayer: ShellMultiplayer | null, playable: PlayableGame, serverIdRef: {
8
9
  current: string | null;
9
- }, setRemotePlayers: Dispatch<SetStateAction<PresencePoseRow[]>>): void;
10
+ }, setRemotePlayers: Dispatch<SetStateAction<PresencePoseRow[]>>, authoritativeFrameRef?: {
11
+ current: AuthoritativeFrameHandler | null;
12
+ }): {
13
+ status: "joining" | "joined" | "failed";
14
+ failureReason: string | null;
15
+ retry: () => void;
16
+ };
@@ -1,23 +1,37 @@
1
- import { useEffect } from "react";
1
+ import { useEffect, useState, useCallback } from "react";
2
2
  import { isServerAuthoritative } from "@jgengine/core/runtime/adapter";
3
3
  import { attachWorldSync } from "./worldSync.js";
4
4
  /** Joins the multiplayer server for the live context and wires presence, feed relay, and chat sync until teardown. */
5
- export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers) {
5
+ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef, setRemotePlayers, authoritativeFrameRef) {
6
+ const [status, setStatus] = useState("joining");
7
+ const [failureReason, setFailureReason] = useState(null);
8
+ const [attempt, setAttempt] = useState(0);
9
+ const retry = useCallback(() => setAttempt((value) => value + 1), []);
6
10
  useEffect(() => {
7
11
  if (ctx === null || multiplayer === null)
8
12
  return;
13
+ setStatus("joining");
14
+ setFailureReason(null);
9
15
  let disposed = false;
10
16
  const cleanups = [];
11
17
  void multiplayer.backend.transport
12
18
  .joinServer({ gameId: multiplayer.gameId })
13
19
  .then((joined) => {
20
+ if (!joined.ok) {
21
+ if (!disposed) {
22
+ setFailureReason(joined.reason);
23
+ setStatus("failed");
24
+ }
25
+ return;
26
+ }
14
27
  if (disposed) {
15
28
  void multiplayer.backend.transport.leaveServer({ serverId: joined.serverId });
16
29
  return;
17
30
  }
18
31
  serverIdRef.current = joined.serverId;
32
+ setStatus("joined");
19
33
  if (isServerAuthoritative(playable.game.multiplayer) && multiplayer.backend.feeds !== undefined) {
20
- cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx));
34
+ cleanups.push(attachWorldSync(multiplayer.backend.feeds, joined.serverId, ctx, (revision) => authoritativeFrameRef?.current?.(revision)));
21
35
  }
22
36
  cleanups.push(multiplayer.backend.presenceSync.subscribe(joined.serverId, (rows) => {
23
37
  setRemotePlayers(rows.filter((row) => row.userId !== multiplayer.userId));
@@ -82,7 +96,12 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
82
96
  }
83
97
  }
84
98
  })
85
- .catch(() => undefined);
99
+ .catch((error) => {
100
+ if (!disposed) {
101
+ setFailureReason(error instanceof Error ? error.message : "Connection failed");
102
+ setStatus("failed");
103
+ }
104
+ });
86
105
  return () => {
87
106
  disposed = true;
88
107
  for (const cleanup of cleanups)
@@ -94,5 +113,6 @@ export function useShellMultiplayerSync(ctx, multiplayer, playable, serverIdRef,
94
113
  void multiplayer.backend.transport.leaveServer({ serverId }).catch(() => undefined);
95
114
  }
96
115
  };
97
- }, [ctx, multiplayer, playable]);
116
+ }, [ctx, multiplayer, playable, attempt]);
117
+ return { status: multiplayer === null ? "joined" : status, failureReason, retry };
98
118
  }
@@ -14,8 +14,9 @@ export declare function useRenderVisibility(): MutableRefObject<VisiblePredicate
14
14
  * conservative preload margin) are never submitted to the renderer, without unmounting them or
15
15
  * touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
16
16
  */
17
- export declare function CullingProvider({ config, children }: {
17
+ export declare function CullingProvider({ config, drawDistance, children }: {
18
18
  config: VisibilityConfig | undefined;
19
+ drawDistance?: number;
19
20
  children: ReactNode;
20
21
  }): ReactNode;
21
22
  export {};
@@ -110,12 +110,17 @@ function viewFromCamera(camera) {
110
110
  * conservative preload margin) are never submitted to the renderer, without unmounting them or
111
111
  * touching gameplay. UI, sky, terrain, and environment live outside this subtree and are unaffected.
112
112
  */
113
- export function CullingProvider({ config, children }) {
113
+ export function CullingProvider({ config, drawDistance, children }) {
114
114
  const ctx = useGameContext();
115
115
  const enabled = config?.enabled !== false;
116
116
  const predicateRef = useRef(ALWAYS_VISIBLE);
117
117
  const camera = useThree((state) => state.camera);
118
- const driver = useMemo(() => (enabled ? buildSystem(ctx, config) : null), [ctx, config, enabled]);
118
+ const resolvedConfig = useMemo(() => {
119
+ if (drawDistance === undefined)
120
+ return config;
121
+ return { ...config, culling: { ...config?.culling, defaultMaxRenderDistance: config?.culling?.defaultMaxRenderDistance ?? drawDistance } };
122
+ }, [config, drawDistance]);
123
+ const driver = useMemo(() => (enabled ? buildSystem(ctx, resolvedConfig) : null), [ctx, resolvedConfig, enabled]);
119
124
  useFrame(() => {
120
125
  if (driver === null) {
121
126
  predicateRef.current = ALWAYS_VISIBLE;
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef } from "react";
3
3
  import * as THREE from "three";
4
4
  import { resolveGridInstances } from "@jgengine/core/world/gridInstances";
5
5
  import { useDisposable } from "../render/useDisposable.js";
6
+ import { TileLayerRenderer } from "./TileLayerRenderer.js";
6
7
  function isGridWorldFeature(feature) {
7
8
  return (feature.kind === "biomes" || feature.kind === "voxel" || feature.kind === "plots" || feature.kind === "tilemap");
8
9
  }
@@ -12,6 +13,9 @@ function isGridWorldFeature(feature) {
12
13
  * `cells`, following the same direct-buffer pattern as `InstancedBodies`.
13
14
  */
14
15
  export function GridWorldScene({ feature }) {
16
+ if (feature.kind === "tilemap" && feature.tileSet !== undefined) {
17
+ return _jsx(TileLayerRenderer, { config: feature });
18
+ }
15
19
  const instances = useMemo(() => (isGridWorldFeature(feature) ? resolveGridInstances(feature) : []), [feature]);
16
20
  const meshRef = useRef(null);
17
21
  const geometry = useDisposable(() => new THREE.BoxGeometry(1, 1, 1), []);
@@ -0,0 +1,9 @@
1
+ import type { TilemapWorldConfig } from "@jgengine/core/world/features";
2
+ /** Inputs for the instanced textured tile-layer renderer. */
3
+ export interface TileLayerRendererProps {
4
+ config: TilemapWorldConfig;
5
+ parallax?: readonly [number, number];
6
+ pixelated?: boolean;
7
+ }
8
+ /** Renders a tilemap as one instanced quad mesh with atlas UV rectangles. */
9
+ export declare function TileLayerRenderer({ config, parallax, pixelated }: TileLayerRendererProps): import("react").JSX.Element | null;
@@ -0,0 +1,64 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useFrame, useLoader, useThree } from "@react-three/fiber";
3
+ import { useEffect, useMemo, useRef } from "react";
4
+ import * as THREE from "three";
5
+ import { sortingOrder } from "@jgengine/core/render/sprite2d";
6
+ import { useDisposable } from "../render/useDisposable.js";
7
+ import { resolveTileLayerInstances } from "./tileLayerInstances.js";
8
+ /** Renders a tilemap as one instanced quad mesh with atlas UV rectangles. */
9
+ export function TileLayerRenderer({ config, parallax = [1, 1], pixelated = true }) {
10
+ const tileSet = config.tileSet;
11
+ const { camera } = useThree();
12
+ const texture = useLoader(THREE.TextureLoader, tileSet?.atlas.image ?? "");
13
+ const instances = useMemo(() => resolveTileLayerInstances(config), [config]);
14
+ const meshRef = useRef(null);
15
+ const geometry = useDisposable(() => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2), []);
16
+ const uvOffset = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
17
+ const uvScale = useMemo(() => new THREE.InstancedBufferAttribute(new Float32Array(Math.max(instances.length, 1) * 2), 2), [instances.length]);
18
+ const material = useDisposable(() => {
19
+ const mat = new THREE.MeshBasicMaterial({ map: texture, transparent: true, alphaTest: 0.08, depthWrite: false });
20
+ mat.onBeforeCompile = (shader) => {
21
+ shader.vertexShader = shader.vertexShader.replace("#include <common>", "#include <common>\nattribute vec2 tileUvOffset;\nattribute vec2 tileUvScale;").replace("#include <uv_vertex>", "#include <uv_vertex>\n#ifdef USE_MAP\n vMapUv = vMapUv * tileUvScale + tileUvOffset;\n#endif");
22
+ };
23
+ mat.customProgramCacheKey = () => "jgengine-tile-layer";
24
+ return mat;
25
+ }, [texture]);
26
+ useEffect(() => {
27
+ texture.colorSpace = THREE.SRGBColorSpace;
28
+ texture.magFilter = pixelated ? THREE.NearestFilter : THREE.LinearFilter;
29
+ texture.minFilter = pixelated ? THREE.NearestFilter : THREE.LinearMipmapLinearFilter;
30
+ texture.needsUpdate = true;
31
+ }, [texture, pixelated]);
32
+ useEffect(() => {
33
+ geometry.setAttribute("tileUvOffset", uvOffset);
34
+ geometry.setAttribute("tileUvScale", uvScale);
35
+ }, [geometry, uvOffset, uvScale]);
36
+ const matrix = useMemo(() => new THREE.Matrix4(), []);
37
+ useFrame(() => {
38
+ const mesh = meshRef.current;
39
+ if (mesh === null)
40
+ return;
41
+ mesh.position.set(-camera.position.x * (1 - parallax[0]), 0, camera.position.z * (1 - parallax[1]));
42
+ });
43
+ useEffect(() => {
44
+ const mesh = meshRef.current;
45
+ if (mesh === null)
46
+ return;
47
+ mesh.count = instances.length;
48
+ const array = mesh.instanceMatrix.array;
49
+ const offset = uvOffset.array;
50
+ const scale = uvScale.array;
51
+ for (let i = 0; i < instances.length; i += 1) {
52
+ const instance = instances[i];
53
+ matrix.makeTranslation(instance.x, 0, instance.z).toArray(array, i * 16);
54
+ offset.set(instance.uvOffset, i * 2);
55
+ scale.set(instance.uvScale, i * 2);
56
+ }
57
+ mesh.instanceMatrix.needsUpdate = true;
58
+ uvOffset.needsUpdate = true;
59
+ uvScale.needsUpdate = true;
60
+ }, [instances, matrix, uvOffset, uvScale]);
61
+ if (tileSet === undefined || instances.length === 0)
62
+ return null;
63
+ return _jsx("instancedMesh", { ref: meshRef, args: [geometry, material, instances.length], renderOrder: sortingOrder(["ground", "objects", "effects"], config.sortingLayer ?? "ground", 0), frustumCulled: false });
64
+ }
@@ -0,0 +1,31 @@
1
+ import { type ReactElement } from "react";
2
+ import type { GraphicsQuality } from "@jgengine/core/settings/settingsModel";
3
+ import type { ParticleEmitterSpec } from "@jgengine/core/vfx/particleDirector";
4
+ /**
5
+ * The per-effect particle pool a graphics tier allows: the requested `max`
6
+ * clamped to the tier cap, so lower tiers degrade density instead of dropping
7
+ * effects entirely.
8
+ */
9
+ export declare function resolveParticleBudget(quality: GraphicsQuality, requested: number | undefined, particleCap?: number): number;
10
+ /**
11
+ * Whether a spec change touches something the JSX reads (blending, static offset,
12
+ * follow binding) and therefore needs a React re-render — config-only retunes are
13
+ * applied imperatively to the live system and must stay render-free.
14
+ *
15
+ * @internal WorldParticles render-boundary helper, exported for its regression test.
16
+ */
17
+ export declare function specNeedsRender(prev: ParticleEmitterSpec, next: ParticleEmitterSpec): boolean;
18
+ /**
19
+ * Renders `ctx.particles` — the game-reachable particle seam. Standing emitters
20
+ * become persistent `ParticleField`s (recreated only when their pool size changes,
21
+ * retuned in place otherwise), `follow` emitters track their entity's live pose
22
+ * each frame, and drained bursts become transient systems reaped once empty.
23
+ * Auto-mounted by the player shell; pools are capped by graphics quality via
24
+ * {@link resolveParticleBudget}.
25
+ *
26
+ * @internal
27
+ */
28
+ export declare function WorldParticles({ quality, particleCap }: {
29
+ quality: GraphicsQuality;
30
+ particleCap?: number;
31
+ }): ReactElement;
@@ -0,0 +1,128 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useEffect, useRef, useState } from "react";
4
+ import { createParticleSystem } from "@jgengine/core/vfx/particles";
5
+ import { useGameContext } from "@jgengine/react/provider";
6
+ import { ParticleField } from "../vfx/ParticleField.js";
7
+ const QUALITY_POOL_CAP = { low: 128, medium: 256, high: 512 };
8
+ /**
9
+ * The per-effect particle pool a graphics tier allows: the requested `max`
10
+ * clamped to the tier cap, so lower tiers degrade density instead of dropping
11
+ * effects entirely.
12
+ */
13
+ export function resolveParticleBudget(quality, requested, particleCap) {
14
+ const cap = Math.max(1, Math.floor(particleCap ?? QUALITY_POOL_CAP[quality]));
15
+ return Math.max(1, Math.min(requested ?? cap, cap));
16
+ }
17
+ const MAX_CONCURRENT_BURSTS = 32;
18
+ /**
19
+ * Whether a spec change touches something the JSX reads (blending, static offset,
20
+ * follow binding) and therefore needs a React re-render — config-only retunes are
21
+ * applied imperatively to the live system and must stay render-free.
22
+ *
23
+ * @internal WorldParticles render-boundary helper, exported for its regression test.
24
+ */
25
+ export function specNeedsRender(prev, next) {
26
+ if (prev.blending !== next.blending || prev.follow !== next.follow)
27
+ return true;
28
+ const a = prev.offset;
29
+ const b = next.offset;
30
+ if (a === b)
31
+ return false;
32
+ if (a === undefined || b === undefined)
33
+ return true;
34
+ return a[0] !== b[0] || a[1] !== b[1] || a[2] !== b[2];
35
+ }
36
+ /**
37
+ * Renders `ctx.particles` — the game-reachable particle seam. Standing emitters
38
+ * become persistent `ParticleField`s (recreated only when their pool size changes,
39
+ * retuned in place otherwise), `follow` emitters track their entity's live pose
40
+ * each frame, and drained bursts become transient systems reaped once empty.
41
+ * Auto-mounted by the player shell; pools are capped by graphics quality via
42
+ * {@link resolveParticleBudget}.
43
+ *
44
+ * @internal
45
+ */
46
+ export function WorldParticles({ quality, particleCap }) {
47
+ const ctx = useGameContext();
48
+ const emittersRef = useRef(new Map());
49
+ const burstsRef = useRef([]);
50
+ const [, setGeneration] = useState(0);
51
+ useEffect(() => {
52
+ const emitters = emittersRef.current;
53
+ function sync() {
54
+ let changed = false;
55
+ const specs = ctx.particles.emitters();
56
+ const liveIds = new Set();
57
+ for (const spec of specs) {
58
+ liveIds.add(spec.id);
59
+ const poolMax = resolveParticleBudget(quality, spec.config.max, particleCap);
60
+ const current = emitters.get(spec.id);
61
+ if (current === undefined || current.poolMax !== poolMax) {
62
+ emitters.set(spec.id, {
63
+ spec,
64
+ poolMax,
65
+ system: createParticleSystem({ ...spec.config, max: poolMax, seed: spec.id }),
66
+ });
67
+ changed = true;
68
+ }
69
+ else if (current.spec !== spec) {
70
+ if (specNeedsRender(current.spec, spec))
71
+ changed = true;
72
+ current.system.configure({ ...spec.config, max: poolMax });
73
+ current.spec = spec;
74
+ }
75
+ }
76
+ for (const id of emitters.keys()) {
77
+ if (liveIds.has(id))
78
+ continue;
79
+ emitters.delete(id);
80
+ changed = true;
81
+ }
82
+ for (const burst of ctx.particles.drainBursts()) {
83
+ const poolMax = resolveParticleBudget(quality, Math.min(burst.count, burst.config.max ?? burst.count), particleCap);
84
+ const system = createParticleSystem({ ...burst.config, max: poolMax, seed: burst.seq });
85
+ system.emit(burst.count);
86
+ burstsRef.current.push({ seq: burst.seq, system, blending: burst.blending ?? "additive" });
87
+ if (burstsRef.current.length > MAX_CONCURRENT_BURSTS)
88
+ burstsRef.current.shift();
89
+ changed = true;
90
+ }
91
+ if (changed)
92
+ setGeneration((n) => n + 1);
93
+ }
94
+ sync();
95
+ const off = ctx.particles.subscribe(sync);
96
+ return () => {
97
+ off();
98
+ emitters.clear();
99
+ burstsRef.current = [];
100
+ };
101
+ }, [ctx, quality, particleCap]);
102
+ useFrame(() => {
103
+ for (const instance of emittersRef.current.values()) {
104
+ const follow = instance.spec.follow;
105
+ if (follow === undefined)
106
+ continue;
107
+ const entity = ctx.scene.entity.get(follow);
108
+ if (entity === null || entity === undefined)
109
+ continue;
110
+ const offset = instance.spec.offset ?? [0, 0, 0];
111
+ instance.system.configure({
112
+ position: [
113
+ entity.position[0] + offset[0],
114
+ entity.position[1] + offset[1],
115
+ entity.position[2] + offset[2],
116
+ ],
117
+ });
118
+ }
119
+ const finished = burstsRef.current.filter((burst) => burst.system.count() === 0);
120
+ if (finished.length > 0) {
121
+ burstsRef.current = burstsRef.current.filter((burst) => burst.system.count() > 0);
122
+ setGeneration((n) => n + 1);
123
+ }
124
+ });
125
+ return (_jsxs(_Fragment, { children: [Array.from(emittersRef.current.values()).map((instance) => (_jsx("group", { position: instance.spec.follow === undefined && instance.spec.offset !== undefined
126
+ ? instance.spec.offset
127
+ : [0, 0, 0], children: _jsx(ParticleField, { system: instance.system, blending: instance.spec.blending ?? "normal" }) }, instance.spec.id))), burstsRef.current.map((burst) => (_jsx(ParticleField, { system: burst.system, blending: burst.blending }, burst.seq)))] }));
128
+ }
@@ -16,6 +16,7 @@ export declare function WorldView({ entitySprites, entityModels, objectModels, o
16
16
  selectedIds: ReadonlySet<string>;
17
17
  hideLocalActor: boolean;
18
18
  }): import("react").JSX.Element;
19
- export declare function RemotePlayers({ rows }: {
19
+ export declare function RemotePlayers({ rows, presenceInterpolationMs }: {
20
20
  rows: PresencePoseRow[];
21
+ presenceInterpolationMs?: number;
21
22
  }): import("react").JSX.Element;