@jgengine/core 0.7.0 → 0.8.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 (159) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +2 -0
  3. package/dist/ai/spawnDirector.d.ts +5 -0
  4. package/dist/ai/spawnDirector.js +13 -1
  5. package/dist/anim/easing.d.ts +14 -0
  6. package/dist/anim/easing.js +55 -0
  7. package/dist/behaviour/behaviour.d.ts +136 -0
  8. package/dist/behaviour/behaviour.js +388 -0
  9. package/dist/cards/cardPile.d.ts +1 -0
  10. package/dist/cards/cardPile.js +12 -3
  11. package/dist/combat/abilityKit.d.ts +5 -0
  12. package/dist/combat/abilityKit.js +10 -0
  13. package/dist/combat/effects.d.ts +1 -1
  14. package/dist/combat/effects.js +9 -6
  15. package/dist/combat/projectiles.d.ts +42 -5
  16. package/dist/combat/projectiles.js +98 -12
  17. package/dist/combat/telegraph.d.ts +5 -1
  18. package/dist/combat/telegraph.js +14 -0
  19. package/dist/commands/commandRegistry.d.ts +1 -1
  20. package/dist/commands/commandRegistry.js +2 -1
  21. package/dist/data/dataSource.d.ts +26 -0
  22. package/dist/data/dataSource.js +93 -0
  23. package/dist/data/devProxy.d.ts +11 -0
  24. package/dist/data/devProxy.js +38 -0
  25. package/dist/data/fetchJson.d.ts +20 -0
  26. package/dist/data/fetchJson.js +43 -0
  27. package/dist/data/jsonDataSource.d.ts +4 -0
  28. package/dist/data/jsonDataSource.js +6 -0
  29. package/dist/devtools/devtools.d.ts +157 -0
  30. package/dist/devtools/devtools.js +484 -0
  31. package/dist/devtools/transformTunables.d.ts +14 -0
  32. package/dist/devtools/transformTunables.js +38 -0
  33. package/dist/game/chat.d.ts +66 -0
  34. package/dist/game/chat.js +165 -0
  35. package/dist/game/defineGame.d.ts +8 -2
  36. package/dist/game/defineGame.js +2 -1
  37. package/dist/game/events.d.ts +25 -0
  38. package/dist/game/levelSequence.d.ts +44 -0
  39. package/dist/game/levelSequence.js +78 -0
  40. package/dist/game/playableGame.d.ts +237 -6
  41. package/dist/game/playableGame.js +6 -1
  42. package/dist/game/progression.d.ts +1 -0
  43. package/dist/game/progression.js +23 -2
  44. package/dist/game/race.d.ts +5 -0
  45. package/dist/game/race.js +20 -0
  46. package/dist/game/social.d.ts +42 -0
  47. package/dist/game/social.js +122 -0
  48. package/dist/game/spawnPoints.d.ts +19 -0
  49. package/dist/game/spawnPoints.js +23 -0
  50. package/dist/game/worldItem.d.ts +3 -0
  51. package/dist/game/worldItem.js +12 -6
  52. package/dist/input/actionBindings.d.ts +10 -0
  53. package/dist/input/actionBindings.js +14 -0
  54. package/dist/input/gestureSurface.d.ts +31 -0
  55. package/dist/input/gestureSurface.js +120 -0
  56. package/dist/input/pointer.d.ts +45 -0
  57. package/dist/input/pointer.js +48 -0
  58. package/dist/input/touchScheme.d.ts +83 -0
  59. package/dist/input/touchScheme.js +115 -0
  60. package/dist/item/affix.d.ts +1 -1
  61. package/dist/item/affix.js +1 -19
  62. package/dist/meta/changelog.d.ts +1 -1
  63. package/dist/meta/changelog.js +37 -1
  64. package/dist/movement/movementModel.d.ts +36 -3
  65. package/dist/movement/movementModel.js +81 -5
  66. package/dist/movement/voxelController.d.ts +61 -0
  67. package/dist/movement/voxelController.js +229 -0
  68. package/dist/multiplayer/chatContract.d.ts +42 -0
  69. package/dist/multiplayer/chatContract.js +57 -0
  70. package/dist/multiplayer/identity.d.ts +13 -0
  71. package/dist/multiplayer/identity.js +20 -0
  72. package/dist/multiplayer/poseSyncGate.d.ts +1 -0
  73. package/dist/multiplayer/poseSyncGate.js +13 -1
  74. package/dist/multiplayer/presenceModel.d.ts +3 -0
  75. package/dist/multiplayer/presenceModel.js +15 -1
  76. package/dist/multiplayer/voiceContract.d.ts +44 -0
  77. package/dist/multiplayer/voiceContract.js +108 -0
  78. package/dist/nav/navConstrain.d.ts +14 -0
  79. package/dist/nav/navConstrain.js +26 -0
  80. package/dist/nav/navFromEnvironment.d.ts +11 -0
  81. package/dist/nav/navFromEnvironment.js +16 -0
  82. package/dist/nav/navGrid.d.ts +10 -0
  83. package/dist/nav/navGrid.js +18 -1
  84. package/dist/nav/pathFollow.d.ts +4 -2
  85. package/dist/nav/pathFollow.js +5 -3
  86. package/dist/physics/ballisticSweep.d.ts +18 -0
  87. package/dist/physics/ballisticSweep.js +49 -0
  88. package/dist/physics/forceVolume.js +5 -5
  89. package/dist/physics/physicsWorld.d.ts +41 -2
  90. package/dist/physics/physicsWorld.js +232 -40
  91. package/dist/physics/spatialGrid.d.ts +1 -1
  92. package/dist/physics/spatialGrid.js +5 -0
  93. package/dist/physics/vehicleBody.d.ts +6 -0
  94. package/dist/physics/vehicleBody.js +16 -17
  95. package/dist/puzzle/cellGrid.d.ts +26 -0
  96. package/dist/puzzle/cellGrid.js +110 -0
  97. package/dist/puzzle/fallingPiece.d.ts +24 -0
  98. package/dist/puzzle/fallingPiece.js +51 -0
  99. package/dist/random/nameGen.d.ts +18 -0
  100. package/dist/random/nameGen.js +35 -0
  101. package/dist/random/rng.d.ts +2 -0
  102. package/dist/random/rng.js +23 -0
  103. package/dist/runtime/adapter.d.ts +35 -0
  104. package/dist/runtime/adapter.js +37 -1
  105. package/dist/runtime/cameraDirector.d.ts +10 -0
  106. package/dist/runtime/cameraDirector.js +25 -0
  107. package/dist/runtime/gameContext.d.ts +44 -2
  108. package/dist/runtime/gameContext.js +100 -4
  109. package/dist/runtime/hostPersistence.d.ts +21 -0
  110. package/dist/runtime/hostPersistence.js +50 -0
  111. package/dist/runtime/inputSnapshot.d.ts +7 -0
  112. package/dist/runtime/inputSnapshot.js +12 -0
  113. package/dist/runtime/motionIntents.d.ts +25 -0
  114. package/dist/runtime/motionIntents.js +25 -0
  115. package/dist/runtime/transport.d.ts +32 -0
  116. package/dist/scene/entityStore.d.ts +8 -0
  117. package/dist/scene/entityStore.js +25 -2
  118. package/dist/scene/movementSpeed.d.ts +35 -0
  119. package/dist/scene/movementSpeed.js +20 -0
  120. package/dist/scene/objectQuery.d.ts +18 -0
  121. package/dist/scene/objectQuery.js +108 -0
  122. package/dist/scene/objectStore.d.ts +11 -0
  123. package/dist/scene/objectStore.js +109 -4
  124. package/dist/scene/paintLayer.d.ts +15 -0
  125. package/dist/scene/paintLayer.js +53 -0
  126. package/dist/scene/spatial.d.ts +21 -0
  127. package/dist/scene/spatial.js +63 -1
  128. package/dist/sensor/concealment.d.ts +21 -0
  129. package/dist/sensor/concealment.js +63 -0
  130. package/dist/sensor/freezeMonitor.d.ts +17 -0
  131. package/dist/sensor/freezeMonitor.js +35 -0
  132. package/dist/session/roles.d.ts +6 -0
  133. package/dist/session/roles.js +32 -0
  134. package/dist/session/roundState.d.ts +41 -17
  135. package/dist/session/roundState.js +67 -38
  136. package/dist/tactics/fallingGrid.d.ts +62 -0
  137. package/dist/tactics/fallingGrid.js +158 -0
  138. package/dist/tactics/tacticalGrid.d.ts +11 -0
  139. package/dist/tactics/tacticalGrid.js +17 -0
  140. package/dist/time/simClock.d.ts +25 -1
  141. package/dist/time/simClock.js +28 -3
  142. package/dist/turn/intent.d.ts +14 -0
  143. package/dist/turn/intent.js +29 -0
  144. package/dist/turn/turnLoop.d.ts +2 -0
  145. package/dist/turn/turnLoop.js +8 -1
  146. package/dist/world/environmentSummary.d.ts +53 -0
  147. package/dist/world/environmentSummary.js +129 -0
  148. package/dist/world/features.d.ts +87 -6
  149. package/dist/world/features.js +52 -2
  150. package/dist/world/gridInstances.d.ts +13 -0
  151. package/dist/world/gridInstances.js +22 -0
  152. package/dist/world/placementController.d.ts +22 -1
  153. package/dist/world/placementController.js +56 -0
  154. package/dist/world/terrain.d.ts +23 -1
  155. package/dist/world/terrain.js +64 -1
  156. package/dist/world/voxelField.d.ts +54 -0
  157. package/dist/world/voxelField.js +241 -0
  158. package/llms.txt +4102 -0
  159. package/package.json +3 -2
