@jgengine/core 0.9.0 → 0.10.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.
Files changed (114) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/dist/ai/spawnDirector.d.ts +13 -0
  3. package/dist/ai/spawnDirector.js +40 -16
  4. package/dist/ai/spawnPoint.d.ts +5 -0
  5. package/dist/ai/spawnPoint.js +5 -0
  6. package/dist/audio/audioFalloff.d.ts +5 -2
  7. package/dist/audio/music.d.ts +53 -0
  8. package/dist/audio/music.js +34 -0
  9. package/dist/audio/synth.d.ts +60 -0
  10. package/dist/audio/synth.js +8 -0
  11. package/dist/authoring.d.ts +14 -0
  12. package/dist/authoring.js +14 -0
  13. package/dist/cartridge/runtime.js +2 -2
  14. package/dist/combat/dotField.d.ts +43 -0
  15. package/dist/combat/dotField.js +48 -0
  16. package/dist/combat/projectiles.d.ts +2 -2
  17. package/dist/combat/projectiles.js +18 -5
  18. package/dist/combat/regenShield.d.ts +35 -0
  19. package/dist/combat/regenShield.js +45 -0
  20. package/dist/combat/shotOrigin.d.ts +13 -1
  21. package/dist/combat/shotOrigin.js +31 -2
  22. package/dist/editor/commands.d.ts +65 -0
  23. package/dist/editor/commands.js +193 -0
  24. package/dist/editor/document.d.ts +40 -0
  25. package/dist/editor/document.js +153 -0
  26. package/dist/editor/index.d.ts +30 -0
  27. package/dist/editor/index.js +6 -0
  28. package/dist/editor/types.d.ts +69 -0
  29. package/dist/editor/types.js +29 -0
  30. package/dist/game/connectedPlayers.d.ts +28 -0
  31. package/dist/game/connectedPlayers.js +21 -0
  32. package/dist/game/defineGame.d.ts +42 -1
  33. package/dist/game/defineGame.js +1 -0
  34. package/dist/game/events.d.ts +15 -0
  35. package/dist/game/keyValueStore.d.ts +25 -0
  36. package/dist/game/keyValueStore.js +61 -0
  37. package/dist/game/levelSequence.d.ts +24 -2
  38. package/dist/game/levelSequence.js +51 -2
  39. package/dist/game/lootTable.d.ts +4 -0
  40. package/dist/game/lootTable.js +1 -0
  41. package/dist/game/modelAnimation.d.ts +8 -0
  42. package/dist/game/modelAnimation.js +18 -0
  43. package/dist/game/playableGame.d.ts +68 -5
  44. package/dist/game/recordBook.d.ts +3 -5
  45. package/dist/index.d.ts +7 -0
  46. package/dist/index.js +7 -0
  47. package/dist/input/actionBindings.d.ts +2 -0
  48. package/dist/input/actionBindings.js +1 -0
  49. package/dist/input/axisInput.d.ts +7 -0
  50. package/dist/input/axisInput.js +16 -0
  51. package/dist/interaction/contextMenu.d.ts +1 -0
  52. package/dist/interaction/contextMenu.js +1 -0
  53. package/dist/interaction/proximityPrompt.d.ts +3 -1
  54. package/dist/interaction/proximityPrompt.js +3 -3
  55. package/dist/meta/changelog.d.ts +1 -1
  56. package/dist/meta/changelog.js +22 -1
  57. package/dist/movement/movementModel.d.ts +4 -0
  58. package/dist/movement/movementModel.js +13 -0
  59. package/dist/movement/playerMovement.d.ts +41 -0
  60. package/dist/movement/playerMovement.js +181 -0
  61. package/dist/random/rng.d.ts +2 -0
  62. package/dist/random/rng.js +2 -0
  63. package/dist/render/postProcessing.d.ts +56 -0
  64. package/dist/render/postProcessing.js +1 -0
  65. package/dist/runtime/adapter.d.ts +19 -0
  66. package/dist/runtime/adapter.js +11 -5
  67. package/dist/runtime/gameContext.d.ts +53 -10
  68. package/dist/runtime/gameContext.js +192 -54
  69. package/dist/runtime/hostedGameRunner.d.ts +49 -0
  70. package/dist/runtime/hostedGameRunner.js +62 -0
  71. package/dist/runtime/hostedWorldSession.d.ts +65 -0
  72. package/dist/runtime/hostedWorldSession.js +57 -0
  73. package/dist/runtime/inputSnapshot.d.ts +13 -0
  74. package/dist/runtime/inputSnapshot.js +2 -0
  75. package/dist/runtime/motionIntents.d.ts +2 -0
  76. package/dist/runtime/motionIntents.js +11 -0
  77. package/dist/runtime/worldChannel.d.ts +58 -0
  78. package/dist/runtime/worldChannel.js +69 -0
  79. package/dist/runtime/worldMirror.d.ts +24 -0
  80. package/dist/runtime/worldMirror.js +31 -0
  81. package/dist/runtime/worldReplication.d.ts +46 -0
  82. package/dist/runtime/worldReplication.js +185 -0
  83. package/dist/runtime/worldSnapshot.d.ts +17 -0
  84. package/dist/runtime/worldSnapshot.js +15 -0
  85. package/dist/scene/assetCatalog.d.ts +1 -0
  86. package/dist/scene/colliders.d.ts +6 -0
  87. package/dist/scene/colliders.js +25 -1
  88. package/dist/scene/entityStats.d.ts +4 -0
  89. package/dist/scene/entityStats.js +21 -0
  90. package/dist/scene/entityStore.d.ts +29 -0
  91. package/dist/scene/entityStore.js +60 -0
  92. package/dist/store/observableKeyedStore.d.ts +4 -0
  93. package/dist/store/observableKeyedStore.js +9 -0
  94. package/dist/tactics/fallingGrid.d.ts +2 -2
  95. package/dist/tactics/fallingGrid.js +2 -2
  96. package/dist/ui/gameLayout.d.ts +125 -0
  97. package/dist/ui/gameLayout.js +129 -0
  98. package/dist/ui/orientation.d.ts +39 -0
  99. package/dist/ui/orientation.js +49 -0
  100. package/dist/ui/swingTimer.d.ts +42 -0
  101. package/dist/ui/swingTimer.js +38 -0
  102. package/dist/visibility/spatialIndex.js +34 -20
  103. package/dist/world/environmentSummary.d.ts +11 -2
  104. package/dist/world/environmentSummary.js +11 -1
  105. package/dist/world/features.d.ts +134 -5
  106. package/dist/world/features.js +40 -3
  107. package/dist/world/roads.d.ts +47 -0
  108. package/dist/world/roads.js +146 -0
  109. package/dist/world/streets.d.ts +80 -0
  110. package/dist/world/streets.js +144 -0
  111. package/dist/world/terrain.d.ts +32 -5
  112. package/dist/world/terrain.js +124 -12
  113. package/llms.txt +485 -79
  114. package/package.json +1 -1
