@jgengine/core 0.1.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 (119) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +11 -0
  3. package/dist/assets/spriteSheet.d.ts +46 -0
  4. package/dist/assets/spriteSheet.js +86 -0
  5. package/dist/combat/death.d.ts +61 -0
  6. package/dist/combat/death.js +61 -0
  7. package/dist/combat/effects.d.ts +61 -0
  8. package/dist/combat/effects.js +108 -0
  9. package/dist/combat/projectiles.d.ts +45 -0
  10. package/dist/combat/projectiles.js +156 -0
  11. package/dist/commands/commandRegistry.d.ts +23 -0
  12. package/dist/commands/commandRegistry.js +26 -0
  13. package/dist/economy/currency.d.ts +26 -0
  14. package/dist/economy/currency.js +28 -0
  15. package/dist/economy/wallet.d.ts +16 -0
  16. package/dist/economy/wallet.js +49 -0
  17. package/dist/game/defineGame.d.ts +41 -0
  18. package/dist/game/defineGame.js +7 -0
  19. package/dist/game/events.d.ts +87 -0
  20. package/dist/game/events.js +24 -0
  21. package/dist/game/feed.d.ts +19 -0
  22. package/dist/game/feed.js +47 -0
  23. package/dist/game/leaderboard.d.ts +46 -0
  24. package/dist/game/leaderboard.js +60 -0
  25. package/dist/game/loadout.d.ts +47 -0
  26. package/dist/game/loadout.js +36 -0
  27. package/dist/game/lootTable.d.ts +27 -0
  28. package/dist/game/lootTable.js +78 -0
  29. package/dist/game/playableGame.d.ts +55 -0
  30. package/dist/game/playableGame.js +1 -0
  31. package/dist/game/quest.d.ts +93 -0
  32. package/dist/game/quest.js +228 -0
  33. package/dist/game/social.d.ts +78 -0
  34. package/dist/game/social.js +227 -0
  35. package/dist/game/trade.d.ts +35 -0
  36. package/dist/game/trade.js +53 -0
  37. package/dist/game/unlocks.d.ts +13 -0
  38. package/dist/game/unlocks.js +34 -0
  39. package/dist/index.d.ts +6 -0
  40. package/dist/index.js +6 -0
  41. package/dist/input/actionBindings.d.ts +41 -0
  42. package/dist/input/actionBindings.js +115 -0
  43. package/dist/input/lookChannel.d.ts +29 -0
  44. package/dist/input/lookChannel.js +44 -0
  45. package/dist/input/touchGestures.d.ts +31 -0
  46. package/dist/input/touchGestures.js +72 -0
  47. package/dist/interaction/proximityPrompt.d.ts +51 -0
  48. package/dist/interaction/proximityPrompt.js +76 -0
  49. package/dist/inventory/inventoryModel.d.ts +57 -0
  50. package/dist/inventory/inventoryModel.js +208 -0
  51. package/dist/item/use.d.ts +25 -0
  52. package/dist/item/use.js +40 -0
  53. package/dist/item/weapon.d.ts +8 -0
  54. package/dist/item/weapon.js +18 -0
  55. package/dist/movement/avatarGait.d.ts +14 -0
  56. package/dist/movement/avatarGait.js +20 -0
  57. package/dist/movement/cameraRig.d.ts +15 -0
  58. package/dist/movement/cameraRig.js +43 -0
  59. package/dist/movement/movementModel.d.ts +96 -0
  60. package/dist/movement/movementModel.js +171 -0
  61. package/dist/movement/poseState.d.ts +23 -0
  62. package/dist/movement/poseState.js +61 -0
  63. package/dist/multiplayer/poseSyncGate.d.ts +18 -0
  64. package/dist/multiplayer/poseSyncGate.js +30 -0
  65. package/dist/multiplayer/presenceContract.d.ts +60 -0
  66. package/dist/multiplayer/presenceContract.js +19 -0
  67. package/dist/multiplayer/presenceModel.d.ts +60 -0
  68. package/dist/multiplayer/presenceModel.js +51 -0
  69. package/dist/runtime/adapter.d.ts +24 -0
  70. package/dist/runtime/adapter.js +12 -0
  71. package/dist/runtime/commandRunner.d.ts +15 -0
  72. package/dist/runtime/commandRunner.js +25 -0
  73. package/dist/runtime/gameContext.d.ts +138 -0
  74. package/dist/runtime/gameContext.js +371 -0
  75. package/dist/runtime/gameRuntime.d.ts +42 -0
  76. package/dist/runtime/gameRuntime.js +95 -0
  77. package/dist/runtime/hostPersistence.d.ts +105 -0
  78. package/dist/runtime/hostPersistence.js +117 -0
  79. package/dist/runtime/save.d.ts +9 -0
  80. package/dist/runtime/save.js +29 -0
  81. package/dist/runtime/snapshot.d.ts +87 -0
  82. package/dist/runtime/snapshot.js +75 -0
  83. package/dist/runtime/transport.d.ts +60 -0
  84. package/dist/runtime/transport.js +1 -0
  85. package/dist/scene/assetCatalog.d.ts +10 -0
  86. package/dist/scene/assetCatalog.js +17 -0
  87. package/dist/scene/assetPreload.d.ts +7 -0
  88. package/dist/scene/assetPreload.js +22 -0
  89. package/dist/scene/behaviors.d.ts +19 -0
  90. package/dist/scene/behaviors.js +18 -0
  91. package/dist/scene/entityStats.d.ts +38 -0
  92. package/dist/scene/entityStats.js +65 -0
  93. package/dist/scene/entityStore.d.ts +52 -0
  94. package/dist/scene/entityStore.js +84 -0
  95. package/dist/scene/objectStore.d.ts +28 -0
  96. package/dist/scene/objectStore.js +69 -0
  97. package/dist/scene/spatial.d.ts +34 -0
  98. package/dist/scene/spatial.js +96 -0
  99. package/dist/scene/targeting.d.ts +20 -0
  100. package/dist/scene/targeting.js +56 -0
  101. package/dist/stats/statModifiers.d.ts +18 -0
  102. package/dist/stats/statModifiers.js +56 -0
  103. package/dist/store/changeSignal.d.ts +7 -0
  104. package/dist/store/changeSignal.js +32 -0
  105. package/dist/store/engineStore.d.ts +22 -0
  106. package/dist/store/engineStore.js +50 -0
  107. package/dist/store/features.d.ts +10 -0
  108. package/dist/store/features.js +23 -0
  109. package/dist/store/observableKeyedStore.d.ts +10 -0
  110. package/dist/store/observableKeyedStore.js +42 -0
  111. package/dist/time/calendarClock.d.ts +17 -0
  112. package/dist/time/calendarClock.js +42 -0
  113. package/dist/time/gameClock.d.ts +9 -0
  114. package/dist/time/gameClock.js +20 -0
  115. package/dist/time/serverTick.d.ts +16 -0
  116. package/dist/time/serverTick.js +18 -0
  117. package/dist/world/features.d.ts +39 -0
  118. package/dist/world/features.js +15 -0
  119. package/package.json +32 -0