@@ -72,12 +72,25 @@ export function createSocial(deps) {
72
72
  friendRequests.delete(requestId);
73
73
  }
74
74
  },
75
+ decline(userId, requestId) {
76
+ const request = friendRequests.get(requestId);
77
+ if (request !== undefined && request.to === userId)
78
+ friendRequests.delete(requestId);
79
+ },
75
80
  list(userId) {
76
81
  return Array.from(friendships.get(userId) ?? [], (friendUserId) => ({
77
82
  userId: friendUserId,
78
83
  online: deps.presence?.(friendUserId).online ?? false,
79
84
  }));
80
85
  },
86
+ requestsFor(userId) {
87
+ const entries = [];
88
+ for (const [requestId, request] of friendRequests) {
89
+ if (request.to === userId)
90
+ entries.push({ requestId, fromUserId: request.from });
91
+ }
92
+ return entries;
93
+ },
81
94
  snapshot(userId) {
82
95
  return {
83
96
  friends: Array.from(friendships.get(userId) ?? []),
@@ -212,9 +225,117 @@ export function createSocial(deps) {
212
225
  role: member === current.leader ? "leader" : "member",
213
226
  }));
214
227
  },
228
+ decline(userId, inviteId) {
229
+ const invite = partyInvites.get(inviteId);
230
+ if (invite !== undefined && invite.to === userId)
231
+ partyInvites.delete(inviteId);
232
+ },
215
233
  membersOf(userId) {
216
234
  return partyOf(userId)?.members.slice() ?? [];
217
235
  },