@@ -1,12 +1,20 @@
1
1
  export const DEFAULT_ENTITY_HIT_RADIUS = 0.5;
2
2
  export const DEFAULT_OBJECT_HALF_EXTENTS = [0.5, 0.5, 0.5];
3
+ /** Matches the shell's fallback actor capsule (~0.7m wide, feet at y=0 to head at ~1.8m). */
4
+ export const DEFAULT_ENTITY_BODY_HALF_EXTENTS = [0.35, 0.9, 0.35];
5
+ /** Entity-local center of the default body hitbox (half its height above the feet). */
6
+ export const DEFAULT_ENTITY_BODY_OFFSET = [0, 0.9, 0];
3
7
  export function defaultEntityColliders() {
4
8
  return {
5
9
  hitboxes: [
6
10
  {
7
11
  name: "body",
8
12
  purpose: "damage",
9
- shape: { kind: "sphere", radius: DEFAULT_ENTITY_HIT_RADIUS },
13
+ shape: {
14
+ kind: "aabb",
15
+ halfExtents: DEFAULT_ENTITY_BODY_HALF_EXTENTS,
16
+ offset: DEFAULT_ENTITY_BODY_OFFSET,
17
+ },
10
18
  damageEligible: true,
11
19
  blocks: false,
12
20
  },
@@ -24,6 +32,22 @@ export function defaultObjectColliders(halfExtents = DEFAULT_OBJECT_HALF_EXTENTS
24
32
  },
25
33
  };
26
34
  }
35
+ /** Blocking physical body derived from an object's rendered scale: a grounded box spanning the visual (base at y=0, matching the shell's fallback mesh). */
36
+ export function scaledObjectColliders(scale) {
37
+ return {
38
+ body: {
39
+ name: "body",
40
+ purpose: "physical",
41
+ shape: {
42
+ kind: "aabb",
43
+ halfExtents: [scale[0] / 2, scale[1] / 2, scale[2] / 2],
44
+ offset: [0, scale[1] / 2, 0],
45
+ },
46
+ damageEligible: false,
47
+ blocks: true,
48
+ },
49
+ };
50
+ }
27
51
  export function resolveColliders(set) {
28
52
  if (set === null || set === undefined)
29
53
  return [];
@@ -28,6 +28,10 @@ export declare function getStatValue(map: StatValueMap, statId: string): StatVal
28
28
  export declare function setStatValue(map: StatValueMap, statId: string, patch: StatValuePatch): StatValueMap;
29
29
  export declare function applyPoolDelta(map: StatValueMap, statId: string, amount: number): PoolDeltaResult;
30
30
  export declare function seedStatValues(catalogStats: StatCatalog): StatValueMap;
31
+ /** Deep-copy the per-entity stat maps into a serializable record — the transport counterpart of {@link hydrateEntityStats}. */
32
+ export declare function snapshotEntityStats(store: ReadonlyMap<string, StatValueMap>): Record<string, StatValueMap>;
33
+ /** Replace a live stat store with deep copies of a snapshot, clearing entities absent from it. */
34
+ export declare function hydrateEntityStats(store: Map<string, StatValueMap>, data: Record<string, StatValueMap>): void;
31
35
  export interface EntityStatsApi {
32
36
  get(instanceId: string, statId: string): StatValue | null;
33
37
  set(instanceId: string, statId: string, patch: StatValuePatch): boolean;
@@ -35,6 +35,27 @@ export function seedStatValues(catalogStats) {
35
35
  }
36
36
  return map;
37
37
  }
38
+ /** Deep-copy the per-entity stat maps into a serializable record — the transport counterpart of {@link hydrateEntityStats}. */
39
+ export function snapshotEntityStats(store) {
40
+ const out = {};
41
+ for (const [instanceId, map] of store) {
42
+ const copy = {};
43
+ for (const [statId, value] of Object.entries(map))
44
+ copy[statId] = { ...value };
45
+ out[instanceId] = copy;
46
+ }
47
+ return out;
48
+ }
49
+ /** Replace a live stat store with deep copies of a snapshot, clearing entities absent from it. */
50
+ export function hydrateEntityStats(store, data) {
51
+ store.clear();
52
+ for (const [instanceId, map] of Object.entries(data)) {
53
+ const copy = {};
54
+ for (const [statId, value] of Object.entries(map))
55
+ copy[statId] = { ...value };
56
+ store.set(instanceId, copy);
57
+ }
58
+ }
38
59
  export function createEntityStatsApi(resolve) {
39
60
  return {
40
61
  get(instanceId, statId) {
@@ -65,6 +65,28 @@ export type EntityUpdatePatch<TMeta = unknown> = Partial<Pick<SceneEntity<TMeta>
65
65
  /** Accepts the same friendly shapes as `spawn`/`setPose` (#286.13). Raw patch semantics — velocity is not derived; use `setPose` with `dt` for that. */
66
66
  position?: SpawnPositionInput;
67
67
  };
68
+ /**
69
+ * Per-entity scratch and cooldown timers, auto-cleared on despawn (#533.8) — the home for AI state
70
+ * (next-shot-at, alert level) that would otherwise be smuggled through the serializable `meta` or a
71
+ * hand-pruned module-level map. Keys share one namespace per entity: `arm`/`ready`/`remaining` store a
72
+ * deadline under a key, `get`/`set` store arbitrary scratch.
73
+ */
74
+ export interface EntityBlackboard {
75
+ /** Read a scratch value; `undefined` if unset or the entity has despawned. */
76
+ get<T>(id: string, key: string): T | undefined;
77
+ /** Write a scratch value; does not notify subscribers, and clears when the entity despawns. */
78
+ set(id: string, key: string, value: unknown): void;
79
+ has(id: string, key: string): boolean;
80
+ delete(id: string, key: string): void;
81
+ /** Drop all scratch and timers for one entity. */
82
+ clear(id: string): void;
83
+ /** Arm a cooldown for `key` that stays busy until absolute time `untilMs`. */
84
+ arm(id: string, key: string, untilMs: number): void;
85
+ /** True when no cooldown is armed for `key`, or its deadline has passed. */
86
+ ready(id: string, key: string, nowMs: number): boolean;
87
+ /** Milliseconds until the armed cooldown elapses; 0 when ready. */
88
+ remaining(id: string, key: string, nowMs: number): number;
89
+ }
68
90
  export interface EntityStore<TMeta = unknown> {
69
91
  spawn(name: string, options?: SpawnOptions<TMeta>): string;
70
92
  despawn(id: string): boolean;
@@ -77,7 +99,14 @@ export interface EntityStore<TMeta = unknown> {
77
99
  clear(): void;
78
100
  subscribe(listener: () => void): () => void;
79
101
  snapshot(): readonly SceneEntity<TMeta>[];
102
+ /**
103
+ * Apply a `snapshot()` (e.g. from an authoritative host) to this store — the counterpart of `snapshot()`
104
+ * for host→client world mirroring: spawns entities present in the snapshot but not here, overwrites
105
+ * existing ones field-for-field (including velocity), and despawns any local entity absent from it.
106
+ */
107
+ hydrate(entities: readonly SceneEntity<TMeta>[]): void;
80
108
  spawnPoseOf(id: string): SpawnPose | null;
81
109
  resetToSpawn(id: string): boolean;
110
+ blackboard: EntityBlackboard;
82
111
  }
83
112
  export declare function createEntityStore<TMeta = unknown>(): EntityStore<TMeta>;
@@ -21,7 +21,34 @@ export function createEntityStore() {
21
21
  const store = createObservableKeyedStore();
22
22
  const spawnPoses = new Map();
23
23
  const constraints = new Map();
24
+ const blackboards = new Map();
24
25
  let nextCounter = 1;
26
+ const blackboard = {
27
+ get: (id, key) => blackboards.get(id)?.get(key),
28
+ set(id, key, value) {
29
+ const board = blackboards.get(id) ?? new Map();
30
+ board.set(key, value);
31
+ blackboards.set(id, board);
32
+ },
33
+ has: (id, key) => blackboards.get(id)?.has(key) ?? false,
34
+ delete(id, key) {
35
+ blackboards.get(id)?.delete(key);
36
+ },
37
+ clear(id) {
38
+ blackboards.delete(id);
39
+ },
40
+ arm(id, key, untilMs) {
41
+ this.set(id, key, untilMs);
42
+ },
43
+ ready(id, key, nowMs) {
44
+ const until = blackboards.get(id)?.get(key);
45
+ return typeof until !== "number" || nowMs >= until;
46
+ },
47
+ remaining(id, key, nowMs) {
48
+ const until = blackboards.get(id)?.get(key);
49
+ return typeof until === "number" ? Math.max(0, until - nowMs) : 0;
50
+ },
51
+ };
25
52
  function generateId() {
26
53
  let id = `entity-${nextCounter}`;
27
54
  while (store.has(id)) {
@@ -39,6 +66,7 @@ export function createEntityStore() {
39
66
  return options.id;
40
67
  if (onExisting === "throw")
41
68
  throw new Error(`Scene entity id "${options.id}" is already spawned.`);
69
+ blackboards.delete(options.id);
42
70
  }
43
71
  const id = options.id ?? generateId();
44
72
  const position = toEntityPosition(options.position);
@@ -64,6 +92,7 @@ export function createEntityStore() {
64
92
  store.delete(id);
65
93
  spawnPoses.delete(id);
66
94
  constraints.delete(id);
95
+ blackboards.delete(id);
67
96
  return existed;
68
97
  },
69
98
  update(id, patch) {
@@ -143,6 +172,7 @@ export function createEntityStore() {
143
172
  store.delete(entity.id);
144
173
  }
145
174
  constraints.clear();
175
+ blackboards.clear();
146
176
  },
147
177
  subscribe(listener) {
148
178
  return store.subscribe(listener);
@@ -150,6 +180,35 @@ export function createEntityStore() {
150
180
  snapshot() {
151
181
  return store.arraySnapshot();
152
182
  },
183
+ hydrate(entities) {
184
+ const incoming = new Set(entities.map((entity) => entity.id));
185
+ for (const current of store.arraySnapshot()) {
186
+ if (!incoming.has(current.id)) {
187
+ store.delete(current.id);
188
+ spawnPoses.delete(current.id);
189
+ constraints.delete(current.id);
190
+ blackboards.delete(current.id);
191
+ }
192
+ }
193
+ for (const entity of entities) {
194
+ store.set(entity.id, {
195
+ id: entity.id,
196
+ name: entity.name,
197
+ position: entity.position,
198
+ rotationY: entity.rotationY,
199
+ rotationX: entity.rotationX,
200
+ rotationZ: entity.rotationZ,
201
+ velocity: entity.velocity,
202
+ role: entity.role,
203
+ movement: entity.movement,
204
+ behaviors: entity.behaviors,
205
+ meta: entity.meta,
206
+ });
207
+ if (!spawnPoses.has(entity.id)) {
208
+ spawnPoses.set(entity.id, { position: entity.position, rotationY: entity.rotationY });
209
+ }
210
+ }
211
+ },
153
212
  spawnPoseOf(id) {
154
213
  return spawnPoses.get(id) ?? null;
155
214
  },
@@ -164,5 +223,6 @@ export function createEntityStore() {
164
223
  store.set(id, current);
165
224
  return true;
166
225
  },
226
+ blackboard,
167
227
  };
168
228
  }
@@ -6,5 +6,9 @@ export interface ObservableKeyedStore<T> {
6
6
  subscribe(listener: () => void): () => void;
7
7
  mapSnapshot(): ReadonlyMap<string, T>;
8
8
  arraySnapshot(): readonly T[];
9
+ /** Serializable `[key, value]` entries — the transport counterpart of {@link hydrate} for host→client mirroring. */
10
+ snapshot(): readonly (readonly [string, T])[];
11
+ /** Replace all entries with `data` (clears keys absent from it), emitting once. */
12
+ hydrate(data: readonly (readonly [string, T])[]): void;
9
13
  }
10
14
  export declare function createObservableKeyedStore<T>(areEqual?: (previous: T, next: T) => boolean): ObservableKeyedStore<T>;
@@ -44,5 +44,14 @@ export function createObservableKeyedStore(areEqual) {
44
44
  }
45
45
  return arrayCache;
46
46
  },
47
+ snapshot() {
48
+ return Array.from(store.entries());
49
+ },
50
+ hydrate(data) {
51
+ store.clear();
52
+ for (const [key, value] of data)
53
+ store.set(key, value);
54
+ emit();
55
+ },
47
56
  };
48
57
  }
@@ -49,14 +49,14 @@ export interface GravityIntervalConfig {
49
49
  }
50
50
  /**
51
51
  * Gravity tick interval for a given level: linear speed-up with a floor, generalizing
52
- * `Games/block-stacker`'s `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
52
+ * a falling-block `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
53
53
  * with configurable base/step/floor.
54
54
  */
55
55
  export declare function gravityIntervalMs(level: number, config?: GravityIntervalConfig): number;
56
56
  /**
57
57
  * A game-agnostic cell grid for gravity-driven falling-block mechanics: bounded occupancy,
58
58
  * footprint placement/collision, line-clear collapse, column-gravity settle (for match-3/sand
59
- * style cascades), and a pure lock-delay helper. Extracted from `Games/block-stacker`'s tetris
59
+ * style cascades), and a pure lock-delay helper. Extracted from falling-block tetris
60
60
  * logic so any falling-block game can adopt it instead of reimplementing the grid.
61
61
  */
62
62
  export declare function createFallingGrid<TCell>(config: FallingGridConfig): FallingGrid<TCell>;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Gravity tick interval for a given level: linear speed-up with a floor, generalizing
3
- * `Games/block-stacker`'s `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
3
+ * a falling-block `gravityInterval` (`max(0.05, 0.8 - level * 0.07)` seconds) to milliseconds
4
4
  * with configurable base/step/floor.
5
5
  */
6
6
  export function gravityIntervalMs(level, config = {}) {
@@ -12,7 +12,7 @@ export function gravityIntervalMs(level, config = {}) {
12
12
  /**
13
13
  * A game-agnostic cell grid for gravity-driven falling-block mechanics: bounded occupancy,
14
14
  * footprint placement/collision, line-clear collapse, column-gravity settle (for match-3/sand
15
- * style cascades), and a pure lock-delay helper. Extracted from `Games/block-stacker`'s tetris
15
+ * style cascades), and a pure lock-delay helper. Extracted from falling-block tetris
16
16
  * logic so any falling-block game can adopt it instead of reimplementing the grid.
17
17
  */
18
18
  export function createFallingGrid(config) {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared mobile-composition geometry. Every UI subsystem (HUD, touch controls,
3
+ * system chrome, screens) registers the physical rectangle it occupies; the
4
+ * engine allocates the viewport once and detects when two subsystems collide,
5
+ * instead of each independently claiming an edge. Pure math — no DOM, no React.
6
+ */
7
+ import type { LayoutOrientation } from "./orientation.js";
8
+ /** The explicit composition mode a game renders for — not a scaled desktop layout. */
9
+ export type GameLayoutMode = "desktop-wide" | "desktop-compact" | "mobile-landscape" | "mobile-portrait";
10
+ /** Edge insets in CSS pixels (safe areas, reservations). */
11
+ export interface Insets {
12
+ top: number;
13
+ right: number;
14
+ bottom: number;
15
+ left: number;
16
+ }
17
+ /** All-zero insets. */
18
+ export declare const ZERO_INSETS: Insets;
19
+ /** Axis-aligned rectangle in CSS pixels (origin top-left). Structurally compatible with a `DOMRect`'s edge fields. */
20
+ export interface LayoutRect {
21
+ left: number;
22
+ top: number;
23
+ right: number;
24
+ bottom: number;
25
+ }
26
+ /** Width of a rect (clamped to ≥0). */
27
+ export declare function rectWidth(rect: LayoutRect): number;
28
+ /** Height of a rect (clamped to ≥0). */
29
+ export declare function rectHeight(rect: LayoutRect): number;
30
+ /** Area of a rect in px². */
31
+ export declare function rectArea(rect: LayoutRect): number;
32
+ /** Do the two rectangles share any interior area? Touching edges do not count. */
33
+ export declare function intersects(a: LayoutRect, b: LayoutRect): boolean;
34
+ /** Overlapping area in px²; 0 when the rectangles don't intersect. */
35
+ export declare function overlapArea(a: LayoutRect, b: LayoutRect): number;
36
+ /** Portrait when taller than wide, else landscape. */
37
+ export declare function orientationOf(width: number, height: number): LayoutOrientation;
38
+ /** Inputs to `resolveLayoutMode`. */
39
+ export interface LayoutModeInput {
40
+ /** Live visible viewport dimensions (prefer `visualViewport`). */
41
+ width: number;
42
+ height: number;
43
+ /** Touchscreen is the primary input (`(pointer: coarse)`). */
44
+ coarsePointer: boolean;
45
+ /** Does the game support phones at all (`platforms` includes `"mobile"`)? Default true. */
46
+ mobileSupported?: boolean;
47
+ /** Desktop width below which the layout is "compact". Default 900. */
48
+ compactWidth?: number;
49
+ }
50
+ /**
51
+ * Resolve the explicit composition mode. A mobile layout is not a scaled
52
+ * desktop layout: a coarse-pointer device on a game that supports mobile is
53
+ * always a `mobile-*` mode, split by live orientation; everything else is a
54
+ * desktop mode split by width.
55
+ */
56
+ export declare function resolveLayoutMode(input: LayoutModeInput): GameLayoutMode;
57
+ /** Whether a mode is one of the phone modes. */
58
+ export declare function isMobileMode(mode: GameLayoutMode): boolean;
59
+ /** What kind of UI a registered region belongs to. */
60
+ export type LayoutRegionKind = "hud" | "control" | "system" | "screen";
61
+ /** How a region participates in collision reporting. */
62
+ export type LayoutCollisionPolicy = "forbid" | "allow" | "warn";
63
+ /** Gameplay-importance tier of a HUD element. */
64
+ export type HudPriority = "critical" | "secondary" | "tertiary";
65
+ /** How a HUD element adapts on phones. */
66
+ export type MobileHudBehavior = "persistent" | "compact" | "icon" | "transient" | "hidden" | "sheet" | "modal";
67
+ /** A physical rectangle a UI subsystem occupies, published to the shared registry. */
68
+ export interface LayoutRegion {
69
+ id: string;
70
+ kind: LayoutRegionKind;
71
+ rect: LayoutRect;
72
+ /** `forbid` reports any overlap, `warn` reports softly, `allow` opts this region out of collision reporting entirely. */
73
+ collisionPolicy: LayoutCollisionPolicy;
74
+ priority?: HudPriority;
75
+ /** Region ids this one may overlap without a report. */
76
+ allowOverlapWith?: readonly string[];
77
+ /** Regions sharing a non-empty group never report against each other. */
78
+ collisionGroup?: string;
79
+ }
80
+ /** One detected forbidden/warned overlap between two regions. */
81
+ export interface LayoutCollision {
82
+ a: string;
83
+ b: string;
84
+ kindA: LayoutRegionKind;
85
+ kindB: LayoutRegionKind;
86
+ /** Overlapping area in px². */
87
+ area: number;
88
+ severity: "forbid" | "warn";
89
+ }
90
+ /** Options for `detectLayoutCollisions`. */
91
+ export interface DetectCollisionsOptions {
92
+ /** Ignore overlaps smaller than this many px² — avoids meaningless sub-pixel warnings. Default 64. */
93
+ minArea?: number;
94
+ }
95
+ /**
96
+ * Every forbidden or warned rectangle intersection across the registered
97
+ * regions. A pair is skipped when either side's policy is `allow`, when either
98
+ * opts into the other via `allowOverlapWith`, when they share a
99
+ * `collisionGroup`, or when the overlap is below `minArea`.
100
+ */
101
+ export declare function detectLayoutCollisions(regions: readonly LayoutRegion[], options?: DetectCollisionsOptions): LayoutCollision[];
102
+ /** Human-readable diagnostic block for a set of collisions (empty string when there are none). */
103
+ export declare function formatLayoutCollisions(collisions: readonly LayoutCollision[]): string;
104
+ /**
105
+ * The usable gameplay rectangle after reserving physical zones for touch
106
+ * controls and system UI. Starts from the safe-area-inset viewport and carves
107
+ * each reserved zone from whichever edge it hugs.
108
+ */
109
+ export declare function computeGameplayRect(viewport: LayoutRect, safeArea: Insets, reserved: readonly LayoutRect[]): LayoutRect;
110
+ /** The shared live geometry the engine allocates once and every UI subsystem reads. */
111
+ export interface GameViewportLayout {
112
+ /** The layout (CSS-pixel) viewport. */
113
+ viewport: LayoutRect;
114
+ /** The live visible viewport (`window.visualViewport`), which excludes mobile browser chrome. */
115
+ visualViewport: LayoutRect;
116
+ safeArea: Insets;
117
+ mode: GameLayoutMode;
118
+ orientation: LayoutOrientation;
119
+ /** All registered regions with their live rects. */
120
+ regions: readonly LayoutRegion[];
121
+ /** Rects reserved by touch controls and system UI. */
122
+ controlZones: readonly LayoutRect[];
123
+ /** Usable world/gameplay area after reservations. */
124
+ gameplayRect: LayoutRect;
125
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Shared mobile-composition geometry. Every UI subsystem (HUD, touch controls,
3
+ * system chrome, screens) registers the physical rectangle it occupies; the
4
+ * engine allocates the viewport once and detects when two subsystems collide,
5
+ * instead of each independently claiming an edge. Pure math — no DOM, no React.
6
+ */
7
+ /** All-zero insets. */
8
+ export const ZERO_INSETS = { top: 0, right: 0, bottom: 0, left: 0 };
9
+ /** Width of a rect (clamped to ≥0). */
10
+ export function rectWidth(rect) {
11
+ return Math.max(0, rect.right - rect.left);
12
+ }
13
+ /** Height of a rect (clamped to ≥0). */
14
+ export function rectHeight(rect) {
15
+ return Math.max(0, rect.bottom - rect.top);
16
+ }
17
+ /** Area of a rect in px². */
18
+ export function rectArea(rect) {
19
+ return rectWidth(rect) * rectHeight(rect);
20
+ }
21
+ /** Do the two rectangles share any interior area? Touching edges do not count. */
22
+ export function intersects(a, b) {
23
+ return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
24
+ }
25
+ /** Overlapping area in px²; 0 when the rectangles don't intersect. */
26
+ export function overlapArea(a, b) {
27
+ const w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
28
+ const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
29
+ return w > 0 && h > 0 ? w * h : 0;
30
+ }
31
+ /** Portrait when taller than wide, else landscape. */
32
+ export function orientationOf(width, height) {
33
+ return height > width ? "portrait" : "landscape";
34
+ }
35
+ const DEFAULT_COMPACT_WIDTH = 900;
36
+ /**
37
+ * Resolve the explicit composition mode. A mobile layout is not a scaled
38
+ * desktop layout: a coarse-pointer device on a game that supports mobile is
39
+ * always a `mobile-*` mode, split by live orientation; everything else is a
40
+ * desktop mode split by width.
41
+ */
42
+ export function resolveLayoutMode(input) {
43
+ const orientation = orientationOf(input.width, input.height);
44
+ const mobile = input.coarsePointer && (input.mobileSupported ?? true);
45
+ if (mobile)
46
+ return orientation === "portrait" ? "mobile-portrait" : "mobile-landscape";
47
+ const compactWidth = input.compactWidth ?? DEFAULT_COMPACT_WIDTH;
48
+ return input.width < compactWidth ? "desktop-compact" : "desktop-wide";
49
+ }
50
+ /** Whether a mode is one of the phone modes. */
51
+ export function isMobileMode(mode) {
52
+ return mode === "mobile-portrait" || mode === "mobile-landscape";
53
+ }
54
+ function allowsOverlap(a, b) {
55
+ if (a.allowOverlapWith?.includes(b.id) === true)
56
+ return true;
57
+ if (b.allowOverlapWith?.includes(a.id) === true)
58
+ return true;
59
+ if (a.collisionGroup !== undefined && a.collisionGroup === b.collisionGroup)
60
+ return true;
61
+ return false;
62
+ }
63
+ const DEFAULT_MIN_AREA = 64;
64
+ /**
65
+ * Every forbidden or warned rectangle intersection across the registered
66
+ * regions. A pair is skipped when either side's policy is `allow`, when either
67
+ * opts into the other via `allowOverlapWith`, when they share a
68
+ * `collisionGroup`, or when the overlap is below `minArea`.
69
+ */
70
+ export function detectLayoutCollisions(regions, options) {
71
+ const minArea = options?.minArea ?? DEFAULT_MIN_AREA;
72
+ const out = [];
73
+ for (let i = 0; i < regions.length; i += 1) {
74
+ for (let j = i + 1; j < regions.length; j += 1) {
75
+ const a = regions[i];
76
+ const b = regions[j];
77
+ if (a.collisionPolicy === "allow" || b.collisionPolicy === "allow")
78
+ continue;
79
+ if (allowsOverlap(a, b))
80
+ continue;
81
+ const area = overlapArea(a.rect, b.rect);
82
+ if (area < minArea)
83
+ continue;
84
+ const severity = a.collisionPolicy === "forbid" || b.collisionPolicy === "forbid" ? "forbid" : "warn";
85
+ out.push({ a: a.id, b: b.id, kindA: a.kind, kindB: b.kind, area: Math.round(area), severity });
86
+ }
87
+ }
88
+ return out;
89
+ }
90
+ function withThousands(n) {
91
+ return Math.round(n)
92
+ .toString()
93
+ .replace(/\B(?=(\d{3})+(?!\d))/g, ",");
94
+ }
95
+ /** Human-readable diagnostic block for a set of collisions (empty string when there are none). */
96
+ export function formatLayoutCollisions(collisions) {
97
+ if (collisions.length === 0)
98
+ return "";
99
+ const lines = collisions.map((c) => `${c.a} intersects ${c.b} by ${withThousands(c.area)} px²`);
100
+ return ["JGENGINE MOBILE LAYOUT COLLISION", ...lines].join("\n");
101
+ }
102
+ /**
103
+ * The usable gameplay rectangle after reserving physical zones for touch
104
+ * controls and system UI. Starts from the safe-area-inset viewport and carves
105
+ * each reserved zone from whichever edge it hugs.
106
+ */
107
+ export function computeGameplayRect(viewport, safeArea, reserved) {
108
+ let top = viewport.top + safeArea.top;
109
+ let bottom = viewport.bottom - safeArea.bottom;
110
+ let left = viewport.left + safeArea.left;
111
+ let right = viewport.right - safeArea.right;
112
+ const midX = (viewport.left + viewport.right) / 2;
113
+ const midY = (viewport.top + viewport.bottom) / 2;
114
+ for (const zone of reserved) {
115
+ const zoneMidX = (zone.left + zone.right) / 2;
116
+ const zoneMidY = (zone.top + zone.bottom) / 2;
117
+ const spanX = zone.right - zone.left;
118
+ const spanY = zone.bottom - zone.top;
119
+ if (zoneMidY > midY)
120
+ bottom = Math.min(bottom, zone.top);
121
+ else if (zoneMidY < midY && spanX >= spanY)
122
+ top = Math.max(top, zone.bottom);
123
+ else if (zoneMidX < midX)
124
+ left = Math.max(left, zone.right);
125
+ else
126
+ right = Math.min(right, zone.left);
127
+ }
128
+ return { left, top, right: Math.max(left, right), bottom: Math.max(top, bottom) };
129
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The game-level orientation contract. A game declares how it supports phone
3
+ * orientations; the shell resolves that against the live orientation to decide
4
+ * whether to show a dismissible rotate hint (advisory) or a full-screen gate
5
+ * that blocks gameplay until the device is turned (required).
6
+ */
7
+ /** How a game supports a phone orientation on coarse-pointer displays. */
8
+ export type MobileOrientationRule = "any" | "portrait" | "landscape" | "portrait-required" | "landscape-required" | "unsupported";
9
+ /** Object form of the `orientation` field: the strict per-platform contract. */
10
+ export interface GameOrientationConfig {
11
+ /** Orientation contract on coarse-pointer / phone displays. Default `"any"`. */
12
+ mobile?: MobileOrientationRule;
13
+ }
14
+ /**
15
+ * The `orientation` field of a game definition. Legacy shorthand `"landscape"`
16
+ * / `"portrait"` stays advisory (a dismissible rotate hint, never a gate). The
17
+ * object form `{ mobile: "landscape-required" }` is the strict contract that
18
+ * blocks gameplay behind the rotate screen until the device is turned.
19
+ */
20
+ export type GameOrientation = "landscape" | "portrait" | GameOrientationConfig;
21
+ /** A concrete device orientation. */
22
+ export type LayoutOrientation = "portrait" | "landscape";
23
+ /** The resolved orientation contract for a platform: whether it's supported, gated, or merely preferred. */
24
+ export interface OrientationRequirement {
25
+ /** False only for `"unsupported"` — the game declares it isn't built for phones. */
26
+ supported: boolean;
27
+ /** Orientation gameplay is hard-gated to; `null` when there is no gate. */
28
+ required: LayoutOrientation | null;
29
+ /** Orientation the game reads best in; drives an advisory hint when not required. */
30
+ preferred: LayoutOrientation | null;
31
+ }
32
+ /** Normalize the `orientation` field (legacy string or object) to a single mobile rule. */
33
+ export declare function resolveMobileOrientationRule(orientation: GameOrientation | undefined): MobileOrientationRule;
34
+ /** Resolve the game's orientation declaration into a concrete requirement for a platform. Desktop is always unconstrained. */
35
+ export declare function resolveOrientationRequirement(orientation: GameOrientation | undefined, platform: "mobile" | "desktop"): OrientationRequirement;
36
+ /** The rotate gate blocks gameplay: a hard requirement (or `unsupported`) the live orientation doesn't satisfy. */
37
+ export declare function orientationGateActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean;
38
+ /** An advisory rotate hint applies: a preference (not a hard gate) the live orientation doesn't satisfy. */
39
+ export declare function orientationHintActive(requirement: OrientationRequirement, liveOrientation: LayoutOrientation): boolean;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The game-level orientation contract. A game declares how it supports phone
3
+ * orientations; the shell resolves that against the live orientation to decide
4
+ * whether to show a dismissible rotate hint (advisory) or a full-screen gate
5
+ * that blocks gameplay until the device is turned (required).
6
+ */
7
+ /** Normalize the `orientation` field (legacy string or object) to a single mobile rule. */
8
+ export function resolveMobileOrientationRule(orientation) {
9
+ if (orientation === undefined)
10
+ return "any";
11
+ if (orientation === "landscape")
12
+ return "landscape";
13
+ if (orientation === "portrait")
14
+ return "portrait";
15
+ return orientation.mobile ?? "any";
16
+ }
17
+ /** Resolve the game's orientation declaration into a concrete requirement for a platform. Desktop is always unconstrained. */
18
+ export function resolveOrientationRequirement(orientation, platform) {
19
+ if (platform === "desktop")
20
+ return { supported: true, required: null, preferred: null };
21
+ const rule = resolveMobileOrientationRule(orientation);
22
+ switch (rule) {
23
+ case "unsupported":
24
+ return { supported: false, required: null, preferred: null };
25
+ case "landscape-required":
26
+ return { supported: true, required: "landscape", preferred: "landscape" };
27
+ case "portrait-required":
28
+ return { supported: true, required: "portrait", preferred: "portrait" };
29
+ case "landscape":
30
+ return { supported: true, required: null, preferred: "landscape" };
31
+ case "portrait":
32
+ return { supported: true, required: null, preferred: "portrait" };
33
+ case "any":
34
+ return { supported: true, required: null, preferred: null };
35
+ }
36
+ }
37
+ /** The rotate gate blocks gameplay: a hard requirement (or `unsupported`) the live orientation doesn't satisfy. */
38
+ export function orientationGateActive(requirement, liveOrientation) {
39
+ if (!requirement.supported)
40
+ return true;
41
+ return requirement.required !== null && requirement.required !== liveOrientation;
42
+ }
43
+ /** An advisory rotate hint applies: a preference (not a hard gate) the live orientation doesn't satisfy. */
44
+ export function orientationHintActive(requirement, liveOrientation) {
45
+ return (requirement.supported &&
46
+ requirement.required === null &&
47
+ requirement.preferred !== null &&
48
+ requirement.preferred !== liveOrientation);
49
+ }