@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.
- package/CHANGELOG.md +21 -1
- package/dist/ai/spawnDirector.d.ts +13 -0
- package/dist/ai/spawnDirector.js +40 -16
- package/dist/ai/spawnPoint.d.ts +5 -0
- package/dist/ai/spawnPoint.js +5 -0
- package/dist/audio/audioFalloff.d.ts +5 -2
- package/dist/audio/music.d.ts +53 -0
- package/dist/audio/music.js +34 -0
- package/dist/audio/synth.d.ts +60 -0
- package/dist/audio/synth.js +8 -0
- package/dist/authoring.d.ts +14 -0
- package/dist/authoring.js +14 -0
- package/dist/cartridge/runtime.js +2 -2
- package/dist/combat/dotField.d.ts +43 -0
- package/dist/combat/dotField.js +48 -0
- package/dist/combat/projectiles.d.ts +2 -2
- package/dist/combat/projectiles.js +18 -5
- package/dist/combat/regenShield.d.ts +35 -0
- package/dist/combat/regenShield.js +45 -0
- package/dist/combat/shotOrigin.d.ts +13 -1
- package/dist/combat/shotOrigin.js +31 -2
- package/dist/editor/commands.d.ts +65 -0
- package/dist/editor/commands.js +193 -0
- package/dist/editor/document.d.ts +40 -0
- package/dist/editor/document.js +153 -0
- package/dist/editor/index.d.ts +30 -0
- package/dist/editor/index.js +6 -0
- package/dist/editor/types.d.ts +69 -0
- package/dist/editor/types.js +29 -0
- package/dist/game/connectedPlayers.d.ts +28 -0
- package/dist/game/connectedPlayers.js +21 -0
- package/dist/game/defineGame.d.ts +42 -1
- package/dist/game/defineGame.js +1 -0
- package/dist/game/events.d.ts +15 -0
- package/dist/game/keyValueStore.d.ts +25 -0
- package/dist/game/keyValueStore.js +61 -0
- package/dist/game/levelSequence.d.ts +24 -2
- package/dist/game/levelSequence.js +51 -2
- package/dist/game/lootTable.d.ts +4 -0
- package/dist/game/lootTable.js +1 -0
- package/dist/game/modelAnimation.d.ts +8 -0
- package/dist/game/modelAnimation.js +18 -0
- package/dist/game/playableGame.d.ts +68 -5
- package/dist/game/recordBook.d.ts +3 -5
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/input/actionBindings.d.ts +2 -0
- package/dist/input/actionBindings.js +1 -0
- package/dist/input/axisInput.d.ts +7 -0
- package/dist/input/axisInput.js +16 -0
- package/dist/interaction/contextMenu.d.ts +1 -0
- package/dist/interaction/contextMenu.js +1 -0
- package/dist/interaction/proximityPrompt.d.ts +3 -1
- package/dist/interaction/proximityPrompt.js +3 -3
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +22 -1
- package/dist/movement/movementModel.d.ts +4 -0
- package/dist/movement/movementModel.js +13 -0
- package/dist/movement/playerMovement.d.ts +41 -0
- package/dist/movement/playerMovement.js +181 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +2 -0
- package/dist/render/postProcessing.d.ts +56 -0
- package/dist/render/postProcessing.js +1 -0
- package/dist/runtime/adapter.d.ts +19 -0
- package/dist/runtime/adapter.js +11 -5
- package/dist/runtime/gameContext.d.ts +53 -10
- package/dist/runtime/gameContext.js +192 -54
- package/dist/runtime/hostedGameRunner.d.ts +49 -0
- package/dist/runtime/hostedGameRunner.js +62 -0
- package/dist/runtime/hostedWorldSession.d.ts +65 -0
- package/dist/runtime/hostedWorldSession.js +57 -0
- package/dist/runtime/inputSnapshot.d.ts +13 -0
- package/dist/runtime/inputSnapshot.js +2 -0
- package/dist/runtime/motionIntents.d.ts +2 -0
- package/dist/runtime/motionIntents.js +11 -0
- package/dist/runtime/worldChannel.d.ts +58 -0
- package/dist/runtime/worldChannel.js +69 -0
- package/dist/runtime/worldMirror.d.ts +24 -0
- package/dist/runtime/worldMirror.js +31 -0
- package/dist/runtime/worldReplication.d.ts +46 -0
- package/dist/runtime/worldReplication.js +185 -0
- package/dist/runtime/worldSnapshot.d.ts +17 -0
- package/dist/runtime/worldSnapshot.js +15 -0
- package/dist/scene/assetCatalog.d.ts +1 -0
- package/dist/scene/colliders.d.ts +6 -0
- package/dist/scene/colliders.js +25 -1
- package/dist/scene/entityStats.d.ts +4 -0
- package/dist/scene/entityStats.js +21 -0
- package/dist/scene/entityStore.d.ts +29 -0
- package/dist/scene/entityStore.js +60 -0
- package/dist/store/observableKeyedStore.d.ts +4 -0
- package/dist/store/observableKeyedStore.js +9 -0
- package/dist/tactics/fallingGrid.d.ts +2 -2
- package/dist/tactics/fallingGrid.js +2 -2
- package/dist/ui/gameLayout.d.ts +125 -0
- package/dist/ui/gameLayout.js +129 -0
- package/dist/ui/orientation.d.ts +39 -0
- package/dist/ui/orientation.js +49 -0
- package/dist/ui/swingTimer.d.ts +42 -0
- package/dist/ui/swingTimer.js +38 -0
- package/dist/visibility/spatialIndex.js +34 -20
- package/dist/world/environmentSummary.d.ts +11 -2
- package/dist/world/environmentSummary.js +11 -1
- package/dist/world/features.d.ts +134 -5
- package/dist/world/features.js +40 -3
- package/dist/world/roads.d.ts +47 -0
- package/dist/world/roads.js +146 -0
- package/dist/world/streets.d.ts +80 -0
- package/dist/world/streets.js +144 -0
- package/dist/world/terrain.d.ts +32 -5
- package/dist/world/terrain.js +124 -12
- package/llms.txt +485 -79
- package/package.json +1 -1
package/dist/scene/colliders.js
CHANGED
|
@@ -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: {
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
+
}
|