236
+ invitesFor(userId) {
237
+ const ttlMs = partyConfig?.inviteTtlMs ?? DEFAULT_INVITE_TTL_MS;
238
+ const cutoff = now() - ttlMs;
239
+ const entries = [];
240
+ for (const [inviteId, invite] of partyInvites) {
241
+ if (invite.createdAt < cutoff) {
242
+ partyInvites.delete(inviteId);
243
+ continue;
244
+ }
245
+ if (invite.to === userId) {
246
+ entries.push({ inviteId, fromUserId: invite.from, createdAt: invite.createdAt });
247
+ }
248
+ }
249
+ return entries;
250
+ },
251
+ };
252
+ const worldInviteTtlMs = deps.worldInviteTtlMs ?? DEFAULT_INVITE_TTL_MS;
253
+ const worldInviteEntries = new Map();
254
+ let worldInviteCounter = 0;
255
+ function pruneWorldInvites() {
256
+ const cutoff = now() - worldInviteTtlMs;
257
+ for (const [id, entry] of worldInviteEntries) {
258
+ if (entry.createdAt < cutoff)
259
+ worldInviteEntries.delete(id);
260
+ }
261
+ }
262
+ function canWorldInvite(fromUserId, toUserId) {
263
+ if (fromUserId === toUserId)
264
+ return { reason: "cannot invite yourself" };
265
+ if (blocked.get(toUserId)?.has(fromUserId) || blocked.get(fromUserId)?.has(toUserId)) {
266
+ return { reason: "blocked" };
267
+ }
268
+ pruneWorldInvites();
269
+ for (const entry of worldInviteEntries.values()) {
270
+ if (entry.fromUserId === fromUserId && entry.toUserId === toUserId) {
271
+ return { reason: "invite already pending" };
272
+ }
273
+ }
274
+ return null;
275
+ }
276
+ const worldInvites = {
277
+ canInvite: canWorldInvite,
278
+ invite(fromUserId, toUserId, target) {
279
+ const denied = canWorldInvite(fromUserId, toUserId);
280
+ if (denied !== null)
281
+ return denied;
282
+ worldInviteCounter += 1;
283
+ const inviteId = `winv_${worldInviteCounter}`;
284
+ const entry = {
285
+ id: inviteId,
286
+ fromUserId,
287
+ toUserId,
288
+ serverId: target.serverId,
289
+ createdAt: now(),
290
+ };
291
+ if (target.joinCode !== undefined)
292
+ entry.joinCode = target.joinCode;
293
+ worldInviteEntries.set(inviteId, entry);
294
+ const invited = {
295
+ inviteId,
296
+ fromUserId,
297
+ toUserId,
298
+ serverId: target.serverId,
299
+ };
300
+ if (target.joinCode !== undefined)
301
+ invited.joinCode = target.joinCode;
302
+ events.emit("social.world.invited", invited);
303
+ return { inviteId };
304
+ },
305
+ accept(userId, inviteId) {
306
+ const entry = worldInviteEntries.get(inviteId);
307
+ if (entry === undefined)
308
+ return { reason: `unknown invite "${inviteId}"` };
309
+ if (entry.toUserId !== userId)
310
+ return { reason: "invite is not addressed to this user" };
311
+ if (now() - entry.createdAt > worldInviteTtlMs) {
312
+ worldInviteEntries.delete(inviteId);
313
+ return { reason: "invite expired" };
314
+ }
315
+ worldInviteEntries.delete(inviteId);
316
+ const target = { serverId: entry.serverId };
317
+ if (entry.joinCode !== undefined)
318
+ target.joinCode = entry.joinCode;
319
+ const accepted = {
320
+ inviteId,
321
+ userId,
322
+ fromUserId: entry.fromUserId,
323
+ serverId: entry.serverId,
324
+ };
325
+ if (entry.joinCode !== undefined)
326
+ accepted.joinCode = entry.joinCode;
327
+ events.emit("social.world.accepted", accepted);
328
+ return { target };
329
+ },
330
+ decline(userId, inviteId) {
331
+ const entry = worldInviteEntries.get(inviteId);
332
+ if (entry !== undefined && entry.toUserId === userId)
333
+ worldInviteEntries.delete(inviteId);
334
+ },
335
+ listFor(userId) {
336
+ pruneWorldInvites();
337
+ return Array.from(worldInviteEntries.values()).filter((entry) => entry.toUserId === userId);
338
+ },
218
339
  };
