@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.
- package/CHANGELOG.md +47 -0
- package/README.md +2 -0
- package/dist/ai/spawnDirector.d.ts +5 -0
- package/dist/ai/spawnDirector.js +13 -1
- package/dist/anim/easing.d.ts +14 -0
- package/dist/anim/easing.js +55 -0
- package/dist/behaviour/behaviour.d.ts +136 -0
- package/dist/behaviour/behaviour.js +388 -0
- package/dist/cards/cardPile.d.ts +1 -0
- package/dist/cards/cardPile.js +12 -3
- package/dist/combat/abilityKit.d.ts +5 -0
- package/dist/combat/abilityKit.js +10 -0
- package/dist/combat/effects.d.ts +1 -1
- package/dist/combat/effects.js +9 -6
- package/dist/combat/projectiles.d.ts +42 -5
- package/dist/combat/projectiles.js +98 -12
- package/dist/combat/telegraph.d.ts +5 -1
- package/dist/combat/telegraph.js +14 -0
- package/dist/commands/commandRegistry.d.ts +1 -1
- package/dist/commands/commandRegistry.js +2 -1
- package/dist/data/dataSource.d.ts +26 -0
- package/dist/data/dataSource.js +93 -0
- package/dist/data/devProxy.d.ts +11 -0
- package/dist/data/devProxy.js +38 -0
- package/dist/data/fetchJson.d.ts +20 -0
- package/dist/data/fetchJson.js +43 -0
- package/dist/data/jsonDataSource.d.ts +4 -0
- package/dist/data/jsonDataSource.js +6 -0
- package/dist/devtools/devtools.d.ts +157 -0
- package/dist/devtools/devtools.js +484 -0
- package/dist/devtools/transformTunables.d.ts +14 -0
- package/dist/devtools/transformTunables.js +38 -0
- package/dist/game/chat.d.ts +66 -0
- package/dist/game/chat.js +165 -0
- package/dist/game/defineGame.d.ts +8 -2
- package/dist/game/defineGame.js +2 -1
- package/dist/game/events.d.ts +25 -0
- package/dist/game/levelSequence.d.ts +44 -0
- package/dist/game/levelSequence.js +78 -0
- package/dist/game/playableGame.d.ts +237 -6
- package/dist/game/playableGame.js +6 -1
- package/dist/game/progression.d.ts +1 -0
- package/dist/game/progression.js +23 -2
- package/dist/game/race.d.ts +5 -0
- package/dist/game/race.js +20 -0
- package/dist/game/social.d.ts +42 -0
- package/dist/game/social.js +122 -0
- package/dist/game/spawnPoints.d.ts +19 -0
- package/dist/game/spawnPoints.js +23 -0
- package/dist/game/worldItem.d.ts +3 -0
- package/dist/game/worldItem.js +12 -6
- package/dist/input/actionBindings.d.ts +10 -0
- package/dist/input/actionBindings.js +14 -0
- package/dist/input/gestureSurface.d.ts +31 -0
- package/dist/input/gestureSurface.js +120 -0
- package/dist/input/pointer.d.ts +45 -0
- package/dist/input/pointer.js +48 -0
- package/dist/input/touchScheme.d.ts +83 -0
- package/dist/input/touchScheme.js +115 -0
- package/dist/item/affix.d.ts +1 -1
- package/dist/item/affix.js +1 -19
- package/dist/meta/changelog.d.ts +1 -1
- package/dist/meta/changelog.js +37 -1
- package/dist/movement/movementModel.d.ts +36 -3
- package/dist/movement/movementModel.js +81 -5
- package/dist/movement/voxelController.d.ts +61 -0
- package/dist/movement/voxelController.js +229 -0
- package/dist/multiplayer/chatContract.d.ts +42 -0
- package/dist/multiplayer/chatContract.js +57 -0
- package/dist/multiplayer/identity.d.ts +13 -0
- package/dist/multiplayer/identity.js +20 -0
- package/dist/multiplayer/poseSyncGate.d.ts +1 -0
- package/dist/multiplayer/poseSyncGate.js +13 -1
- package/dist/multiplayer/presenceModel.d.ts +3 -0
- package/dist/multiplayer/presenceModel.js +15 -1
- package/dist/multiplayer/voiceContract.d.ts +44 -0
- package/dist/multiplayer/voiceContract.js +108 -0
- package/dist/nav/navConstrain.d.ts +14 -0
- package/dist/nav/navConstrain.js +26 -0
- package/dist/nav/navFromEnvironment.d.ts +11 -0
- package/dist/nav/navFromEnvironment.js +16 -0
- package/dist/nav/navGrid.d.ts +10 -0
- package/dist/nav/navGrid.js +18 -1
- package/dist/nav/pathFollow.d.ts +4 -2
- package/dist/nav/pathFollow.js +5 -3
- package/dist/physics/ballisticSweep.d.ts +18 -0
- package/dist/physics/ballisticSweep.js +49 -0
- package/dist/physics/forceVolume.js +5 -5
- package/dist/physics/physicsWorld.d.ts +41 -2
- package/dist/physics/physicsWorld.js +232 -40
- package/dist/physics/spatialGrid.d.ts +1 -1
- package/dist/physics/spatialGrid.js +5 -0
- package/dist/physics/vehicleBody.d.ts +6 -0
- package/dist/physics/vehicleBody.js +16 -17
- package/dist/puzzle/cellGrid.d.ts +26 -0
- package/dist/puzzle/cellGrid.js +110 -0
- package/dist/puzzle/fallingPiece.d.ts +24 -0
- package/dist/puzzle/fallingPiece.js +51 -0
- package/dist/random/nameGen.d.ts +18 -0
- package/dist/random/nameGen.js +35 -0
- package/dist/random/rng.d.ts +2 -0
- package/dist/random/rng.js +23 -0
- package/dist/runtime/adapter.d.ts +35 -0
- package/dist/runtime/adapter.js +37 -1
- package/dist/runtime/cameraDirector.d.ts +10 -0
- package/dist/runtime/cameraDirector.js +25 -0
- package/dist/runtime/gameContext.d.ts +44 -2
- package/dist/runtime/gameContext.js +100 -4
- package/dist/runtime/hostPersistence.d.ts +21 -0
- package/dist/runtime/hostPersistence.js +50 -0
- package/dist/runtime/inputSnapshot.d.ts +7 -0
- package/dist/runtime/inputSnapshot.js +12 -0
- package/dist/runtime/motionIntents.d.ts +25 -0
- package/dist/runtime/motionIntents.js +25 -0
- package/dist/runtime/transport.d.ts +32 -0
- package/dist/scene/entityStore.d.ts +8 -0
- package/dist/scene/entityStore.js +25 -2
- package/dist/scene/movementSpeed.d.ts +35 -0
- package/dist/scene/movementSpeed.js +20 -0
- package/dist/scene/objectQuery.d.ts +18 -0
- package/dist/scene/objectQuery.js +108 -0
- package/dist/scene/objectStore.d.ts +11 -0
- package/dist/scene/objectStore.js +109 -4
- package/dist/scene/paintLayer.d.ts +15 -0
- package/dist/scene/paintLayer.js +53 -0
- package/dist/scene/spatial.d.ts +21 -0
- package/dist/scene/spatial.js +63 -1
- package/dist/sensor/concealment.d.ts +21 -0
- package/dist/sensor/concealment.js +63 -0
- package/dist/sensor/freezeMonitor.d.ts +17 -0
- package/dist/sensor/freezeMonitor.js +35 -0
- package/dist/session/roles.d.ts +6 -0
- package/dist/session/roles.js +32 -0
- package/dist/session/roundState.d.ts +41 -17
- package/dist/session/roundState.js +67 -38
- package/dist/tactics/fallingGrid.d.ts +62 -0
- package/dist/tactics/fallingGrid.js +158 -0
- package/dist/tactics/tacticalGrid.d.ts +11 -0
- package/dist/tactics/tacticalGrid.js +17 -0
- package/dist/time/simClock.d.ts +25 -1
- package/dist/time/simClock.js +28 -3
- package/dist/turn/intent.d.ts +14 -0
- package/dist/turn/intent.js +29 -0
- package/dist/turn/turnLoop.d.ts +2 -0
- package/dist/turn/turnLoop.js +8 -1
- package/dist/world/environmentSummary.d.ts +53 -0
- package/dist/world/environmentSummary.js +129 -0
- package/dist/world/features.d.ts +87 -6
- package/dist/world/features.js +52 -2
- package/dist/world/gridInstances.d.ts +13 -0
- package/dist/world/gridInstances.js +22 -0
- package/dist/world/placementController.d.ts +22 -1
- package/dist/world/placementController.js +56 -0
- package/dist/world/terrain.d.ts +23 -1
- package/dist/world/terrain.js +64 -1
- package/dist/world/voxelField.d.ts +54 -0
- package/dist/world/voxelField.js +241 -0
- package/llms.txt +4102 -0
- package/package.json +3 -2
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voxel character controller (pure model).
|
|
3
|
+
*
|
|
4
|
+
* The base first-person controller in `movementModel.ts` assumes flat ground at
|
|
5
|
+
* y=0 — it integrates gravity into a `jumpOffset` and never asks the world how
|
|
6
|
+
* tall the ground is. That is fine for a plaza demo but wrong for any game where
|
|
7
|
+
* the player walks on placed blocks and digs down into the world: they float on
|
|
8
|
+
* an invisible floor, clip through walls, and can never descend.
|
|
9
|
+
*
|
|
10
|
+
* This module resolves the player as an axis-aligned box against a set of solid
|
|
11
|
+
* unit cells (a `SolidQuery`), so the same held-keys → intent pipeline drives a
|
|
12
|
+
* body that stands on blocks, falls into holes, is stopped by walls, and steps
|
|
13
|
+
* up single-block ledges. Keeping it renderer-free means the shell can drive it
|
|
14
|
+
* from live scene objects while the descent/landing/step-up behaviour stays unit
|
|
15
|
+
* testable without three.js.
|
|
16
|
+
*
|
|
17
|
+
* Cell convention matches the shell's object rendering and the voxel raycast:
|
|
18
|
+
* cell (x,y,z) is the solid box X∈[x-0.5,x+0.5], Y∈[y,y+1], Z∈[z-0.5,z+0.5]. A
|
|
19
|
+
* block at cell y therefore has its top face at world y+1, so a player standing
|
|
20
|
+
* on the surface layer (cell y=-1) rests with their feet at y=0.
|
|
21
|
+
*/
|
|
22
|
+
import { MOVEMENT_TUNING } from "./movementModel.js";
|
|
23
|
+
export const DEFAULT_VOXEL_DIMS = {
|
|
24
|
+
halfWidth: 0.3,
|
|
25
|
+
height: 1.8,
|
|
26
|
+
stepHeight: 0.6,
|
|
27
|
+
};
|
|
28
|
+
export function createVoxelPlayerBody(x, y, z) {
|
|
29
|
+
return { x, y, z, velocityX: 0, velocityY: 0, velocityZ: 0, grounded: true, jumpHeld: false };
|
|
30
|
+
}
|
|
31
|
+
const SUPPORT_EPSILON = 0.02;
|
|
32
|
+
const PENETRATION_EPSILON = 1e-4;
|
|
33
|
+
function cellX(worldX) {
|
|
34
|
+
return Math.floor(worldX + 0.5);
|
|
35
|
+
}
|
|
36
|
+
function cellZ(worldZ) {
|
|
37
|
+
return Math.floor(worldZ + 0.5);
|
|
38
|
+
}
|
|
39
|
+
/** True if the player box centred at (x, y, z) overlaps any solid cell. */
|
|
40
|
+
function overlapsSolid(x, y, z, isSolid, dims) {
|
|
41
|
+
const minCellX = cellX(x - dims.halfWidth);
|
|
42
|
+
const maxCellX = cellX(x + dims.halfWidth);
|
|
43
|
+
const minCellZ = cellZ(z - dims.halfWidth);
|
|
44
|
+
const maxCellZ = cellZ(z + dims.halfWidth);
|
|
45
|
+
const minCellY = Math.floor(y + PENETRATION_EPSILON);
|
|
46
|
+
const maxCellY = Math.floor(y + dims.height - PENETRATION_EPSILON);
|
|
47
|
+
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
|
|
48
|
+
for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
|
|
49
|
+
for (let cy = minCellY; cy <= maxCellY; cy += 1) {
|
|
50
|
+
if (isSolid(cx, cy, cz))
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
/** True if a solid cell sits directly beneath the feet, or terrain supports them (i.e. the player is supported). */
|
|
58
|
+
function hasSupport(x, z, y, isSolid, dims, groundHeight) {
|
|
59
|
+
const cy = Math.floor(y - SUPPORT_EPSILON);
|
|
60
|
+
const minCellX = cellX(x - dims.halfWidth);
|
|
61
|
+
const maxCellX = cellX(x + dims.halfWidth);
|
|
62
|
+
const minCellZ = cellZ(z - dims.halfWidth);
|
|
63
|
+
const maxCellZ = cellZ(z + dims.halfWidth);
|
|
64
|
+
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
|
|
65
|
+
for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
|
|
66
|
+
if (isSolid(cx, cy, cz))
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (groundHeight !== undefined && y <= groundHeight(x, z) + SUPPORT_EPSILON)
|
|
71
|
+
return true;
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Highest solid-cell top under the footprint at (x, z) within the vertical
|
|
76
|
+
* window [fromY, toY]. Returns null when nothing supports the feet there — used
|
|
77
|
+
* both to snap a fall onto a block and to raise a step-up.
|
|
78
|
+
*/
|
|
79
|
+
function supportTopWithin(x, z, fromY, toY, isSolid, dims) {
|
|
80
|
+
const minCellX = cellX(x - dims.halfWidth);
|
|
81
|
+
const maxCellX = cellX(x + dims.halfWidth);
|
|
82
|
+
const minCellZ = cellZ(z - dims.halfWidth);
|
|
83
|
+
const maxCellZ = cellZ(z + dims.halfWidth);
|
|
84
|
+
const lowCellY = Math.floor(fromY - PENETRATION_EPSILON);
|
|
85
|
+
const highCellY = Math.floor(toY - PENETRATION_EPSILON);
|
|
86
|
+
let best = null;
|
|
87
|
+
for (let cx = minCellX; cx <= maxCellX; cx += 1) {
|
|
88
|
+
for (let cz = minCellZ; cz <= maxCellZ; cz += 1) {
|
|
89
|
+
for (let cy = lowCellY; cy <= highCellY; cy += 1) {
|
|
90
|
+
if (!isSolid(cx, cy, cz))
|
|
91
|
+
continue;
|
|
92
|
+
const top = cy + 1;
|
|
93
|
+
if (best === null || top > best)
|
|
94
|
+
best = top;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return best;
|
|
99
|
+
}
|
|
100
|
+
function targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed) {
|
|
101
|
+
if (!intent.moving)
|
|
102
|
+
return { x: 0, z: 0 };
|
|
103
|
+
let fx = forwardX;
|
|
104
|
+
let fz = forwardZ;
|
|
105
|
+
const lengthSq = fx * fx + fz * fz;
|
|
106
|
+
if (lengthSq < 1e-6) {
|
|
107
|
+
fx = 0;
|
|
108
|
+
fz = -1;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
const inv = 1 / Math.sqrt(lengthSq);
|
|
112
|
+
fx *= inv;
|
|
113
|
+
fz *= inv;
|
|
114
|
+
}
|
|
115
|
+
const rightX = -fz;
|
|
116
|
+
const rightZ = fx;
|
|
117
|
+
let vx = fx * intent.forward + rightX * intent.right;
|
|
118
|
+
let vz = fz * intent.forward + rightZ * intent.right;
|
|
119
|
+
const magSq = vx * vx + vz * vz;
|
|
120
|
+
if (magSq < 1e-6)
|
|
121
|
+
return { x: 0, z: 0 };
|
|
122
|
+
const speedMultiplier = intent.crouching
|
|
123
|
+
? MOVEMENT_TUNING.crouchSpeedMultiplier
|
|
124
|
+
: intent.running
|
|
125
|
+
? MOVEMENT_TUNING.runSpeedMultiplier
|
|
126
|
+
: 1;
|
|
127
|
+
const targetSpeed = baseSpeed * MOVEMENT_TUNING.walkSpeedMultiplier * speedMultiplier;
|
|
128
|
+
const scale = targetSpeed / Math.sqrt(magSq);
|
|
129
|
+
vx *= scale;
|
|
130
|
+
vz *= scale;
|
|
131
|
+
return { x: vx, z: vz };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Move the body along one horizontal axis, resolving collisions. Returns the
|
|
135
|
+
* component of the step that survived (0 when a wall stopped the move). When the
|
|
136
|
+
* move is blocked but the obstacle is a short ledge, the body steps up onto it.
|
|
137
|
+
*/
|
|
138
|
+
function moveAxis(body, axis, delta, isSolid, dims) {
|
|
139
|
+
if (delta === 0)
|
|
140
|
+
return 0;
|
|
141
|
+
const nextX = axis === "x" ? body.x + delta : body.x;
|
|
142
|
+
const nextZ = axis === "z" ? body.z + delta : body.z;
|
|
143
|
+
if (!overlapsSolid(nextX, body.y, nextZ, isSolid, dims)) {
|
|
144
|
+
body.x = nextX;
|
|
145
|
+
body.z = nextZ;
|
|
146
|
+
return delta;
|
|
147
|
+
}
|
|
148
|
+
if (body.grounded) {
|
|
149
|
+
const raised = supportTopWithin(nextX, nextZ, body.y, body.y + dims.stepHeight, isSolid, dims);
|
|
150
|
+
if (raised !== null &&
|
|
151
|
+
raised - body.y <= dims.stepHeight + PENETRATION_EPSILON &&
|
|
152
|
+
raised >= body.y &&
|
|
153
|
+
!overlapsSolid(nextX, raised, nextZ, isSolid, dims)) {
|
|
154
|
+
body.x = nextX;
|
|
155
|
+
body.z = nextZ;
|
|
156
|
+
body.y = raised;
|
|
157
|
+
return delta;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Advance one frame of voxel-collided player kinematics. Mutates `body`
|
|
164
|
+
* (position, velocity, grounded) in place.
|
|
165
|
+
*
|
|
166
|
+
* `forwardX`/`forwardZ` are the camera heading projected onto the ground plane
|
|
167
|
+
* (need not be normalized). Pulling this out of the render loop is what lets us
|
|
168
|
+
* assert "dig the block underfoot and the player drops a level" in a unit test.
|
|
169
|
+
*
|
|
170
|
+
* `groundHeight`, when supplied, is an additional solid floor sampled at the
|
|
171
|
+
* body's (x, z): the player never sinks below it and standing on it counts as
|
|
172
|
+
* supported (resets jump state like landing on a solid cell). Where terrain and
|
|
173
|
+
* a solid cell's top disagree, the higher of the two wins.
|
|
174
|
+
*/
|
|
175
|
+
export function advanceVoxelPlayer(body, intent, forwardX, forwardZ, baseSpeed, rawDeltaSeconds, isSolid, dims = DEFAULT_VOXEL_DIMS, tuning, groundHeight) {
|
|
176
|
+
const dt = Math.min(rawDeltaSeconds, MOVEMENT_TUNING.maxFrameSeconds);
|
|
177
|
+
const gravityAcceleration = tuning?.gravityAcceleration ?? MOVEMENT_TUNING.gravityAcceleration;
|
|
178
|
+
const jumpVelocity = tuning?.jumpVelocity ?? MOVEMENT_TUNING.jumpVelocity;
|
|
179
|
+
const target = targetHorizontalVelocity(intent, forwardX, forwardZ, baseSpeed);
|
|
180
|
+
const acceleration = body.grounded
|
|
181
|
+
? MOVEMENT_TUNING.groundAcceleration
|
|
182
|
+
: MOVEMENT_TUNING.airAcceleration;
|
|
183
|
+
const accelerationBlend = 1 - Math.exp(-acceleration * dt);
|
|
184
|
+
body.velocityX += (target.x - body.velocityX) * accelerationBlend;
|
|
185
|
+
body.velocityZ += (target.z - body.velocityZ) * accelerationBlend;
|
|
186
|
+
if (!intent.moving && body.grounded) {
|
|
187
|
+
const friction = Math.exp(-MOVEMENT_TUNING.groundFriction * dt);
|
|
188
|
+
body.velocityX *= friction;
|
|
189
|
+
body.velocityZ *= friction;
|
|
190
|
+
}
|
|
191
|
+
if (moveAxis(body, "x", body.velocityX * dt, isSolid, dims) === 0)
|
|
192
|
+
body.velocityX = 0;
|
|
193
|
+
if (moveAxis(body, "z", body.velocityZ * dt, isSolid, dims) === 0)
|
|
194
|
+
body.velocityZ = 0;
|
|
195
|
+
const supported = body.grounded && hasSupport(body.x, body.z, body.y, isSolid, dims, groundHeight);
|
|
196
|
+
body.grounded = supported;
|
|
197
|
+
const jumpPressed = intent.jumping;
|
|
198
|
+
if (jumpPressed && !body.jumpHeld && body.grounded && !intent.crouching) {
|
|
199
|
+
body.velocityY = jumpVelocity;
|
|
200
|
+
body.grounded = false;
|
|
201
|
+
}
|
|
202
|
+
body.jumpHeld = jumpPressed;
|
|
203
|
+
if (!body.grounded) {
|
|
204
|
+
body.velocityY -= gravityAcceleration * dt;
|
|
205
|
+
const nextY = body.y + body.velocityY * dt;
|
|
206
|
+
if (body.velocityY <= 0) {
|
|
207
|
+
const terrainFloor = groundHeight?.(body.x, body.z);
|
|
208
|
+
const solidBlocked = overlapsSolid(body.x, nextY, body.z, isSolid, dims);
|
|
209
|
+
const terrainBlocked = terrainFloor !== undefined && nextY <= terrainFloor;
|
|
210
|
+
if (solidBlocked || terrainBlocked) {
|
|
211
|
+
const solidTop = solidBlocked
|
|
212
|
+
? (supportTopWithin(body.x, body.z, nextY, body.y + dims.height, isSolid, dims) ?? Math.floor(body.y))
|
|
213
|
+
: undefined;
|
|
214
|
+
body.y = Math.max(solidTop ?? -Infinity, terrainFloor ?? -Infinity);
|
|
215
|
+
body.velocityY = 0;
|
|
216
|
+
body.grounded = true;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
body.y = nextY;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (overlapsSolid(body.x, nextY, body.z, isSolid, dims)) {
|
|
223
|
+
body.velocityY = 0;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
body.y = nextY;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ChatMessage } from "../game/chat.js";
|
|
2
|
+
export interface ChatSendArgs {
|
|
3
|
+
channelId: string;
|
|
4
|
+
body: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ChatSendOutcome {
|
|
7
|
+
ok: boolean;
|
|
8
|
+
reason?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ChatActions {
|
|
11
|
+
sendMessage(args: ChatSendArgs): Promise<ChatSendOutcome>;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Backend seam for remote text chat, mirroring PresenceTransport: the use*
|
|
15
|
+
* members are called as React hooks by consumers, so a mounted transport must
|
|
16
|
+
* never change identity — remount the subtree to switch backends. useMessages
|
|
17
|
+
* returns undefined while the subscription is loading and the channel's recent
|
|
18
|
+
* history once live.
|
|
19
|
+
*/
|
|
20
|
+
export interface ChatTransport {
|
|
21
|
+
useMessages(channelId: string | "skip"): readonly ChatMessage[] | undefined;
|
|
22
|
+
useActions(): ChatActions;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Callback seam for backends that cannot host React hooks (e.g. the ws
|
|
26
|
+
* client): subscribe delivers the channel's recent history on every change;
|
|
27
|
+
* send resolves with the host's verdict. @jgengine/react's chatTransportFromSync
|
|
28
|
+
* lifts a ChatSync into a ChatTransport.
|
|
29
|
+
*/
|
|
30
|
+
export interface ChatSync {
|
|
31
|
+
subscribe(channelId: string, onChange: (messages: readonly ChatMessage[]) => void): () => void;
|
|
32
|
+
send(channelId: string, body: string): Promise<ChatSendOutcome>;
|
|
33
|
+
}
|
|
34
|
+
export declare function createLocalChatTransport(options?: {
|
|
35
|
+
userId?: string;
|
|
36
|
+
historyLimit?: number;
|
|
37
|
+
now?: () => number;
|
|
38
|
+
}): {
|
|
39
|
+
transport: ChatTransport;
|
|
40
|
+
sync: ChatSync;
|
|
41
|
+
actions: ChatActions;
|
|
42
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function createLocalChatTransport(options) {
|
|
2
|
+
const userId = options?.userId ?? "local";
|
|
3
|
+
const historyLimit = options?.historyLimit ?? 100;
|
|
4
|
+
const now = options?.now ?? Date.now;
|
|
5
|
+
const messagesByChannel = new Map();
|
|
6
|
+
const listeners = new Map();
|
|
7
|
+
let counter = 0;
|
|
8
|
+
function snapshot(channelId) {
|
|
9
|
+
return (messagesByChannel.get(channelId) ?? []).slice();
|
|
10
|
+
}
|
|
11
|
+
const sync = {
|
|
12
|
+
subscribe(channelId, onChange) {
|
|
13
|
+
let set = listeners.get(channelId);
|
|
14
|
+
if (set === undefined) {
|
|
15
|
+
set = new Set();
|
|
16
|
+
listeners.set(channelId, set);
|
|
17
|
+
}
|
|
18
|
+
set.add(onChange);
|
|
19
|
+
onChange(snapshot(channelId));
|
|
20
|
+
return () => {
|
|
21
|
+
set.delete(onChange);
|
|
22
|
+
if (set.size === 0)
|
|
23
|
+
listeners.delete(channelId);
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
send(channelId, body) {
|
|
27
|
+
const trimmed = body.trim();
|
|
28
|
+
if (trimmed.length === 0)
|
|
29
|
+
return Promise.resolve({ ok: false, reason: "empty message" });
|
|
30
|
+
counter += 1;
|
|
31
|
+
const message = {
|
|
32
|
+
id: `local_${counter}`,
|
|
33
|
+
channelId,
|
|
34
|
+
fromUserId: userId,
|
|
35
|
+
body: trimmed,
|
|
36
|
+
at: now(),
|
|
37
|
+
};
|
|
38
|
+
const ring = messagesByChannel.get(channelId) ?? [];
|
|
39
|
+
ring.push(message);
|
|
40
|
+
if (ring.length > historyLimit)
|
|
41
|
+
ring.splice(0, ring.length - historyLimit);
|
|
42
|
+
messagesByChannel.set(channelId, ring);
|
|
43
|
+
const current = snapshot(channelId);
|
|
44
|
+
for (const listener of listeners.get(channelId) ?? [])
|
|
45
|
+
listener(current);
|
|
46
|
+
return Promise.resolve({ ok: true });
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
const actions = {
|
|
50
|
+
sendMessage: (args) => sync.send(args.channelId, args.body),
|
|
51
|
+
};
|
|
52
|
+
const transport = {
|
|
53
|
+
useMessages: (channelId) => (channelId === "skip" ? undefined : snapshot(channelId)),
|
|
54
|
+
useActions: () => actions,
|
|
55
|
+
};
|
|
56
|
+
return { transport, sync, actions };
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface AuthSession {
|
|
2
|
+
userId: string;
|
|
3
|
+
displayName?: string;
|
|
4
|
+
avatarUrl?: string;
|
|
5
|
+
email?: string;
|
|
6
|
+
isNew?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface PlayerIdentity {
|
|
9
|
+
userId: string;
|
|
10
|
+
isNew: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function sessionPlayer(session: AuthSession): PlayerIdentity;
|
|
13
|
+
export declare function resolveGuestSession(seed?: string): AuthSession;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function sessionPlayer(session) {
|
|
2
|
+
return { userId: session.userId, isNew: session.isNew ?? false };
|
|
3
|
+
}
|
|
4
|
+
const DEFAULT_GUEST_SEED = "local";
|
|
5
|
+
export function resolveGuestSession(seed) {
|
|
6
|
+
const normalized = seed === undefined || seed.trim().length === 0 ? DEFAULT_GUEST_SEED : seed.trim();
|
|
7
|
+
const hash = fnv1a(normalized);
|
|
8
|
+
return {
|
|
9
|
+
userId: `guest_${hash}`,
|
|
10
|
+
displayName: `Guest ${hash.slice(0, 4).toUpperCase()}`,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function fnv1a(input) {
|
|
14
|
+
let hash = 0x811c9dc5;
|
|
15
|
+
for (let index = 0; index < input.length; index += 1) {
|
|
16
|
+
hash ^= input.charCodeAt(index);
|
|
17
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
18
|
+
}
|
|
19
|
+
return hash.toString(36);
|
|
20
|
+
}
|
|
@@ -1,9 +1,21 @@
|
|
|
1
|
+
function appearanceChanged(a, b) {
|
|
2
|
+
if (a === b)
|
|
3
|
+
return false;
|
|
4
|
+
if (a === undefined || b === undefined)
|
|
5
|
+
return true;
|
|
6
|
+
const aKeys = Object.keys(a);
|
|
7
|
+
const bKeys = Object.keys(b);
|
|
8
|
+
if (aKeys.length !== bKeys.length)
|
|
9
|
+
return true;
|
|
10
|
+
return aKeys.some((key) => a[key] !== b[key]);
|
|
11
|
+
}
|
|
1
12
|
function poseChanged(a, b, tuning) {
|
|
2
13
|
return (Math.abs(a.x - b.x) > tuning.positionEpsilon
|
|
3
14
|
|| Math.abs(a.y - b.y) > tuning.verticalEpsilon
|
|
4
15
|
|| Math.abs(a.z - b.z) > tuning.positionEpsilon
|
|
5
16
|
|| Math.abs(a.rotationY - b.rotationY) > tuning.rotationEpsilon
|
|
6
|
-
|| Math.abs(a.rotationPitch - b.rotationPitch) > tuning.rotationEpsilon
|
|
17
|
+
|| Math.abs(a.rotationPitch - b.rotationPitch) > tuning.rotationEpsilon
|
|
18
|
+
|| appearanceChanged(a.appearance, b.appearance));
|
|
7
19
|
}
|
|
8
20
|
export function createPoseSyncGate(tuning) {
|
|
9
21
|
let lastSentPose = null;
|
|
@@ -7,6 +7,7 @@ export interface PresencePoseState {
|
|
|
7
7
|
rotationY: number;
|
|
8
8
|
rotationPitch?: number;
|
|
9
9
|
lastSeenAtMs?: number;
|
|
10
|
+
appearance?: Record<string, string>;
|
|
10
11
|
}
|
|
11
12
|
export interface IncomingPose {
|
|
12
13
|
position: {
|
|
@@ -16,6 +17,7 @@ export interface IncomingPose {
|
|
|
16
17
|
};
|
|
17
18
|
rotationY?: number;
|
|
18
19
|
rotationPitch?: number;
|
|
20
|
+
appearance?: Record<string, string>;
|
|
19
21
|
}
|
|
20
22
|
export interface PoseSyncRules {
|
|
21
23
|
/** Speed cap (units/sec) for client-authoritative movement. */
|
|
@@ -36,6 +38,7 @@ export interface PoseSyncDecision {
|
|
|
36
38
|
};
|
|
37
39
|
rotationY: number;
|
|
38
40
|
rotationPitch: number;
|
|
41
|
+
appearance?: Record<string, string>;
|
|
39
42
|
/** True when the pose differs and a pose write is needed. */
|
|
40
43
|
changed: boolean;
|
|
41
44
|
/** True when only the keep-alive stamp should be written. */
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
function appearanceEqual(a, b) {
|
|
2
|
+
if (a === b)
|
|
3
|
+
return true;
|
|
4
|
+
if (a === undefined || b === undefined)
|
|
5
|
+
return false;
|
|
6
|
+
const aKeys = Object.keys(a);
|
|
7
|
+
const bKeys = Object.keys(b);
|
|
8
|
+
if (aKeys.length !== bKeys.length)
|
|
9
|
+
return false;
|
|
10
|
+
return aKeys.every((key) => a[key] === b[key]);
|
|
11
|
+
}
|
|
1
12
|
export function decidePoseSync(current, incoming, rules, nowMs) {
|
|
2
13
|
const elapsedSec = Math.max(rules.minElapsedSec, Math.min(rules.maxElapsedSec, (nowMs - (current.lastSeenAtMs ?? nowMs)) / 1000));
|
|
3
14
|
const maxDist = rules.maxSpeed * elapsedSec;
|
|
@@ -17,14 +28,17 @@ export function decidePoseSync(current, incoming, rules, nowMs) {
|
|
|
17
28
|
: Math.max(0, Math.min(rules.maxVerticalOffset, incoming.position.y));
|
|
18
29
|
const nextRotationY = incoming.rotationY ?? current.rotationY;
|
|
19
30
|
const nextRotationPitch = incoming.rotationPitch ?? current.rotationPitch ?? 0;
|
|
31
|
+
const nextAppearance = incoming.appearance ?? current.appearance;
|
|
32
|
+
const appearanceChanged = incoming.appearance !== undefined && !appearanceEqual(incoming.appearance, current.appearance);
|
|
20
33
|
const moved = targetX !== current.position.x || targetZ !== current.position.z;
|
|
21
34
|
const jumped = nextY !== current.position.y;
|
|
22
35
|
const rotated = nextRotationY !== current.rotationY || nextRotationPitch !== (current.rotationPitch ?? 0);
|
|
23
|
-
const changed = moved || jumped || rotated;
|
|
36
|
+
const changed = moved || jumped || rotated || appearanceChanged;
|
|
24
37
|
return {
|
|
25
38
|
position: { x: targetX, y: nextY, z: targetZ },
|
|
26
39
|
rotationY: nextRotationY,
|
|
27
40
|
rotationPitch: nextRotationPitch,
|
|
41
|
+
appearance: nextAppearance,
|
|
28
42
|
changed,
|
|
29
43
|
refreshKeepAlive: !changed && shouldRefreshKeepAlive(current.lastSeenAtMs, nowMs, rules),
|
|
30
44
|
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export interface VoiceParticipant {
|
|
2
|
+
userId: string;
|
|
3
|
+
streamId?: string;
|
|
4
|
+
}
|
|
5
|
+
export interface VoiceRoute {
|
|
6
|
+
fromUserId: string;
|
|
7
|
+
channelId: string;
|
|
8
|
+
gain: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Signaling seam for voice: who is in a channel and which media stream
|
|
12
|
+
* descriptor they published. The media plane (WebRTC, SFU, or anything else
|
|
13
|
+
* that moves audio bytes) stays behind this seam, host-supplied — the engine
|
|
14
|
+
* never touches it. subscribers delivers the channel roster on every change,
|
|
15
|
+
* starting with the current roster.
|
|
16
|
+
*/
|
|
17
|
+
export interface VoiceTransport {
|
|
18
|
+
join(channelId: string, streamId?: string): Promise<void>;
|
|
19
|
+
leave(channelId: string): Promise<void>;
|
|
20
|
+
publish(channelId: string, streamId: string): Promise<void>;
|
|
21
|
+
subscribers(channelId: string, onChange: (participants: readonly VoiceParticipant[]) => void): () => void;
|
|
22
|
+
}
|
|
23
|
+
export declare function createLocalVoiceTransport(options?: {
|
|
24
|
+
userId?: string;
|
|
25
|
+
}): {
|
|
26
|
+
transport: VoiceTransport;
|
|
27
|
+
participants(channelId: string): readonly VoiceParticipant[];
|
|
28
|
+
};
|
|
29
|
+
export type PushToTalkMode = "hold" | "toggle" | "openMic";
|
|
30
|
+
export type PushToTalkStatus = "idle" | "keyed" | "open";
|
|
31
|
+
export interface PushToTalk {
|
|
32
|
+
mode(): PushToTalkMode;
|
|
33
|
+
setMode(mode: PushToTalkMode): void;
|
|
34
|
+
keyDown(): void;
|
|
35
|
+
keyUp(): void;
|
|
36
|
+
muted(): boolean;
|
|
37
|
+
setMuted(muted: boolean): void;
|
|
38
|
+
status(): PushToTalkStatus;
|
|
39
|
+
transmitting(): boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function createPushToTalk(config?: {
|
|
42
|
+
mode?: PushToTalkMode;
|
|
43
|
+
onChange?: (transmitting: boolean) => void;
|
|
44
|
+
}): PushToTalk;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
export function createLocalVoiceTransport(options) {
|
|
2
|
+
const userId = options?.userId ?? "local";
|
|
3
|
+
const rosters = new Map();
|
|
4
|
+
const listeners = new Map();
|
|
5
|
+
function roster(channelId) {
|
|
6
|
+
let entries = rosters.get(channelId);
|
|
7
|
+
if (entries === undefined) {
|
|
8
|
+
entries = new Map();
|
|
9
|
+
rosters.set(channelId, entries);
|
|
10
|
+
}
|
|
11
|
+
return entries;
|
|
12
|
+
}
|
|
13
|
+
function snapshot(channelId) {
|
|
14
|
+
return [...roster(channelId).values()].map((participant) => ({ ...participant }));
|
|
15
|
+
}
|
|
16
|
+
function notify(channelId) {
|
|
17
|
+
const current = snapshot(channelId);
|
|
18
|
+
for (const listener of listeners.get(channelId) ?? [])
|
|
19
|
+
listener(current);
|
|
20
|
+
}
|
|
21
|
+
const transport = {
|
|
22
|
+
join(channelId, streamId) {
|
|
23
|
+
const participant = { userId };
|
|
24
|
+
if (streamId !== undefined)
|
|
25
|
+
participant.streamId = streamId;
|
|
26
|
+
roster(channelId).set(userId, participant);
|
|
27
|
+
notify(channelId);
|
|
28
|
+
return Promise.resolve();
|
|
29
|
+
},
|
|
30
|
+
leave(channelId) {
|
|
31
|
+
if (roster(channelId).delete(userId))
|
|
32
|
+
notify(channelId);
|
|
33
|
+
return Promise.resolve();
|
|
34
|
+
},
|
|
35
|
+
publish(channelId, streamId) {
|
|
36
|
+
const participant = roster(channelId).get(userId);
|
|
37
|
+
if (participant !== undefined) {
|
|
38
|
+
participant.streamId = streamId;
|
|
39
|
+
notify(channelId);
|
|
40
|
+
}
|
|
41
|
+
return Promise.resolve();
|
|
42
|
+
},
|
|
43
|
+
subscribers(channelId, onChange) {
|
|
44
|
+
let set = listeners.get(channelId);
|
|
45
|
+
if (set === undefined) {
|
|
46
|
+
set = new Set();
|
|
47
|
+
listeners.set(channelId, set);
|
|
48
|
+
}
|
|
49
|
+
set.add(onChange);
|
|
50
|
+
onChange(snapshot(channelId));
|
|
51
|
+
return () => {
|
|
52
|
+
set.delete(onChange);
|
|
53
|
+
if (set.size === 0)
|
|
54
|
+
listeners.delete(channelId);
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
return { transport, participants: snapshot };
|
|
59
|
+
}
|
|
60
|
+
export function createPushToTalk(config) {
|
|
61
|
+
let mode = config?.mode ?? "hold";
|
|
62
|
+
let keyed = false;
|
|
63
|
+
let muted = false;
|
|
64
|
+
let lastTransmitting = false;
|
|
65
|
+
function transmitting() {
|
|
66
|
+
if (muted)
|
|
67
|
+
return false;
|
|
68
|
+
return mode === "openMic" ? true : keyed;
|
|
69
|
+
}
|
|
70
|
+
function emit() {
|
|
71
|
+
const current = transmitting();
|
|
72
|
+
if (current === lastTransmitting)
|
|
73
|
+
return;
|
|
74
|
+
lastTransmitting = current;
|
|
75
|
+
config?.onChange?.(current);
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
mode: () => mode,
|
|
79
|
+
setMode(next) {
|
|
80
|
+
mode = next;
|
|
81
|
+
keyed = false;
|
|
82
|
+
emit();
|
|
83
|
+
},
|
|
84
|
+
keyDown() {
|
|
85
|
+
if (mode === "hold")
|
|
86
|
+
keyed = true;
|
|
87
|
+
else if (mode === "toggle")
|
|
88
|
+
keyed = !keyed;
|
|
89
|
+
emit();
|
|
90
|
+
},
|
|
91
|
+
keyUp() {
|
|
92
|
+
if (mode === "hold")
|
|
93
|
+
keyed = false;
|
|
94
|
+
emit();
|
|
95
|
+
},
|
|
96
|
+
muted: () => muted,
|
|
97
|
+
setMuted(next) {
|
|
98
|
+
muted = next;
|
|
99
|
+
emit();
|
|
100
|
+
},
|
|
101
|
+
status() {
|
|
102
|
+
if (mode === "openMic")
|
|
103
|
+
return "open";
|
|
104
|
+
return keyed ? "keyed" : "idle";
|
|
105
|
+
},
|
|
106
|
+
transmitting,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityPosition } from "../scene/entityStore.js";
|
|
2
|
+
import type { NavGrid, NavPoint } from "./navGrid.js";
|
|
3
|
+
export interface NavConstrainProposed {
|
|
4
|
+
position: EntityPosition;
|
|
5
|
+
rotationY: number;
|
|
6
|
+
grounded: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface NavConstrainEntity {
|
|
9
|
+
position: EntityPosition;
|
|
10
|
+
}
|
|
11
|
+
export interface NavConstrainOptions {
|
|
12
|
+
y?: (point: NavPoint) => number;
|
|
13
|
+
}
|
|
14
|
+
export declare function constrainToNavGrid(grid: NavGrid, options?: NavConstrainOptions): (proposed: NavConstrainProposed, entity: NavConstrainEntity) => NavConstrainProposed | null;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
function withYRemap(proposed, options) {
|
|
2
|
+
if (options?.y === undefined)
|
|
3
|
+
return proposed;
|
|
4
|
+
const y = options.y([proposed.position[0], proposed.position[2]]);
|
|
5
|
+
return { ...proposed, position: [proposed.position[0], y, proposed.position[2]] };
|
|
6
|
+
}
|
|
7
|
+
function walkable(grid, point) {
|
|
8
|
+
const cell = grid.cellAt(point);
|
|
9
|
+
return grid.isWalkable(cell.col, cell.row);
|
|
10
|
+
}
|
|
11
|
+
export function constrainToNavGrid(grid, options) {
|
|
12
|
+
return (proposed, entity) => {
|
|
13
|
+
const target = [proposed.position[0], proposed.position[2]];
|
|
14
|
+
if (walkable(grid, target))
|
|
15
|
+
return withYRemap(proposed, options);
|
|
16
|
+
const slideX = [entity.position[0], proposed.position[2]];
|
|
17
|
+
if (walkable(grid, slideX)) {
|
|
18
|
+
return withYRemap({ ...proposed, position: [entity.position[0], proposed.position[1], proposed.position[2]] }, options);
|
|
19
|
+
}
|
|
20
|
+
const slideZ = [proposed.position[0], entity.position[2]];
|
|
21
|
+
if (walkable(grid, slideZ)) {
|
|
22
|
+
return withYRemap({ ...proposed, position: [proposed.position[0], proposed.position[1], entity.position[2]] }, options);
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EnvironmentWorldFeature } from "../world/features.js";
|
|
2
|
+
import type { Aabb } from "../world/geometry.js";
|
|
3
|
+
export interface NavObstacleGrid {
|
|
4
|
+
blockAabb(aabb: Aabb): void;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Expands every structure descriptor on an environment world feature into its
|
|
8
|
+
* generated buildings and blocks their footprints on `grid`. Returns the
|
|
9
|
+
* number of buildings blocked.
|
|
10
|
+
*/
|
|
11
|
+
export declare function populateNavGridFromEnvironment(grid: NavObstacleGrid, world: EnvironmentWorldFeature): number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolveStructureBuildings } from "../world/environmentSummary.js";
|
|
2
|
+
/**
|
|
3
|
+
* Expands every structure descriptor on an environment world feature into its
|
|
4
|
+
* generated buildings and blocks their footprints on `grid`. Returns the
|
|
5
|
+
* number of buildings blocked.
|
|
6
|
+
*/
|
|
7
|
+
export function populateNavGridFromEnvironment(grid, world) {
|
|
8
|
+
let blocked = 0;
|
|
9
|
+
for (const descriptor of world.structures ?? []) {
|
|
10
|
+
for (const building of resolveStructureBuildings(descriptor)) {
|
|
11
|
+
grid.blockAabb(building.bounds);
|
|
12
|
+
blocked += 1;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return blocked;
|
|
16
|
+
}
|