@@ -0,0 +1,208 @@
1
+ export function createEmptyInventory(layout) {
2
+ return { slots: new Array(layout.slots).fill(null) };
3
+ }
4
+ function violatesKind(layout, traits, itemId) {
5
+ if (layout.accepts === undefined || traits.kind === undefined)
6
+ return false;
7
+ const kind = traits.kind(itemId);
8
+ if (Array.isArray(layout.accepts))
9
+ return kind === null || !layout.accepts.includes(kind);
10
+ return kind !== layout.accepts;
11
+ }
12
+ function computeCapacity(state, traits, itemId) {
13
+ const limit = traits.stackLimit(itemId);
14
+ let existingRoom = 0;
15
+ let freeSlots = 0;
16
+ for (const slot of state.slots) {
17
+ if (slot === null)
18
+ freeSlots += 1;
19
+ else if (slot.itemId === itemId)
20
+ existingRoom += Math.max(0, limit - slot.count);
21
+ }
22
+ return { limit, existingRoom, freeCapacity: freeSlots * limit };
23
+ }
24
+ function fillSlots(slots, itemId, limit, amount) {
25
+ let remaining = amount;
26
+ for (let i = 0; i < slots.length && remaining > 0; i++) {
27
+ const slot = slots[i];
28
+ if (slot !== null && slot.itemId === itemId && slot.count < limit) {
29
+ const add = Math.min(limit - slot.count, remaining);
30
+ slots[i] = { itemId, count: slot.count + add };
31
+ remaining -= add;
32
+ }
33
+ }
34
+ for (let i = 0; i < slots.length && remaining > 0; i++) {
35
+ if (slots[i] === null) {
36
+ const add = Math.min(limit, remaining);
37
+ slots[i] = { itemId, count: add };
38
+ remaining -= add;
39
+ }
40
+ }
41
+ return remaining;
42
+ }
43
+ function autoPut(state, layout, traits, itemId, count) {
44
+ if (violatesKind(layout, traits, itemId))
45
+ return { status: "rejected", reason: "wrong-kind" };
46
+ if (count <= 0)
47
+ return { status: "ok", state };
48
+ const { limit, existingRoom, freeCapacity } = computeCapacity(state, traits, itemId);
49
+ if (existingRoom + freeCapacity < count)
50
+ return { status: "rejected", reason: "no-space" };
51
+ const slots = state.slots.slice();
52
+ fillSlots(slots, itemId, limit, count);
53
+ return { status: "ok", state: { slots } };
54
+ }
55
+ function explicitPut(state, layout, traits, itemId, count, slot) {
56
+ if (slot < 0 || slot >= state.slots.length)
57
+ return { status: "rejected", reason: "invalid-slot" };
58
+ if (violatesKind(layout, traits, itemId))
59
+ return { status: "rejected", reason: "wrong-kind" };
60
+ if (count <= 0)
61
+ return { status: "ok", state };
62
+ const limit = traits.stackLimit(itemId);
63
+ const existing = state.slots[slot];
64
+ const slots = state.slots.slice();
65
+ if (existing === null) {
66
+ if (count > limit)
67
+ return { status: "rejected", reason: "no-space" };
68
+ slots[slot] = { itemId, count };
69
+ return { status: "ok", state: { slots } };
70
+ }
71
+ if (existing.itemId !== itemId)
72
+ return { status: "rejected", reason: "slot-occupied" };
73
+ const newCount = existing.count + count;
74
+ if (newCount > limit)
75
+ return { status: "rejected", reason: "no-space" };
76
+ slots[slot] = { itemId, count: newCount };
77
+ return { status: "ok", state: { slots } };
78
+ }
79
+ export function putItem(state, layout, traits, itemId, count, options) {
80
+ if (options?.slot !== undefined)
81
+ return explicitPut(state, layout, traits, itemId, count, options.slot);
82
+ return autoPut(state, layout, traits, itemId, count);
83
+ }
84
+ export function takeItem(state, itemId, count) {
85
+ if (count <= 0)
86
+ return { status: "ok", state };
87
+ if (countItem(state, itemId) < count)
88
+ return { status: "rejected", reason: "insufficient" };
89
+ const slots = state.slots.slice();
90
+ let remaining = count;
91
+ for (let i = slots.length - 1; i >= 0 && remaining > 0; i--) {
92
+ const slot = slots[i];
93
+ if (slot !== null && slot.itemId === itemId) {
94
+ const removed = Math.min(slot.count, remaining);
95
+ const newCount = slot.count - removed;
96
+ slots[i] = newCount > 0 ? { itemId, count: newCount } : null;
97
+ remaining -= removed;
98
+ }
99
+ }
100
+ return { status: "ok", state: { slots } };
101
+ }
102
+ export function countItem(state, itemId) {
103
+ let total = 0;
104
+ for (const slot of state.slots)
105
+ if (slot !== null && slot.itemId === itemId)
106
+ total += slot.count;
107
+ return total;
108
+ }
109
+ export function hasItem(state, itemId, count) {
110
+ return countItem(state, itemId) >= count;
111
+ }
112
+ export function moveItem(from, fromSlot, to, toLayout, traits, toSlot) {
113
+ if (fromSlot < 0 || fromSlot >= from.slots.length)
114
+ return { status: "rejected", reason: "invalid-slot" };
115
+ const source = from.slots[fromSlot];
116
+ if (source === null)
117
+ return { status: "rejected", reason: "empty-slot" };
118
+ if (violatesKind(toLayout, traits, source.itemId))
119
+ return { status: "rejected", reason: "wrong-kind" };
120
+ const sameInventory = from === to;
121
+ if (sameInventory && toSlot === undefined) {
122
+ return { status: "ok", from, to };
123
+ }
124
+ if (sameInventory && toSlot === fromSlot) {
125
+ return { status: "ok", from, to };
126
+ }
127
+ const fromSlots = from.slots.slice();
128
+ const toSlots = sameInventory ? fromSlots : to.slots.slice();
129
+ const limit = traits.stackLimit(source.itemId);
130
+ if (toSlot !== undefined) {
131
+ if (toSlot < 0 || toSlot >= toSlots.length)
132
+ return { status: "rejected", reason: "invalid-slot" };
133
+ const dest = toSlots[toSlot];
134
+ if (dest === null) {
135
+ toSlots[toSlot] = { itemId: source.itemId, count: source.count };
136
+ fromSlots[fromSlot] = null;
137
+ }
138
+ else if (dest.itemId === source.itemId) {
139
+ const merged = dest.count + source.count;
140
+ const placed = Math.min(merged, limit);
141
+ const remainder = merged - placed;
142
+ toSlots[toSlot] = { itemId: source.itemId, count: placed };
143
+ fromSlots[fromSlot] = remainder > 0 ? { itemId: source.itemId, count: remainder } : null;
144
+ }
145
+ else {
146
+ toSlots[toSlot] = source;
147
+ fromSlots[fromSlot] = dest;
148
+ }
149
+ }
150
+ else {
151
+ const { existingRoom, freeCapacity } = computeCapacity({ slots: toSlots }, traits, source.itemId);
152
+ if (existingRoom + freeCapacity < source.count)
153
+ return { status: "rejected", reason: "no-space" };
154
+ fillSlots(toSlots, source.itemId, limit, source.count);
155
+ fromSlots[fromSlot] = null;
156
+ }
157
+ return {
158
+ status: "ok",
159
+ from: { slots: fromSlots },
160
+ to: sameInventory ? { slots: fromSlots } : { slots: toSlots },
161
+ };
162
+ }
163
+ export function createInventorySet(layouts, traits) {
164
+ const states = new Map();
165
+ for (const id of Object.keys(layouts)) {
166
+ states.set(id, createEmptyInventory(layouts[id]));
167
+ }
168
+ function requireState(id) {
169
+ const current = states.get(id);
170
+ if (current === undefined)
171
+ throw new Error(`unknown inventory id: ${id}`);
172
+ return current;
173
+ }
174
+ return {
175
+ put(id, itemId, count, options) {
176
+ const result = putItem(requireState(id), layouts[id], traits, itemId, count, options);
177
+ if (result.status === "ok")
178
+ states.set(id, result.state);
179
+ return result;
180
+ },
181
+ take(id, itemId, count) {
182
+ const result = takeItem(requireState(id), itemId, count);
183
+ if (result.status === "ok")
184
+ states.set(id, result.state);
185
+ return result;
186
+ },
187
+ move(fromId, fromSlot, toId, toSlot) {
188
+ const result = moveItem(requireState(fromId), fromSlot, requireState(toId), layouts[toId], traits, toSlot);
189
+ if (result.status === "ok") {
190
+ states.set(fromId, result.from);
191
+ states.set(toId, result.to);
192
+ }
193
+ return result;
194
+ },
195
+ count(id, itemId) {
196
+ return countItem(requireState(id), itemId);
197
+ },
198
+ has(id, itemId, count) {
199
+ return hasItem(requireState(id), itemId, count);
200
+ },
201
+ state(id) {
202
+ return requireState(id);
203
+ },
204
+ replaceState(id, state) {
205
+ states.set(id, state);
206
+ },
207
+ };
208
+ }
@@ -0,0 +1,25 @@
1
+ import type { Aim } from "../scene/spatial.js";
2
+ export interface ItemUseInput {
3
+ from: string;
4
+ itemId: string;
5
+ inventoryId?: string;
6
+ aim?: Aim;
7
+ }
8
+ export interface ItemUseRejection {
9
+ reason: string;
10
+ }
11
+ export interface ItemUseResult<TState> {
12
+ state: TState;
13
+ error?: string;
14
+ }
15
+ export interface ItemUseHandler<TState> {
16
+ can?(state: TState, input: ItemUseInput): ItemUseRejection | null;
17
+ apply(state: TState, input: ItemUseInput): ItemUseResult<TState>;
18
+ }
19
+ export interface ItemUse<TState> {
20
+ register(handlers: Record<string, ItemUseHandler<TState>>): void;
21
+ registered(): string[];
22
+ can(state: TState, input: ItemUseInput): ItemUseRejection | null;
23
+ use(state: TState, input: ItemUseInput): ItemUseResult<TState>;
24
+ }
25
+ export declare function createItemUse<TState>(resolveUse: (itemId: string) => string | null | undefined): ItemUse<TState>;
@@ -0,0 +1,40 @@
1
+ export function createItemUse(resolveUse) {
2
+ const handlers = new Map();
3
+ function resolveHandler(itemId) {
4
+ const useName = resolveUse(itemId);
5
+ if (!useName)
6
+ return { rejection: { reason: "not-usable" } };
7
+ const handler = handlers.get(useName);
8
+ if (!handler)
9
+ return { rejection: { reason: "unknown-handler" } };
10
+ return { handler };
11
+ }
12
+ return {
13
+ register(newHandlers) {
14
+ for (const [name, handler] of Object.entries(newHandlers)) {
15
+ if (handlers.has(name)) {
16
+ throw new Error(`Item use handler "${name}" is already registered.`);
17
+ }
18
+ handlers.set(name, handler);
19
+ }
20
+ },
21
+ registered() {
22
+ return Array.from(handlers.keys());
23
+ },
24
+ can(state, input) {
25
+ const resolved = resolveHandler(input.itemId);
26
+ if ("rejection" in resolved)
27
+ return resolved.rejection;
28
+ return resolved.handler.can?.(state, input) ?? null;
29
+ },
30
+ use(state, input) {
31
+ const resolved = resolveHandler(input.itemId);
32
+ if ("rejection" in resolved)
33
+ return { state, error: resolved.rejection.reason };
34
+ const rejection = resolved.handler.can?.(state, input);
35
+ if (rejection)
36
+ return { state, error: rejection.reason };
37
+ return resolved.handler.apply(state, input);
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,8 @@
1
+ export interface WeaponEntry {
2
+ weapon?: Record<string, unknown>;
3
+ }
4
+ export declare function getWeaponStat(entry: WeaponEntry | null | undefined, stat: string): number | null;
5
+ export interface WeaponStats {
6
+ getStat(itemId: string, stat: string): number | null;
7
+ }
8
+ export declare function createWeaponStats(resolveEntry: (itemId: string) => WeaponEntry | null | undefined): WeaponStats;
@@ -0,0 +1,18 @@
1
+ export function getWeaponStat(entry, stat) {
2
+ if (!entry?.weapon)
3
+ return null;
4
+ let value = entry.weapon;
5
+ for (const key of stat.split(".")) {
6
+ if (typeof value !== "object" || value === null)
7
+ return null;
8
+ value = value[key];
9
+ }
10
+ return typeof value === "number" ? value : null;
11
+ }
12
+ export function createWeaponStats(resolveEntry) {
13
+ return {
14
+ getStat(itemId, stat) {
15
+ return getWeaponStat(resolveEntry(itemId), stat);
16
+ },
17
+ };
18
+ }
@@ -0,0 +1,14 @@
1
+ export interface GaitTuning {
2
+ /** Stride cycles per world unit travelled — ties step cadence to actual speed. */
3
+ stridesPerUnit: number;
4
+ bobAmplitude: number;
5
+ swayAmplitude: number;
6
+ /** Speed at which bob/sway reach full intensity. */
7
+ fullIntensitySpeed: number;
8
+ }
9
+ export declare const DEFAULT_GAIT_TUNING: GaitTuning;
10
+ export declare function advanceGaitPhase(phase: number, speedUnitsPerSec: number, dtSec: number, tuning?: GaitTuning): number;
11
+ /** Vertical hop per footfall — two peaks per stride cycle. */
12
+ export declare function gaitBobOffset(phase: number, speedUnitsPerSec: number, tuning?: GaitTuning): number;
13
+ /** Signed side-to-side lean alternating with each footfall. */
14
+ export declare function gaitSwayAngle(phase: number, speedUnitsPerSec: number, tuning?: GaitTuning): number;
@@ -0,0 +1,20 @@
1
+ export const DEFAULT_GAIT_TUNING = {
2
+ stridesPerUnit: 0.95,
3
+ bobAmplitude: 0.05,
4
+ swayAmplitude: 0.035,
5
+ fullIntensitySpeed: 4.5,
6
+ };
7
+ export function advanceGaitPhase(phase, speedUnitsPerSec, dtSec, tuning = DEFAULT_GAIT_TUNING) {
8
+ return phase + speedUnitsPerSec * dtSec * tuning.stridesPerUnit * Math.PI * 2;
9
+ }
10
+ function gaitIntensity(speedUnitsPerSec, tuning) {
11
+ return Math.min(1, Math.max(0, speedUnitsPerSec / tuning.fullIntensitySpeed));
12
+ }
13
+ /** Vertical hop per footfall — two peaks per stride cycle. */
14
+ export function gaitBobOffset(phase, speedUnitsPerSec, tuning = DEFAULT_GAIT_TUNING) {
15
+ return Math.abs(Math.sin(phase)) * tuning.bobAmplitude * gaitIntensity(speedUnitsPerSec, tuning);
16
+ }
17
+ /** Signed side-to-side lean alternating with each footfall. */
18
+ export function gaitSwayAngle(phase, speedUnitsPerSec, tuning = DEFAULT_GAIT_TUNING) {
19
+ return Math.sin(phase) * tuning.swayAmplitude * gaitIntensity(speedUnitsPerSec, tuning);
20
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Camera-rig feel math: the pure per-frame computations behind the first-person
3
+ * camera (eye-height crouch blending, head bob, tracking smoothing, pitch
4
+ * clamping). Kept alongside movementModel.ts so a renderer-side controller can
5
+ * apply the results to its own three.js camera/vectors without owning the math.
6
+ */
7
+ export declare const CAMERA_PITCH_LIMIT = 1.45;
8
+ export declare function clampCameraPitch(pitch: number): number;
9
+ export declare function resolveTargetEyeHeight(crouching: boolean, grounded: boolean): number;
10
+ export declare function advanceEyeHeight(currentEyeHeight: number, targetEyeHeight: number, deltaSeconds: number): number;
11
+ export declare function resolveHeadBobRate(crouching: boolean, running: boolean): number;
12
+ export declare function resolveHeadBobAmplitude(crouching: boolean, running: boolean, grounded: boolean): number;
13
+ export declare function advanceHeadBobTime(currentHeadBobTime: number, moving: boolean, grounded: boolean, bobRate: number, deltaSeconds: number): number;
14
+ export declare function resolveHeadBobOffset(headBobTime: number, bobAmplitude: number): number;
15
+ export declare function resolveCameraTrackingBlend(deltaSeconds: number): number;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Camera-rig feel math: the pure per-frame computations behind the first-person
3
+ * camera (eye-height crouch blending, head bob, tracking smoothing, pitch
4
+ * clamping). Kept alongside movementModel.ts so a renderer-side controller can
5
+ * apply the results to its own three.js camera/vectors without owning the math.
6
+ */
7
+ import { MOVEMENT_TUNING } from "./movementModel.js";
8
+ export const CAMERA_PITCH_LIMIT = 1.45;
9
+ export function clampCameraPitch(pitch) {
10
+ return Math.max(-CAMERA_PITCH_LIMIT, Math.min(CAMERA_PITCH_LIMIT, pitch));
11
+ }
12
+ export function resolveTargetEyeHeight(crouching, grounded) {
13
+ return crouching && grounded ? MOVEMENT_TUNING.crouchEyeHeight : MOVEMENT_TUNING.standEyeHeight;
14
+ }
15
+ export function advanceEyeHeight(currentEyeHeight, targetEyeHeight, deltaSeconds) {
16
+ const blend = 1 - Math.exp(-MOVEMENT_TUNING.crouchTransitionSpeed * deltaSeconds);
17
+ return currentEyeHeight + (targetEyeHeight - currentEyeHeight) * blend;
18
+ }
19
+ export function resolveHeadBobRate(crouching, running) {
20
+ return crouching
21
+ ? MOVEMENT_TUNING.crouchBobRate
22
+ : running
23
+ ? MOVEMENT_TUNING.runBobRate
24
+ : MOVEMENT_TUNING.walkBobRate;
25
+ }
26
+ export function resolveHeadBobAmplitude(crouching, running, grounded) {
27
+ if (!grounded)
28
+ return 0;
29
+ return crouching
30
+ ? MOVEMENT_TUNING.crouchBobAmplitude
31
+ : running
32
+ ? MOVEMENT_TUNING.runBobAmplitude
33
+ : MOVEMENT_TUNING.walkBobAmplitude;
34
+ }
35
+ export function advanceHeadBobTime(currentHeadBobTime, moving, grounded, bobRate, deltaSeconds) {
36
+ return moving && grounded ? currentHeadBobTime + deltaSeconds * bobRate : 0;
37
+ }
38
+ export function resolveHeadBobOffset(headBobTime, bobAmplitude) {
39
+ return Math.sin(headBobTime * Math.PI * 2) * bobAmplitude;
40
+ }
41
+ export function resolveCameraTrackingBlend(deltaSeconds) {
42
+ return 1 - Math.exp(-MOVEMENT_TUNING.cameraTrackingSpeed * deltaSeconds);
43
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Movement core (pure model).
3
+ *
4
+ * This module owns the raw key model, the pure translation from held keys
5
+ * into a frame-independent {@link MovementIntent}, and the kinematics
6
+ * integration. Keeping it framework-free means the kinematics controller,
7
+ * the keyboard handler and the on-screen touch D-pad all speak the same
8
+ * vocabulary, and the translation is unit testable without a renderer.
9
+ * DOM capture, pointer lock, and the per-frame look channel live in
10
+ * movementInput.ts.
11
+ */
12
+ export type MovementKey = "w" | "a" | "s" | "d" | "shift" | "control" | "c" | "space";
13
+ export type MovementKeysState = Record<MovementKey, boolean>;
14
+ export declare function createEmptyMovementKeys(): MovementKeysState;
15
+ /**
16
+ * Frame-independent description of what the player is asking the avatar to do.
17
+ * `forward`/`right` are in the range -1..1 in the avatar's local frame; the
18
+ * controller rotates them into world space against the camera each frame.
19
+ */
20
+ export interface MovementIntent {
21
+ forward: number;
22
+ right: number;
23
+ crouching: boolean;
24
+ running: boolean;
25
+ jumping: boolean;
26
+ /** True when there is any directional input this frame. */
27
+ moving: boolean;
28
+ }
29
+ /**
30
+ * Translate the set of held keys into an intent. When `canMove` is false (a
31
+ * menu is open, the world is paused) the avatar is fully idle so it never
32
+ * drifts behind an overlay.
33
+ */
34
+ export declare function resolveMovementIntent(keys: MovementKeysState, canMove: boolean): MovementIntent;
35
+ /**
36
+ * Kinematics + feel tuning for the first-person controller. Centralised here so
37
+ * movement feel lives in one place rather than scattered through the renderer.
38
+ */
39
+ export declare const MOVEMENT_TUNING: {
40
+ readonly standEyeHeight: 1.7;
41
+ readonly crouchEyeHeight: 1.15;
42
+ readonly walkSpeedMultiplier: 1.75;
43
+ readonly runSpeedMultiplier: 2.25;
44
+ readonly crouchSpeedMultiplier: 0.45;
45
+ readonly groundAcceleration: 26;
46
+ readonly airAcceleration: 12;
47
+ readonly groundFriction: 18;
48
+ readonly jumpVelocity: 7.1;
49
+ readonly gravityAcceleration: 24;
50
+ readonly cameraTrackingSpeed: 24;
51
+ readonly crouchTransitionSpeed: 14;
52
+ readonly crouchBobRate: 0.5;
53
+ readonly walkBobRate: 1.5;
54
+ readonly runBobRate: 2.5;
55
+ readonly crouchBobAmplitude: 0.02;
56
+ readonly walkBobAmplitude: 0.045;
57
+ readonly runBobAmplitude: 0.06;
58
+ /** Below this horizontal speed the avatar is treated as standing still. */
59
+ readonly movingSpeedThreshold: 0.2;
60
+ /** Largest physics step we integrate so a stalled tab can't teleport the player. */
61
+ readonly maxFrameSeconds: 0.05;
62
+ };
63
+ /**
64
+ * Mutable kinematic state carried between frames by the controller. Kept here so
65
+ * the velocity / jump / gravity integration is a pure function testable without
66
+ * a renderer — the controller just owns the ref.
67
+ */
68
+ export interface PlayerMotionState {
69
+ horizontalVelocityX: number;
70
+ horizontalVelocityZ: number;
71
+ verticalVelocity: number;
72
+ jumpOffset: number;
73
+ grounded: boolean;
74
+ jumpHeld: boolean;
75
+ }
76
+ export declare function createPlayerMotionState(): PlayerMotionState;
77
+ /** Horizontal step (world units) the avatar should commit this frame. */
78
+ export interface MovementFrameStep {
79
+ stepX: number;
80
+ stepZ: number;
81
+ }
82
+ /**
83
+ * Advance one frame of avatar kinematics. Mutates `motion` (velocity, jump,
84
+ * gravity, grounded) and returns the horizontal step to commit through the
85
+ * collision-resolving stepper.
86
+ *
87
+ * `forwardX`/`forwardZ` are the camera heading projected onto the ground plane
88
+ * (need not be normalized); the avatar's right axis is derived from it. Pulling
89
+ * this out of the render loop is what lets us assert "held forward for N frames
90
+ * crosses the room" in a unit test instead of only in the live game.
91
+ */
92
+ export declare function advancePlayerMotion(motion: PlayerMotionState, intent: MovementIntent, forwardX: number, forwardZ: number, baseSpeed: number, rawDeltaSeconds: number): MovementFrameStep;
93
+ /** Peak jump height from MOVEMENT_TUNING.jumpVelocity + gravity, with small buffer. */
94
+ export declare const MAX_JUMP_OFFSET = 1.15;
95
+ /** Camera yaw looks along -Z; character mesh faces +Z at body rotation.y = 0. */
96
+ export declare function cameraYawToAvatarBodyYaw(cameraYaw: number): number;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Movement core (pure model).
3
+ *
4
+ * This module owns the raw key model, the pure translation from held keys
5
+ * into a frame-independent {@link MovementIntent}, and the kinematics
6
+ * integration. Keeping it framework-free means the kinematics controller,
7
+ * the keyboard handler and the on-screen touch D-pad all speak the same
8
+ * vocabulary, and the translation is unit testable without a renderer.
9
+ * DOM capture, pointer lock, and the per-frame look channel live in
10
+ * movementInput.ts.
11
+ */
12
+ export function createEmptyMovementKeys() {
13
+ return { w: false, a: false, s: false, d: false, shift: false, control: false, c: false, space: false };
14
+ }
15
+ const IDLE_INTENT = {
16
+ forward: 0,
17
+ right: 0,
18
+ crouching: false,
19
+ running: false,
20
+ jumping: false,
21
+ moving: false,
22
+ };
23
+ /**
24
+ * Translate the set of held keys into an intent. When `canMove` is false (a
25
+ * menu is open, the world is paused) the avatar is fully idle so it never
26
+ * drifts behind an overlay.
27
+ */
28
+ export function resolveMovementIntent(keys, canMove) {
29
+ if (!canMove)
30
+ return IDLE_INTENT;
31
+ const forward = (keys.w ? 1 : 0) - (keys.s ? 1 : 0);
32
+ const right = (keys.d ? 1 : 0) - (keys.a ? 1 : 0);
33
+ const crouching = keys.control || keys.c;
34
+ const running = keys.shift && !crouching;
35
+ return {
36
+ forward,
37
+ right,
38
+ crouching,
39
+ running,
40
+ jumping: keys.space,
41
+ moving: forward !== 0 || right !== 0,
42
+ };
43
+ }
44
+ /**
45
+ * Kinematics + feel tuning for the first-person controller. Centralised here so
46
+ * movement feel lives in one place rather than scattered through the renderer.
47
+ */
48
+ export const MOVEMENT_TUNING = {
49
+ standEyeHeight: 1.7,
50
+ crouchEyeHeight: 1.15,
51
+ walkSpeedMultiplier: 1.75,
52
+ runSpeedMultiplier: 2.25,
53
+ crouchSpeedMultiplier: 0.45,
54
+ groundAcceleration: 26,
55
+ airAcceleration: 12,
56
+ groundFriction: 18,
57
+ jumpVelocity: 7.1,
58
+ gravityAcceleration: 24,
59
+ cameraTrackingSpeed: 24,
60
+ crouchTransitionSpeed: 14,
61
+ crouchBobRate: 0.5,
62
+ walkBobRate: 1.5,
63
+ runBobRate: 2.5,
64
+ crouchBobAmplitude: 0.02,
65
+ walkBobAmplitude: 0.045,
66
+ runBobAmplitude: 0.06,
67
+ /** Below this horizontal speed the avatar is treated as standing still. */
68
+ movingSpeedThreshold: 0.2,
69
+ /** Largest physics step we integrate so a stalled tab can't teleport the player. */
70
+ maxFrameSeconds: 0.05,
71
+ };
72
+ /** Speed (units/s) the avatar should target given the active intent. */
73
+ function resolveTargetSpeed(intent, baseSpeed) {
74
+ const speedMultiplier = intent.crouching
75
+ ? MOVEMENT_TUNING.crouchSpeedMultiplier
76
+ : intent.running
77
+ ? MOVEMENT_TUNING.runSpeedMultiplier
78
+ : 1;
79
+ return baseSpeed * MOVEMENT_TUNING.walkSpeedMultiplier * speedMultiplier;
80
+ }
81
+ export function createPlayerMotionState() {
82
+ return {
83
+ horizontalVelocityX: 0,
84
+ horizontalVelocityZ: 0,
85
+ verticalVelocity: 0,
86
+ jumpOffset: 0,
87
+ grounded: true,
88
+ jumpHeld: false,
89
+ };
90
+ }
91
+ /**
92
+ * Advance one frame of avatar kinematics. Mutates `motion` (velocity, jump,
93
+ * gravity, grounded) and returns the horizontal step to commit through the
94
+ * collision-resolving stepper.
95
+ *
96
+ * `forwardX`/`forwardZ` are the camera heading projected onto the ground plane
97
+ * (need not be normalized); the avatar's right axis is derived from it. Pulling
98
+ * this out of the render loop is what lets us assert "held forward for N frames
99
+ * crosses the room" in a unit test instead of only in the live game.
100
+ */
101
+ export function advancePlayerMotion(motion, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds) {
102
+ const deltaSeconds = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
103
+ const targetSpeed = resolveTargetSpeed(intent, baseSpeed);
104
+ let targetVelocityX = 0;
105
+ let targetVelocityZ = 0;
106
+ if (intent.moving) {
107
+ // Project the camera heading onto the ground and fall back to -Z if the
108
+ // player is looking straight up/down so "forward" is always well defined.
109
+ let fx = forwardX;
110
+ let fz = forwardZ;
111
+ const forwardLengthSq = fx * fx + fz * fz;
112
+ if (forwardLengthSq < 1e-6) {
113
+ fx = 0;
114
+ fz = -1;
115
+ }
116
+ else {
117
+ const inv = 1 / Math.sqrt(forwardLengthSq);
118
+ fx *= inv;
119
+ fz *= inv;
120
+ }
121
+ // right = forward × up, with up = (0, 1, 0) → (-fz, 0, fx); unit when forward is.
122
+ const rightX = -fz;
123
+ const rightZ = fx;
124
+ targetVelocityX = fx * intent.forward + rightX * intent.right;
125
+ targetVelocityZ = fz * intent.forward + rightZ * intent.right;
126
+ const lengthSq = targetVelocityX * targetVelocityX + targetVelocityZ * targetVelocityZ;
127
+ if (lengthSq > 1e-6) {
128
+ const scale = targetSpeed / Math.sqrt(lengthSq);
129
+ targetVelocityX *= scale;
130
+ targetVelocityZ *= scale;
131
+ }
132
+ else {
133
+ targetVelocityX = 0;
134
+ targetVelocityZ = 0;
135
+ }
136
+ }
137
+ const acceleration = motion.grounded ? MOVEMENT_TUNING.groundAcceleration : MOVEMENT_TUNING.airAcceleration;
138
+ const accelerationBlend = 1 - Math.exp(-acceleration * deltaSeconds);
139
+ motion.horizontalVelocityX += (targetVelocityX - motion.horizontalVelocityX) * accelerationBlend;
140
+ motion.horizontalVelocityZ += (targetVelocityZ - motion.horizontalVelocityZ) * accelerationBlend;
141
+ if (!intent.moving && motion.grounded) {
142
+ const friction = Math.exp(-MOVEMENT_TUNING.groundFriction * deltaSeconds);
143
+ motion.horizontalVelocityX *= friction;
144
+ motion.horizontalVelocityZ *= friction;
145
+ }
146
+ const jumpPressed = intent.jumping;
147
+ if (jumpPressed && !motion.jumpHeld && motion.grounded && !intent.crouching) {
148
+ motion.verticalVelocity = MOVEMENT_TUNING.jumpVelocity;
149
+ motion.grounded = false;
150
+ }
151
+ motion.jumpHeld = jumpPressed;
152
+ if (!motion.grounded || motion.verticalVelocity > 0) {
153
+ motion.verticalVelocity -= MOVEMENT_TUNING.gravityAcceleration * deltaSeconds;
154
+ motion.jumpOffset += motion.verticalVelocity * deltaSeconds;
155
+ if (motion.jumpOffset <= 0) {
156
+ motion.jumpOffset = 0;
157
+ motion.verticalVelocity = 0;
158
+ motion.grounded = true;
159
+ }
160
+ }
161
+ return {
162
+ stepX: motion.horizontalVelocityX * deltaSeconds,
163
+ stepZ: motion.horizontalVelocityZ * deltaSeconds,
164
+ };
165
+ }
166
+ /** Peak jump height from MOVEMENT_TUNING.jumpVelocity + gravity, with small buffer. */
167
+ export const MAX_JUMP_OFFSET = 1.15;
168
+ /** Camera yaw looks along -Z; character mesh faces +Z at body rotation.y = 0. */
169
+ export function cameraYawToAvatarBodyYaw(cameraYaw) {
170
+ return cameraYaw + Math.PI;
171
+ }