219
340
  const emotes = {
220
341
  play(fromUserId, emoteId, radius) {
@@ -239,5 +360,6 @@ export function createSocial(deps) {
239
360
  },
240
361
  },
241
362
  emotes,
363
+ worldInvites,
242
364
  };
243
365
  }
@@ -0,0 +1,19 @@
1
+ import type { EntityPose } from "../scene/entityStore.js";
2
+ export interface SpawnPointPose {
3
+ x: number;
4
+ y: number;
5
+ z: number;
6
+ rotationY?: number;
7
+ }
8
+ export interface RespawnTarget {
9
+ setPose(id: string, pose: EntityPose): boolean;
10
+ }
11
+ export interface SpawnPoints {
12
+ record(id: string, pose: SpawnPointPose): void;
13
+ get(id: string): SpawnPointPose | undefined;
14
+ list(): readonly (SpawnPointPose & {
15
+ id: string;
16
+ })[];
17
+ respawn(entities: RespawnTarget, entityId: string, spawnId: string): boolean;
18
+ }
19
+ export declare function createSpawnPoints(): SpawnPoints;
@@ -0,0 +1,23 @@
1
+ export function createSpawnPoints() {
2
+ const points = new Map();
3
+ return {
4
+ record(id, pose) {
5
+ points.set(id, { ...pose });
6
+ },
7
+ get(id) {
8
+ return points.get(id);
9
+ },
10
+ list() {
11
+ return Array.from(points, ([id, pose]) => ({ id, ...pose }));
12
+ },
13
+ respawn(entities, entityId, spawnId) {
14
+ const pose = points.get(spawnId);
15
+ if (pose === undefined)
16
+ return false;
17
+ return entities.setPose(entityId, {
18
+ position: { x: pose.x, y: pose.y, z: pose.z },
19
+ ...(pose.rotationY === undefined ? {} : { rotationY: pose.rotationY }),
20
+ });
21
+ },
22
+ };
23
+ }
@@ -44,7 +44,10 @@ export interface WorldItemStore {
44
44
  get(instanceId: string): WorldItemRecord | null;
45
45
  list(): readonly WorldItemRecord[];
46
46
  nearestInRadius(from: EntityPosition, radius: number, filter?: (record: WorldItemRecord) => boolean): string | null;
47
+ /** Detaches the record for a pickup: caller is expected to grant its contents (e.g. `loot.grantToPlayer`) itself. */
47
48
  take(instanceId: string): WorldItemRecord | null;
49
+ /** Consumes/destroys the record with no grant implied (decay, an explosion, a cleanup sweep) — same despawn path as `take`, leaves no orphaned record. */
50
+ remove(instanceId: string): WorldItemRecord | null;
48
51
  }
49
52
  /** Pure pickup-radius + nearest-item selection, usable without a live store (click-to-grab, proximity prompts). */
50
53
  export declare function selectNearestWorldItem(candidates: readonly {
@@ -33,6 +33,14 @@ export function selectNearestWorldItem(candidates, from, radius) {
33
33
  export function createWorldItemStore(deps) {
34
34
  const records = new Map();
35
35
  const now = deps.now ?? Date.now;
36
+ function removeRecord(instanceId) {
37
+ const record = records.get(instanceId);
38
+ if (record === undefined)
39
+ return null;
40
+ records.delete(instanceId);
41
+ deps.despawnEntity(instanceId);
42
+ return record;
43
+ }
36
44
  return {
37
45
  spawn(input) {
38
46
  const instanceId = deps.spawnEntity(input.position);
@@ -69,12 +77,10 @@ export function createWorldItemStore(deps) {
69
77
  return selectNearestWorldItem(candidates, from, radius);
70
78
  },
71
79
  take(instanceId) {
72
- const record = records.get(instanceId);
73
- if (record === undefined)
74
- return null;
75
- records.delete(instanceId);
76
- deps.despawnEntity(instanceId);
77
- return record;
80
+ return removeRecord(instanceId);
81
+ },
82
+ remove(instanceId) {
83
+ return removeRecord(instanceId);
78
84
  },
79
85
  };
80
86
  }
@@ -27,8 +27,18 @@ export type ActionStateBindingMap<TAction extends string, TCode extends string =
27
27
  export type ActionCodes<TCode extends string = string> = readonly TCode[] | {
28
28
  hold?: readonly TCode[];
29
29
  toggle?: readonly TCode[];
30
+ repeatMs?: number;
30
31
  };
31
32
  export type ActionCodesMap<TAction extends string = string, TCode extends string = string> = Record<TAction, ActionCodes<TCode>>;
33
+ export declare function actionRepeatMs(codes: ActionCodes | undefined): number | undefined;
34
+ export interface ShouldDispatchActionInput {
35
+ pressed: boolean;
36
+ down: boolean;
37
+ repeatMs: number | undefined;
38
+ lastFiredAt: number | null;
39
+ now: number;
40
+ }
41
+ export declare function shouldDispatchAction({ pressed, down, repeatMs, lastFiredAt, now }: ShouldDispatchActionInput): boolean;
32
42
  export declare function toActionStateBindingMap<TAction extends string, TCode extends string>(map: ActionCodesMap<TAction, TCode>): ActionStateBindingMap<TAction, TCode>;
33
43
  export declare function hotbarSlotBindings(count: number, options?: {
34
44
  action?: (slot: number) => string;
@@ -27,6 +27,20 @@ export function normalizeKeyCode(code) {
27
27
  return "Control";
28
28
  return code;
29
29
  }
30
+ export function actionRepeatMs(codes) {
31
+ if (codes === undefined || Array.isArray(codes))
32
+ return undefined;
33
+ return codes.repeatMs;
34
+ }
35
+ export function shouldDispatchAction({ pressed, down, repeatMs, lastFiredAt, now }) {
36
+ if (pressed)
37
+ return true;
38
+ if (repeatMs === undefined || repeatMs <= 0)
39
+ return false;
40
+ if (!down || lastFiredAt === null)
41
+ return false;
42
+ return now - lastFiredAt >= repeatMs;
43
+ }
30
44
  function toBindings(codes) {
31
45
  return codes.map((code) => ({ primary: code, secondary: null }));
32
46
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Single-finger play-surface gesture recognizer for touch controls: taps,
3
+ * directional flick swipes, and continuous step drags all resolve to action
4
+ * names from a TouchGestureBindings declaration. Drag steps stream out of
5
+ * move() as the finger travels; taps and swipes resolve on end(). Timestamps
6
+ * are injected so the state machine stays pure; the capture layer owns touch
7
+ * identifiers and pointer capture.
8
+ */
9
+ import type { TouchGestureBindings } from "./touchScheme.js";
10
+ export interface GestureSurfaceTuning {
11
+ /** A finger that moves less than this (px) and lifts quickly is a tap. */
12
+ tapMoveThresholdPx: number;
13
+ tapMaxMs: number;
14
+ /** Minimum travel (px) for a lift to register as a swipe. */
15
+ swipeMinPx: number;
16
+ /** Minimum lift velocity (px/ms) for a swipe; slower travel is a drag that ends quietly. */
17
+ swipeMinVelocity: number;
18
+ /** Default travel (px) per repeated drag action when the binding sets none. */
19
+ dragStepPx: number;
20
+ }
21
+ export declare const DEFAULT_GESTURE_TUNING: GestureSurfaceTuning;
22
+ export interface GestureSurfaceTracker {
23
+ begin(x: number, y: number, nowMs: number): void;
24
+ /** Drag-step actions fired by this movement, in travel order. */
25
+ move(x: number, y: number): readonly string[];
26
+ /** Tap or swipe action resolved by lifting the finger, if any. */
27
+ end(x: number, y: number, nowMs: number): readonly string[];
28
+ cancel(): void;
29
+ isActive(): boolean;
30
+ }
31
+ export declare function createGestureSurfaceTracker(bindings: TouchGestureBindings, tuning?: GestureSurfaceTuning): GestureSurfaceTracker;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Single-finger play-surface gesture recognizer for touch controls: taps,
3
+ * directional flick swipes, and continuous step drags all resolve to action
4
+ * names from a TouchGestureBindings declaration. Drag steps stream out of
5
+ * move() as the finger travels; taps and swipes resolve on end(). Timestamps
6
+ * are injected so the state machine stays pure; the capture layer owns touch
7
+ * identifiers and pointer capture.
8
+ */
9
+ export const DEFAULT_GESTURE_TUNING = {
10
+ tapMoveThresholdPx: 12,
11
+ tapMaxMs: 280,
12
+ swipeMinPx: 48,
13
+ swipeMinVelocity: 0.45,
14
+ dragStepPx: 36,
15
+ };
16
+ export function createGestureSurfaceTracker(bindings, tuning = DEFAULT_GESTURE_TUNING) {
17
+ const drag = bindings.drag;
18
+ const dragStepPx = drag?.stepPx ?? tuning.dragStepPx;
19
+ const dragBound = {
20
+ horizontal: drag?.left !== undefined || drag?.right !== undefined,
21
+ vertical: drag?.up !== undefined || drag?.down !== undefined,
22
+ };
23
+ let active = false;
24
+ let startX = 0;
25
+ let startY = 0;
26
+ let lastX = 0;
27
+ let lastY = 0;
28
+ let startTime = 0;
29
+ let moved = false;
30
+ let axis = null;
31
+ let dragAccum = 0;
32
+ let dragStepsFired = 0;
33
+ const commitAxis = (totalDx, totalDy) => {
34
+ if (axis !== null)
35
+ return;
36
+ axis = Math.abs(totalDx) >= Math.abs(totalDy) ? "horizontal" : "vertical";
37
+ };
38
+ const swipeFor = (totalDx, totalDy) => {
39
+ if (axis === "horizontal") {
40
+ return totalDx >= 0 ? bindings.swipeRight : bindings.swipeLeft;
41
+ }
42
+ return totalDy >= 0 ? bindings.swipeDown : bindings.swipeUp;
43
+ };
44
+ const dragActionFor = (direction) => {
45
+ if (axis === "horizontal")
46
+ return direction >= 0 ? drag?.right : drag?.left;
47
+ return direction >= 0 ? drag?.down : drag?.up;
48
+ };
49
+ return {
50
+ begin(x, y, nowMs) {
51
+ active = true;
52
+ startX = lastX = x;
53
+ startY = lastY = y;
54
+ startTime = nowMs;
55
+ moved = false;
56
+ axis = null;
57
+ dragAccum = 0;
58
+ dragStepsFired = 0;
59
+ },
60
+ move(x, y) {
61
+ if (!active)
62
+ return [];
63
+ const totalDx = x - startX;
64
+ const totalDy = y - startY;
65
+ if (!moved && Math.hypot(totalDx, totalDy) > tuning.tapMoveThresholdPx) {
66
+ moved = true;
67
+ commitAxis(totalDx, totalDy);
68
+ }
69
+ const fired = [];
70
+ if (moved && axis !== null && dragBound[axis]) {
71
+ const delta = axis === "horizontal" ? x - lastX : y - lastY;
72
+ if (delta !== 0 && dragAccum !== 0 && Math.sign(delta) !== Math.sign(dragAccum))
73
+ dragAccum = 0;
74
+ dragAccum += delta;
75
+ while (Math.abs(dragAccum) >= dragStepPx) {
76
+ const direction = Math.sign(dragAccum);
77
+ const action = dragActionFor(direction);
78
+ dragAccum -= direction * dragStepPx;
79
+ if (action !== undefined) {
80
+ fired.push(action);
81
+ dragStepsFired += 1;
82
+ }
83
+ }
84
+ }
85
+ lastX = x;
86
+ lastY = y;
87
+ return fired;
88
+ },
89
+ end(x, y, nowMs) {
90
+ if (!active)
91
+ return [];
92
+ active = false;
93
+ const totalDx = x - startX;
94
+ const totalDy = y - startY;
95
+ const elapsed = nowMs - startTime;
96
+ if (!moved) {
97
+ if (elapsed <= tuning.tapMaxMs && bindings.tap !== undefined)
98
+ return [bindings.tap];
99
+ return [];
100
+ }
101
+ const travel = Math.hypot(totalDx, totalDy);
102
+ const velocity = elapsed <= 0 ? Infinity : travel / elapsed;
103
+ if (travel < tuning.swipeMinPx || velocity < tuning.swipeMinVelocity)
104
+ return [];
105
+ const direction = axis === "horizontal" ? Math.sign(totalDx) : Math.sign(totalDy);
106
+ if (axis !== null && dragBound[axis] && dragActionFor(direction) !== undefined)
107
+ return [];
108
+ const swipe = swipeFor(totalDx, totalDy);
109
+ if (swipe === undefined || dragStepsFired > 0)
110
+ return [];
111
+ return [swipe];
112
+ },
113
+ cancel() {
114
+ active = false;
115
+ },
116
+ isActive() {
117
+ return active;
118
+ },
119
+ };
120
+ }
@@ -14,6 +14,19 @@ export interface PointerHit {
14
14
  entity: string | null;
15
15
  /** Topmost scene-object instance id under the cursor, or null. */
16
16
  object: string | null;
17
+ /** Texture-space UV at the hit, when the intersected mesh carries UVs; absent for the ground-plane fallback. */
18
+ uv?: {
19
+ u: number;
20
+ v: number;
21
+ };
22
+ /** Sampled `#rrggbb` color + PBR params from the hit mesh's `MeshStandardMaterial` (#151.2); `null`/unset when the hit surface has no standard material (e.g. the ground plane). */
23
+ material?: {
24
+ color: string;
25
+ metalness?: number;
26
+ roughness?: number;
27
+ } | null;
28
+ /** Index of the hit instance when the intersected mesh is a `THREE.InstancedMesh`; absent otherwise. */
29
+ instanceId?: number;
17
30
  }
18
31
  export type PointerButton = "primary" | "secondary" | "middle";
19
32
  /** Build an `origin → point` aim for `item.use` / projectiles, firing toward the cursor. */
@@ -22,3 +35,35 @@ export declare function aimToPoint(origin: PointerVec3, point: PointerVec3): Aim
22
35
  export declare function moveTargetFromHit(hit: PointerHit): PointerVec3;
23
36
  /** Project a pointer hit onto the XZ plane for navmesh routing (`findPath` takes `[x, z]`). */
24
37
  export declare function groundOf(hit: PointerHit): readonly [number, number];
38
+ /** Snapshot of an in-progress or just-released drag: origin/current cursor points plus the clamped pull vector. */
39
+ export interface DragState {
40
+ origin: PointerVec3;
41
+ current: PointerVec3;
42
+ /** `current - origin`, clamped to `maxPull` in length. */
43
+ pull: PointerVec3;
44
+ /** Length of `pull`, at most `maxPull`. */
45
+ magnitude: number;
46
+ /** `magnitude / maxPull`, in `[0, 1]`. `0` when `maxPull` is unset. */
47
+ fraction: number;
48
+ }
49
+ export type DragResult = DragState;
50
+ export interface DragCaptureConfig {
51
+ /** Clamp on pull-vector length. Unset (default) means unclamped. */
52
+ maxPull?: number;
53
+ /** Max distance from `origin` a `begin()` point may be to start a drag. Unset (default) means unrestricted. */
54
+ grabRadius?: number;
55
+ }
56
+ /** Renderer-agnostic drag-capture / pull-vector state machine for slingshot-style aiming, drawback abilities, and similar gestures. */
57
+ export interface DragCapture {
58
+ /** Start a drag at `origin` if `at` is within `grabRadius` of it. Returns whether the drag started. */
59
+ begin(origin: PointerVec3, at: PointerVec3): boolean;
60
+ /** Move the drag's current point. No-op when no drag is active. */
61
+ update(at: PointerVec3): void;
62
+ /** End the drag, returning its final state, or `null` if no drag was active. */
63
+ release(): DragResult | null;
64
+ /** Abort the drag without producing a result. */
65
+ cancel(): void;
66
+ /** The current drag state, or `null` when idle. */
67
+ state(): DragState | null;
68
+ }
69
+ export declare function createDragCapture(config?: DragCaptureConfig): DragCapture;
@@ -15,3 +15,51 @@ export function moveTargetFromHit(hit) {
15
15
  export function groundOf(hit) {
16
16
  return [hit.point[0], hit.point[2]];
17
17
  }
18
+ function subtractVec3(a, b) {
19
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
20
+ }
21
+ function lengthVec3(v) {
22
+ return Math.hypot(v[0], v[1], v[2]);
23
+ }
24
+ export function createDragCapture(config = {}) {
25
+ const maxPull = config.maxPull ?? Number.POSITIVE_INFINITY;
26
+ const grabRadius = config.grabRadius ?? Number.POSITIVE_INFINITY;
27
+ let origin = null;
28
+ let current = null;
29
+ function snapshot() {
30
+ if (origin === null || current === null)
31
+ return null;
32
+ const offset = subtractVec3(current, origin);
33
+ const rawLength = lengthVec3(offset);
34
+ const scale = rawLength > maxPull && rawLength > 0 ? maxPull / rawLength : 1;
35
+ const pull = [offset[0] * scale, offset[1] * scale, offset[2] * scale];
36
+ const magnitude = Math.min(rawLength, maxPull);
37
+ const fraction = Number.isFinite(maxPull) && maxPull > 0 ? magnitude / maxPull : 0;
38
+ return { origin, current, pull, magnitude, fraction };
39
+ }
40
+ return {
41
+ begin(dragOrigin, at) {
42
+ if (lengthVec3(subtractVec3(at, dragOrigin)) > grabRadius)
43
+ return false;
44
+ origin = dragOrigin;
45
+ current = at;
46
+ return true;
47
+ },
48
+ update(at) {
49
+ if (origin === null)
50
+ return;
51
+ current = at;
52
+ },
53
+ release() {
54
+ const result = snapshot();
55
+ origin = null;
56
+ current = null;
57
+ return result;
58
+ },
59
+ cancel() {
60
+ origin = null;
61
+ current = null;
62
+ },
63
+ state: snapshot,
64
+ };
65
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Touch control scheme derived from a game's existing action bindings: the
3
+ * capture layer renders a virtual joystick, on-screen buttons, and a gesture
4
+ * surface, and feeds synthetic `touch:<action>` codes into the same
5
+ * ActionStateTracker the keyboard uses, so games gain touch play without any
6
+ * per-game input code. Games refine the derived defaults through
7
+ * TouchControlsConfig (gesture bindings, button curation) or opt out entirely
8
+ * with `touch: false`.
9
+ */
10
+ import { type ActionCodesMap } from "./actionBindings.js";
11
+ export declare const TOUCH_CODE_PREFIX = "touch:";
12
+ export declare function touchCode(action: string): string;
13
+ /** Every action gains a synthetic touch code alongside its physical codes. */
14
+ export declare function withTouchCodes(map: ActionCodesMap | undefined): ActionCodesMap;
15
+ export interface TouchDragBinding {
16
+ /** Action fired once per `stepPx` of travel toward that side. */
17
+ left?: string;
18
+ right?: string;
19
+ up?: string;
20
+ down?: string;
21
+ stepPx?: number;
22
+ }
23
+ export interface TouchGestureBindings {
24
+ /** Action fired by a quick still tap on the play surface. */
25
+ tap?: string;
26
+ /** Actions fired by a fast flick past the swipe threshold. */
27
+ swipeUp?: string;
28
+ swipeDown?: string;
29
+ swipeLeft?: string;
30
+ swipeRight?: string;
31
+ /** Continuous drag: repeats the directional action every `stepPx` of travel. Wins over swipes on its axis, except a flick can still fire the swipe binding for a direction the drag leaves unbound. */
32
+ drag?: TouchDragBinding;
33
+ }
34
+ export interface TouchButtonSpec {
35
+ action: string;
36
+ label?: string;
37
+ /** Glyph name from the capture layer's icon catalog (`@jgengine/react/gameIcons` `GameIconName`); unset derives one from the action name, `false` forces the text label. */
38
+ icon?: string | false;
39
+ }
40
+ export interface TouchControlsConfig {
41
+ /** `false` removes the virtual joystick even when movement actions are bound. */
42
+ movement?: false;
43
+ /** Curated on-screen buttons (order preserved); omit to derive one button per remaining action. */
44
+ buttons?: readonly (string | TouchButtonSpec)[];
45
+ /** Play-surface gestures; actions consumed here stop appearing as derived buttons. */
46
+ gestures?: TouchGestureBindings;
47
+ /** Drag-to-look on the play surface; defaults to true for first-person games. */
48
+ look?: boolean;
49
+ /** Radians of look rotation per pixel of touch travel. Default 0.005. */
50
+ lookSensitivity?: number;
51
+ /** Actions to leave off the derived button cluster. */
52
+ hidden?: readonly string[];
53
+ }
54
+ export interface TouchJoystick {
55
+ up: string | null;
56
+ down: string | null;
57
+ left: string | null;
58
+ right: string | null;
59
+ }
60
+ export interface TouchButton {
61
+ action: string;
62
+ label: string;
63
+ /** Glyph hint for the capture layer: a name to render, `false` to force text, null to derive from the action name. */
64
+ icon: string | false | null;
65
+ }
66
+ export interface TouchScheme {
67
+ joystick: TouchJoystick | null;
68
+ buttons: readonly TouchButton[];
69
+ gestures: TouchGestureBindings | null;
70
+ look: boolean;
71
+ lookSensitivity: number;
72
+ }
73
+ export declare function touchActionLabel(action: string): string;
74
+ export interface DeriveTouchSchemeOptions {
75
+ reserved: ReadonlySet<string>;
76
+ firstPerson: boolean;
77
+ config?: TouchControlsConfig | false;
78
+ }
79
+ /**
80
+ * Null means "render no touch controls" — either the game opted out or there
81
+ * is nothing to synthesize.
82
+ */
83
+ export declare function deriveTouchScheme(input: ActionCodesMap | undefined, { reserved, firstPerson, config }: DeriveTouchSchemeOptions): TouchScheme | null;