@genex-ai/cli-demo 0.38.0 → 0.40.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/README.md +22 -1
- package/dist/index.js +153 -12
- package/package.json +5 -1
- package/templates/controllers/NETWORKING.md +29 -0
- package/templates/controllers/character/character-controller.ts +90 -0
- package/templates/controllers/drone/drone-controller.ts +8 -0
- package/templates/controllers/interact/enter-exit.ts +14 -5
- package/templates/controllers/network/networked-pushable.ts +263 -0
- package/templates/controllers/network/networked-vehicle.ts +240 -0
- package/templates/controllers/network/pose.ts +114 -0
- package/templates/controllers/shared/physics-world.ts +10 -0
- package/templates/controllers/vehicle/vehicle-controller.ts +5 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +10 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +54 -34
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +8 -6
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +39 -21
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +8 -6
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +23 -8
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +19 -10
- package/templates/controllers/shared/NETWORKING.md +0 -11
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
3
|
+
import type {
|
|
4
|
+
ObjectControlOptions,
|
|
5
|
+
ObjectControlResult,
|
|
6
|
+
Session,
|
|
7
|
+
} from "@genex-ai/multiplayer";
|
|
8
|
+
import {
|
|
9
|
+
followBody,
|
|
10
|
+
placeBody,
|
|
11
|
+
readNetworkPose,
|
|
12
|
+
roundPose,
|
|
13
|
+
stateFromBody,
|
|
14
|
+
type NetworkPoseState,
|
|
15
|
+
type PoseSafety,
|
|
16
|
+
} from "./pose.ts";
|
|
17
|
+
|
|
18
|
+
export interface NetworkedPushableOptions<TPlayer extends Record<string, unknown>> {
|
|
19
|
+
id: string;
|
|
20
|
+
room: () => Session<TPlayer> | null;
|
|
21
|
+
body: RAPIER.RigidBody;
|
|
22
|
+
object: THREE.Object3D;
|
|
23
|
+
safety?: PoseSafety;
|
|
24
|
+
publishHz?: number;
|
|
25
|
+
keepaliveMs?: number;
|
|
26
|
+
releaseAfterRestMs?: number;
|
|
27
|
+
restSpeed?: number;
|
|
28
|
+
minimumOwnMs?: number;
|
|
29
|
+
onInvalidState?: (raw: Record<string, unknown>) => void;
|
|
30
|
+
onOwnerChange?: (owner: string | undefined) => void;
|
|
31
|
+
onRecovery?: (state: NetworkPoseState) => void;
|
|
32
|
+
onControl?: (result: ObjectControlResult) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Reusable distributed Rapier proxy: dynamic only for the confirmed owner,
|
|
37
|
+
* kinematic follower otherwise. It consumes the SDK-smoothed state directly;
|
|
38
|
+
* it never adds a second interpolation buffer.
|
|
39
|
+
*/
|
|
40
|
+
export class NetworkedPushable<TPlayer extends Record<string, unknown>> {
|
|
41
|
+
private readonly id: string;
|
|
42
|
+
private readonly room: () => Session<TPlayer> | null;
|
|
43
|
+
private readonly body: RAPIER.RigidBody;
|
|
44
|
+
private readonly object: THREE.Object3D;
|
|
45
|
+
private readonly safety: PoseSafety;
|
|
46
|
+
private readonly publishEveryMs: number;
|
|
47
|
+
private readonly keepaliveMs: number;
|
|
48
|
+
private readonly releaseAfterRestMs: number;
|
|
49
|
+
private readonly restSpeed: number;
|
|
50
|
+
private readonly minimumOwnMs: number;
|
|
51
|
+
private readonly onInvalidState?: (raw: Record<string, unknown>) => void;
|
|
52
|
+
private readonly onOwnerChange?: (owner: string | undefined) => void;
|
|
53
|
+
private readonly onRecovery?: (state: NetworkPoseState) => void;
|
|
54
|
+
private readonly onControl?: (result: ObjectControlResult) => void;
|
|
55
|
+
|
|
56
|
+
private touching = false;
|
|
57
|
+
private wasMine = false;
|
|
58
|
+
private pendingClaim: Promise<ObjectControlResult> | null = null;
|
|
59
|
+
private pendingRelease: Promise<ObjectControlResult> | null = null;
|
|
60
|
+
private retryAt = 0;
|
|
61
|
+
private ownedAt = 0;
|
|
62
|
+
private restSince = 0;
|
|
63
|
+
private lastPublishAt = 0;
|
|
64
|
+
private lastPayload = "";
|
|
65
|
+
private lastEpoch: number | undefined;
|
|
66
|
+
private lastOwner: string | undefined;
|
|
67
|
+
|
|
68
|
+
constructor(options: NetworkedPushableOptions<TPlayer>) {
|
|
69
|
+
this.id = options.id;
|
|
70
|
+
this.room = options.room;
|
|
71
|
+
this.body = options.body;
|
|
72
|
+
this.object = options.object;
|
|
73
|
+
this.safety = options.safety ?? {};
|
|
74
|
+
this.publishEveryMs = 1000 / (options.publishHz ?? 20);
|
|
75
|
+
this.keepaliveMs = options.keepaliveMs ?? 500;
|
|
76
|
+
this.releaseAfterRestMs = options.releaseAfterRestMs ?? 2000;
|
|
77
|
+
this.restSpeed = options.restSpeed ?? 0.1;
|
|
78
|
+
this.minimumOwnMs = options.minimumOwnMs ?? 500;
|
|
79
|
+
this.onInvalidState = options.onInvalidState;
|
|
80
|
+
this.onOwnerChange = options.onOwnerChange;
|
|
81
|
+
this.onRecovery = options.onRecovery;
|
|
82
|
+
this.onControl = options.onControl;
|
|
83
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Keep this true for the whole contact, not just the collision rising edge. */
|
|
87
|
+
setContact(active: boolean): void {
|
|
88
|
+
this.touching = active;
|
|
89
|
+
if (!active) this.retryAt = 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async requestClaim(options: ObjectControlOptions = {}): Promise<ObjectControlResult> {
|
|
93
|
+
const room = this.room();
|
|
94
|
+
if (!room) return this.localFailure("disconnected");
|
|
95
|
+
if (this.pendingClaim) return this.pendingClaim;
|
|
96
|
+
const promise = room.objects.claimConfirmed(this.id, options);
|
|
97
|
+
this.pendingClaim = promise;
|
|
98
|
+
const result = await promise;
|
|
99
|
+
this.pendingClaim = null;
|
|
100
|
+
this.onControl?.(result);
|
|
101
|
+
if (!result.accepted && this.touching && (result.reason === "held" || result.reason === "rate-limited")) {
|
|
102
|
+
// Both reasons are transient and carry (or imply) a server-computed retry delay. Ignoring
|
|
103
|
+
// it for rate-limited would re-claim every round-trip and keep the control lane saturated.
|
|
104
|
+
this.retryAt = performance.now() + Math.max(16, result.retryAfterMs ?? 50);
|
|
105
|
+
} else if (!result.accepted && (result.reason === "timeout" || result.reason === "disconnected")) {
|
|
106
|
+
// Outcome unknown / transport down — back off instead of hammering; the shared owner
|
|
107
|
+
// mirror converges regardless (an actually-applied claim flips isMine on its own).
|
|
108
|
+
this.retryAt = performance.now() + 250;
|
|
109
|
+
} else {
|
|
110
|
+
this.retryAt = 0;
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Confirm authority before applying an irreversible kick/weapon impulse. */
|
|
116
|
+
async withAuthority(action: (body: RAPIER.RigidBody) => void): Promise<ObjectControlResult> {
|
|
117
|
+
const result = await this.requestClaim();
|
|
118
|
+
if (!result.accepted) return result;
|
|
119
|
+
this.becomeOwner();
|
|
120
|
+
action(this.body);
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
update(): void {
|
|
125
|
+
const room = this.room();
|
|
126
|
+
if (!room) return;
|
|
127
|
+
const view = room.objects.get<NetworkPoseState>(this.id);
|
|
128
|
+
if (view?.owner !== this.lastOwner) {
|
|
129
|
+
this.lastOwner = view?.owner;
|
|
130
|
+
this.onOwnerChange?.(this.lastOwner);
|
|
131
|
+
}
|
|
132
|
+
const mine = view?.isMine === true;
|
|
133
|
+
const now = performance.now();
|
|
134
|
+
|
|
135
|
+
if (this.touching && !mine && !this.pendingClaim && now >= this.retryAt) {
|
|
136
|
+
void this.requestClaim();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (mine && !this.wasMine) this.becomeOwner();
|
|
140
|
+
if (!mine && this.wasMine) {
|
|
141
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
142
|
+
this.restSince = 0;
|
|
143
|
+
}
|
|
144
|
+
this.wasMine = mine;
|
|
145
|
+
|
|
146
|
+
if (mine) {
|
|
147
|
+
const p = this.body.translation();
|
|
148
|
+
const q = this.body.rotation();
|
|
149
|
+
this.object.position.set(p.x, p.y, p.z);
|
|
150
|
+
this.object.quaternion.set(q.x, q.y, q.z, q.w);
|
|
151
|
+
this.maybeReleaseAtRest(now);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const raw = view?.state as Record<string, unknown> | undefined;
|
|
156
|
+
const pose = readNetworkPose(raw, this.safety);
|
|
157
|
+
if (!pose) {
|
|
158
|
+
if (raw && Object.keys(raw).length > 0) this.onInvalidState?.(raw);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
this.object.position.set(pose.x, pose.y, pose.z);
|
|
162
|
+
this.object.quaternion.fromArray(pose.q);
|
|
163
|
+
const snapped = this.lastEpoch !== undefined && view !== undefined && view.epoch !== this.lastEpoch;
|
|
164
|
+
this.lastEpoch = view?.epoch;
|
|
165
|
+
if (snapped) {
|
|
166
|
+
placeBody(this.body, pose, false);
|
|
167
|
+
this.onRecovery?.(pose);
|
|
168
|
+
} else {
|
|
169
|
+
followBody(this.body, pose);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
publish(now = performance.now()): void {
|
|
174
|
+
const room = this.room();
|
|
175
|
+
if (!room?.objects.get(this.id)?.isMine) return;
|
|
176
|
+
if (now - this.lastPublishAt < this.publishEveryMs) return;
|
|
177
|
+
const state = roundPose(stateFromBody(this.body));
|
|
178
|
+
const payload = JSON.stringify(state);
|
|
179
|
+
if (payload === this.lastPayload && now - this.lastPublishAt < this.keepaliveMs) return;
|
|
180
|
+
this.lastPayload = payload;
|
|
181
|
+
this.lastPublishAt = now;
|
|
182
|
+
room.objects.set(this.id, state);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async release(): Promise<ObjectControlResult> {
|
|
186
|
+
const room = this.room();
|
|
187
|
+
if (!room) return this.localFailure("disconnected", "release");
|
|
188
|
+
if (this.pendingRelease) return this.pendingRelease;
|
|
189
|
+
const pending = room.objects.releaseConfirmed(this.id);
|
|
190
|
+
this.pendingRelease = pending;
|
|
191
|
+
const result = await pending;
|
|
192
|
+
this.pendingRelease = null;
|
|
193
|
+
this.onControl?.(result);
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async reset(state: NetworkPoseState): Promise<ObjectControlResult> {
|
|
198
|
+
const room = this.room();
|
|
199
|
+
if (!room) return this.localFailure("disconnected");
|
|
200
|
+
const result = await room.objects.claimConfirmed(this.id, { authority: "host" });
|
|
201
|
+
this.onControl?.(result);
|
|
202
|
+
if (!result.accepted) return result;
|
|
203
|
+
this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
|
|
204
|
+
placeBody(this.body, state);
|
|
205
|
+
this.object.position.set(state.x, state.y, state.z);
|
|
206
|
+
this.object.quaternion.fromArray(state.q);
|
|
207
|
+
room.objects.snap(this.id, roundPose(state));
|
|
208
|
+
this.wasMine = true;
|
|
209
|
+
this.ownedAt = performance.now();
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private becomeOwner(): void {
|
|
214
|
+
const room = this.room();
|
|
215
|
+
const raw = room?.objects.get<NetworkPoseState>(this.id)?.stateRaw;
|
|
216
|
+
const pose = readNetworkPose(raw, this.safety);
|
|
217
|
+
this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
|
|
218
|
+
// Preserve the current kinematic proxy pose: it is exactly what the local
|
|
219
|
+
// player saw at acceptance, so replacing it with delayed raw truth creates
|
|
220
|
+
// a visible backward jump. Seed velocity from raw authority instead so
|
|
221
|
+
// momentum survives the handoff without rewinding the body.
|
|
222
|
+
if (pose) {
|
|
223
|
+
this.body.setLinvel({ x: pose.vx ?? 0, y: pose.vy ?? 0, z: pose.vz ?? 0 }, true);
|
|
224
|
+
this.body.setAngvel({ x: pose.avx ?? 0, y: pose.avy ?? 0, z: pose.avz ?? 0 }, true);
|
|
225
|
+
}
|
|
226
|
+
this.body.wakeUp();
|
|
227
|
+
this.wasMine = true;
|
|
228
|
+
this.ownedAt = performance.now();
|
|
229
|
+
this.restSince = 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private maybeReleaseAtRest(now: number): void {
|
|
233
|
+
if (this.touching || now - this.ownedAt < this.minimumOwnMs) {
|
|
234
|
+
this.restSince = 0;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const velocity = this.body.linvel();
|
|
238
|
+
const speed = Math.hypot(velocity.x, velocity.y, velocity.z);
|
|
239
|
+
if (speed > this.restSpeed) {
|
|
240
|
+
this.restSince = 0;
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
this.restSince ||= now;
|
|
244
|
+
if (now - this.restSince >= this.releaseAfterRestMs) {
|
|
245
|
+
this.restSince = now;
|
|
246
|
+
void this.release();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private localFailure(
|
|
251
|
+
reason: "disconnected",
|
|
252
|
+
operation: "claim" | "release" | "remove" = "claim",
|
|
253
|
+
): ObjectControlResult {
|
|
254
|
+
return {
|
|
255
|
+
requestId: "local",
|
|
256
|
+
operation,
|
|
257
|
+
id: this.id,
|
|
258
|
+
accepted: false,
|
|
259
|
+
sequence: 0,
|
|
260
|
+
reason,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
3
|
+
import type { ObjectControlResult, Session } from "@genex-ai/multiplayer";
|
|
4
|
+
import {
|
|
5
|
+
followBody,
|
|
6
|
+
placeBody,
|
|
7
|
+
readNetworkPose,
|
|
8
|
+
roundPose,
|
|
9
|
+
stateFromBody,
|
|
10
|
+
type NetworkPoseState,
|
|
11
|
+
type PoseSafety,
|
|
12
|
+
} from "./pose.ts";
|
|
13
|
+
|
|
14
|
+
export interface NetworkedVehicleUnit {
|
|
15
|
+
syncFromBody(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NetworkedVehiclePhysics {
|
|
19
|
+
registerBody(body: RAPIER.RigidBody, object: THREE.Object3D): void;
|
|
20
|
+
unregisterBody(body: RAPIER.RigidBody): void;
|
|
21
|
+
snapBodyInterpolation(body: RAPIER.RigidBody): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface NetworkedVehicleOptions<TPlayer extends Record<string, unknown>> {
|
|
25
|
+
id: string;
|
|
26
|
+
room: () => Session<TPlayer> | null;
|
|
27
|
+
body: RAPIER.RigidBody;
|
|
28
|
+
object: THREE.Object3D;
|
|
29
|
+
unit: NetworkedVehicleUnit;
|
|
30
|
+
physics: NetworkedVehiclePhysics;
|
|
31
|
+
safety?: PoseSafety;
|
|
32
|
+
publishHz?: number;
|
|
33
|
+
/** Called after authority is confirmed, before vehicle controls take over. */
|
|
34
|
+
onEnter?: () => void;
|
|
35
|
+
/** Place/unpark the character and return the discontinuous player state to publish. */
|
|
36
|
+
onExit?: (vehiclePose: NetworkPoseState) => TPlayer | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* Called when seat authority is lost WITHOUT a clean exit() — the relay reassigned the object
|
|
39
|
+
* (steal, orphan repair, host reset). The helper has already stopped driving and switched the
|
|
40
|
+
* body back to a follower; use this to dismount the character and restore on-foot controls.
|
|
41
|
+
*/
|
|
42
|
+
onSeatLost?: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface NetworkedVehicleEnterOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Claim the seat even when another player currently owns (occupies) it. Off by default:
|
|
48
|
+
* idle vehicles are unowned, so an existing owner IS the current driver, and the relay's
|
|
49
|
+
* short anti-thrash hold only protects the first 300 ms of a drive — without this gate any
|
|
50
|
+
* walk-up claim would hijack an occupied vehicle and force-dismount its driver.
|
|
51
|
+
*/
|
|
52
|
+
steal?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Confirmed single-seat ownership with idle-unowned persistence semantics. */
|
|
56
|
+
export class NetworkedVehicle<TPlayer extends Record<string, unknown>> {
|
|
57
|
+
readonly id: string;
|
|
58
|
+
driving = false;
|
|
59
|
+
|
|
60
|
+
private readonly room: () => Session<TPlayer> | null;
|
|
61
|
+
private readonly body: RAPIER.RigidBody;
|
|
62
|
+
private readonly object: THREE.Object3D;
|
|
63
|
+
private readonly unit: NetworkedVehicleUnit;
|
|
64
|
+
private readonly physics: NetworkedVehiclePhysics;
|
|
65
|
+
private readonly safety: PoseSafety;
|
|
66
|
+
private readonly publishEveryMs: number;
|
|
67
|
+
private readonly onEnter?: () => void;
|
|
68
|
+
private readonly onExit?: (vehiclePose: NetworkPoseState) => TPlayer | undefined;
|
|
69
|
+
private readonly onSeatLost?: () => void;
|
|
70
|
+
private registered = false;
|
|
71
|
+
private pendingSeat: Promise<ObjectControlResult> | null = null;
|
|
72
|
+
private lastPublishAt = 0;
|
|
73
|
+
private lastEpoch: number | undefined;
|
|
74
|
+
|
|
75
|
+
constructor(options: NetworkedVehicleOptions<TPlayer>) {
|
|
76
|
+
this.id = options.id;
|
|
77
|
+
this.room = options.room;
|
|
78
|
+
this.body = options.body;
|
|
79
|
+
this.object = options.object;
|
|
80
|
+
this.unit = options.unit;
|
|
81
|
+
this.physics = options.physics;
|
|
82
|
+
this.safety = options.safety ?? {};
|
|
83
|
+
this.publishEveryMs = 1000 / (options.publishHz ?? 30);
|
|
84
|
+
this.onEnter = options.onEnter;
|
|
85
|
+
this.onExit = options.onExit;
|
|
86
|
+
this.onSeatLost = options.onSeatLost;
|
|
87
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async enter(options: NetworkedVehicleEnterOptions = {}): Promise<ObjectControlResult> {
|
|
91
|
+
const room = this.room();
|
|
92
|
+
if (!room) return this.localFailure("disconnected");
|
|
93
|
+
if (this.pendingSeat) return this.pendingSeat;
|
|
94
|
+
const view = room.objects.get<NetworkPoseState>(this.id);
|
|
95
|
+
// Occupancy gate: idle vehicles stay unowned, so a present owner IS the current driver.
|
|
96
|
+
// The relay accepts a rival claim once its 300 ms anti-thrash hold lapses — it arbitrates
|
|
97
|
+
// ownership, not seats — so the seat semantics live here. Refuse without an explicit steal.
|
|
98
|
+
if (!options.steal && view?.owner !== undefined && !view.isMine) {
|
|
99
|
+
return {
|
|
100
|
+
requestId: "local",
|
|
101
|
+
operation: "claim",
|
|
102
|
+
id: this.id,
|
|
103
|
+
accepted: false,
|
|
104
|
+
owner: view.owner,
|
|
105
|
+
sequence: 0,
|
|
106
|
+
reason: "held",
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
const rawBeforeClaim = readNetworkPose(view?.stateRaw, this.safety);
|
|
110
|
+
const pending = room.objects.claimConfirmed(this.id);
|
|
111
|
+
this.pendingSeat = pending;
|
|
112
|
+
const result = await pending;
|
|
113
|
+
this.pendingSeat = null;
|
|
114
|
+
if (!result.accepted) return result;
|
|
115
|
+
|
|
116
|
+
const rawAfterClaim = readNetworkPose(
|
|
117
|
+
room.objects.get<NetworkPoseState>(this.id)?.stateRaw,
|
|
118
|
+
this.safety,
|
|
119
|
+
);
|
|
120
|
+
const entryPose = rawAfterClaim ?? rawBeforeClaim;
|
|
121
|
+
if (entryPose) placeBody(this.body, entryPose);
|
|
122
|
+
this.body.setBodyType(RAPIER.RigidBodyType.Dynamic, true);
|
|
123
|
+
this.body.wakeUp();
|
|
124
|
+
if (!this.registered) {
|
|
125
|
+
this.physics.registerBody(this.body, this.object);
|
|
126
|
+
this.registered = true;
|
|
127
|
+
}
|
|
128
|
+
this.unit.syncFromBody();
|
|
129
|
+
this.physics.snapBodyInterpolation(this.body);
|
|
130
|
+
this.driving = true;
|
|
131
|
+
this.onEnter?.();
|
|
132
|
+
room.objects.set(this.id, roundPose(stateFromBody(this.body, room.id)));
|
|
133
|
+
return result;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async exit(): Promise<ObjectControlResult> {
|
|
137
|
+
const room = this.room();
|
|
138
|
+
if (!room) return this.localFailure("disconnected", "release");
|
|
139
|
+
const pose = roundPose(stateFromBody(this.body));
|
|
140
|
+
room.objects.set(this.id, pose);
|
|
141
|
+
const result = await room.objects.releaseConfirmed(this.id);
|
|
142
|
+
if (!result.accepted) return result;
|
|
143
|
+
|
|
144
|
+
const playerState = this.onExit?.(pose);
|
|
145
|
+
if (playerState) room.me.snap(playerState);
|
|
146
|
+
this.driving = false;
|
|
147
|
+
if (this.registered) {
|
|
148
|
+
this.physics.unregisterBody(this.body);
|
|
149
|
+
this.registered = false;
|
|
150
|
+
}
|
|
151
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
updateFollower(): void {
|
|
156
|
+
if (this.driving) {
|
|
157
|
+
const p = this.body.translation();
|
|
158
|
+
const q = this.body.rotation();
|
|
159
|
+
this.object.position.set(p.x, p.y, p.z);
|
|
160
|
+
this.object.quaternion.set(q.x, q.y, q.z, q.w);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const room = this.room();
|
|
164
|
+
const view = room?.objects.get<NetworkPoseState>(this.id);
|
|
165
|
+
const pose = readNetworkPose(view?.state, this.safety);
|
|
166
|
+
if (!pose) return;
|
|
167
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
168
|
+
const snapped = this.lastEpoch !== undefined && view !== undefined && view.epoch !== this.lastEpoch;
|
|
169
|
+
this.lastEpoch = view?.epoch;
|
|
170
|
+
if (snapped) {
|
|
171
|
+
placeBody(this.body, pose, false);
|
|
172
|
+
this.physics.snapBodyInterpolation(this.body);
|
|
173
|
+
} else {
|
|
174
|
+
followBody(this.body, pose);
|
|
175
|
+
}
|
|
176
|
+
this.object.position.set(pose.x, pose.y, pose.z);
|
|
177
|
+
this.object.quaternion.fromArray(pose.q);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
publish(now = performance.now()): void {
|
|
181
|
+
const room = this.room();
|
|
182
|
+
if (!room || !this.driving || now - this.lastPublishAt < this.publishEveryMs) return;
|
|
183
|
+
if (!room.objects.get(this.id)?.isMine) {
|
|
184
|
+
// Seat authority moved without a clean exit (steal / orphan repair / host reset).
|
|
185
|
+
this.bailOut();
|
|
186
|
+
this.onSeatLost?.();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
this.lastPublishAt = now;
|
|
190
|
+
room.objects.set(this.id, roundPose(stateFromBody(this.body, room.id)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Host-only initial seed/reset. Leaves the idle vehicle unowned afterwards. */
|
|
194
|
+
async seedIdle(state: NetworkPoseState): Promise<ObjectControlResult> {
|
|
195
|
+
const room = this.room();
|
|
196
|
+
if (!room) return this.localFailure("disconnected");
|
|
197
|
+
if (!room.isHost) return this.localFailure("not-host");
|
|
198
|
+
const result = await room.objects.claimConfirmed(this.id, { authority: "host" });
|
|
199
|
+
if (!result.accepted) return result;
|
|
200
|
+
placeBody(this.body, state, false);
|
|
201
|
+
this.unit.syncFromBody();
|
|
202
|
+
this.physics.snapBodyInterpolation(this.body);
|
|
203
|
+
room.objects.snap(this.id, roundPose(state));
|
|
204
|
+
const release = await room.objects.releaseConfirmed(this.id);
|
|
205
|
+
return release.accepted ? result : release;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Finalize an object the relay reassigned after its driver disconnected. */
|
|
209
|
+
async releaseOrphanIfHost(): Promise<void> {
|
|
210
|
+
const room = this.room();
|
|
211
|
+
const view = room?.objects.get<NetworkPoseState>(this.id);
|
|
212
|
+
if (!room?.isHost || this.driving || !view?.isMine) return;
|
|
213
|
+
const raw = readNetworkPose(view.stateRaw, this.safety);
|
|
214
|
+
if (raw) room.objects.snap(this.id, roundPose({ ...raw, driverId: undefined }));
|
|
215
|
+
await room.objects.releaseConfirmed(this.id);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
bailOut(): void {
|
|
219
|
+
this.driving = false;
|
|
220
|
+
if (this.registered) {
|
|
221
|
+
this.physics.unregisterBody(this.body);
|
|
222
|
+
this.registered = false;
|
|
223
|
+
}
|
|
224
|
+
this.body.setBodyType(RAPIER.RigidBodyType.KinematicPositionBased, true);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private localFailure(
|
|
228
|
+
reason: "disconnected" | "not-host",
|
|
229
|
+
operation: "claim" | "release" | "remove" = "claim",
|
|
230
|
+
): ObjectControlResult {
|
|
231
|
+
return {
|
|
232
|
+
requestId: "local",
|
|
233
|
+
operation,
|
|
234
|
+
id: this.id,
|
|
235
|
+
accepted: false,
|
|
236
|
+
sequence: 0,
|
|
237
|
+
reason,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import RAPIER from "@dimforge/rapier3d-compat";
|
|
2
|
+
|
|
3
|
+
export type NetworkQuaternion = [number, number, number, number];
|
|
4
|
+
|
|
5
|
+
export interface NetworkPoseState extends Record<string, unknown> {
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
z: number;
|
|
9
|
+
q: NetworkQuaternion;
|
|
10
|
+
vx?: number;
|
|
11
|
+
vy?: number;
|
|
12
|
+
vz?: number;
|
|
13
|
+
avx?: number;
|
|
14
|
+
avy?: number;
|
|
15
|
+
avz?: number;
|
|
16
|
+
driverId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PoseSafety {
|
|
20
|
+
/** Absolute coordinate ceiling for this game. Default 10,000. */
|
|
21
|
+
maxPosition?: number;
|
|
22
|
+
/** Linear/angular component ceiling. Default 250. */
|
|
23
|
+
maxVelocity?: number;
|
|
24
|
+
/** Quaternion squared-length tolerance around 1. Default 0.1. */
|
|
25
|
+
quaternionTolerance?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const finite = (value: unknown): value is number =>
|
|
29
|
+
typeof value === "number" && Number.isFinite(value);
|
|
30
|
+
|
|
31
|
+
export function readNetworkPose(
|
|
32
|
+
raw: Record<string, unknown> | undefined,
|
|
33
|
+
safety: PoseSafety = {},
|
|
34
|
+
): NetworkPoseState | null {
|
|
35
|
+
if (!raw || !finite(raw.x) || !finite(raw.y) || !finite(raw.z)) return null;
|
|
36
|
+
const maxPosition = safety.maxPosition ?? 10_000;
|
|
37
|
+
if (Math.max(Math.abs(raw.x), Math.abs(raw.y), Math.abs(raw.z)) > maxPosition) return null;
|
|
38
|
+
if (!Array.isArray(raw.q) || raw.q.length !== 4 || !raw.q.every(finite)) return null;
|
|
39
|
+
const q = raw.q as NetworkQuaternion;
|
|
40
|
+
const qLen2 = q[0] ** 2 + q[1] ** 2 + q[2] ** 2 + q[3] ** 2;
|
|
41
|
+
if (!Number.isFinite(qLen2) || qLen2 < 1e-8) return null;
|
|
42
|
+
const tolerance = safety.quaternionTolerance ?? 0.1;
|
|
43
|
+
if (Math.abs(qLen2 - 1) > tolerance) return null;
|
|
44
|
+
|
|
45
|
+
const maxVelocity = safety.maxVelocity ?? 250;
|
|
46
|
+
for (const key of ["vx", "vy", "vz", "avx", "avy", "avz"] as const) {
|
|
47
|
+
const value = raw[key];
|
|
48
|
+
if (value !== undefined && (!finite(value) || Math.abs(value) > maxVelocity)) return null;
|
|
49
|
+
}
|
|
50
|
+
if (
|
|
51
|
+
raw.driverId !== undefined &&
|
|
52
|
+
(typeof raw.driverId !== "string" || raw.driverId.length > 128)
|
|
53
|
+
) return null;
|
|
54
|
+
|
|
55
|
+
const invLength = 1 / Math.sqrt(qLen2);
|
|
56
|
+
return {
|
|
57
|
+
...raw,
|
|
58
|
+
x: raw.x,
|
|
59
|
+
y: raw.y,
|
|
60
|
+
z: raw.z,
|
|
61
|
+
q: [q[0] * invLength, q[1] * invLength, q[2] * invLength, q[3] * invLength],
|
|
62
|
+
} as NetworkPoseState;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function stateFromBody(body: RAPIER.RigidBody, driverId?: string): NetworkPoseState {
|
|
66
|
+
const p = body.translation();
|
|
67
|
+
const q = body.rotation();
|
|
68
|
+
const v = body.linvel();
|
|
69
|
+
const av = body.angvel();
|
|
70
|
+
return {
|
|
71
|
+
x: p.x,
|
|
72
|
+
y: p.y,
|
|
73
|
+
z: p.z,
|
|
74
|
+
q: [q.x, q.y, q.z, q.w],
|
|
75
|
+
vx: v.x,
|
|
76
|
+
vy: v.y,
|
|
77
|
+
vz: v.z,
|
|
78
|
+
avx: av.x,
|
|
79
|
+
avy: av.y,
|
|
80
|
+
avz: av.z,
|
|
81
|
+
...(driverId ? { driverId } : {}),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function placeBody(body: RAPIER.RigidBody, state: NetworkPoseState, wake = true): void {
|
|
86
|
+
body.setTranslation({ x: state.x, y: state.y, z: state.z }, wake);
|
|
87
|
+
body.setRotation({ x: state.q[0], y: state.q[1], z: state.q[2], w: state.q[3] }, wake);
|
|
88
|
+
body.setLinvel({ x: state.vx ?? 0, y: state.vy ?? 0, z: state.vz ?? 0 }, wake);
|
|
89
|
+
body.setAngvel({ x: state.avx ?? 0, y: state.avy ?? 0, z: state.avz ?? 0 }, wake);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function followBody(body: RAPIER.RigidBody, state: NetworkPoseState): void {
|
|
93
|
+
body.setNextKinematicTranslation({ x: state.x, y: state.y, z: state.z });
|
|
94
|
+
body.setNextKinematicRotation({ x: state.q[0], y: state.q[1], z: state.q[2], w: state.q[3] });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function roundPose(state: NetworkPoseState, places = 3): NetworkPoseState {
|
|
98
|
+
const scale = 10 ** places;
|
|
99
|
+
const round = (value: number | undefined): number | undefined =>
|
|
100
|
+
value === undefined ? undefined : Math.round(value * scale) / scale;
|
|
101
|
+
return {
|
|
102
|
+
...state,
|
|
103
|
+
x: round(state.x)!,
|
|
104
|
+
y: round(state.y)!,
|
|
105
|
+
z: round(state.z)!,
|
|
106
|
+
q: state.q.map((value) => round(value)!) as NetworkQuaternion,
|
|
107
|
+
vx: round(state.vx),
|
|
108
|
+
vy: round(state.vy),
|
|
109
|
+
vz: round(state.vz),
|
|
110
|
+
avx: round(state.avx),
|
|
111
|
+
avy: round(state.avy),
|
|
112
|
+
avz: round(state.avz),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -321,6 +321,16 @@ export class PhysicsWorld {
|
|
|
321
321
|
this.bodyStates.delete(body.handle);
|
|
322
322
|
}
|
|
323
323
|
|
|
324
|
+
/** Reset render interpolation history to the body's CURRENT pose. */
|
|
325
|
+
snapBodyInterpolation(body: RAPIER.RigidBody): void {
|
|
326
|
+
const t = body.translation();
|
|
327
|
+
const r = body.rotation();
|
|
328
|
+
this.previousState.set(body.handle, {
|
|
329
|
+
position: new THREE.Vector3(t.x, t.y, t.z),
|
|
330
|
+
rotation: new THREE.Quaternion(r.x, r.y, r.z, r.w),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
324
334
|
/**
|
|
325
335
|
* Unregister the body's Object3D, drop event handlers for all of its
|
|
326
336
|
* colliders, and remove the body (and its colliders) from the world.
|
|
@@ -484,6 +484,11 @@ export class VehicleController {
|
|
|
484
484
|
}
|
|
485
485
|
}
|
|
486
486
|
|
|
487
|
+
/** Refresh cached pose/velocity/axes immediately after an external body placement. */
|
|
488
|
+
syncFromBody(): void {
|
|
489
|
+
this.updateVehicleInfo();
|
|
490
|
+
}
|
|
491
|
+
|
|
487
492
|
private updateVehicleInfo(): void {
|
|
488
493
|
this.vehiclePos.copy(this.body.translation());
|
|
489
494
|
this.vehicleQuat.copy(this.body.rotation());
|
|
@@ -28,6 +28,9 @@ a character controller from scratch and do not swap in a kinematic-controller
|
|
|
28
28
|
tutorial: this one is a real dynamic body that pushes crates, rides moving
|
|
29
29
|
platforms, climbs stairs and slides on too-steep slopes out of the box.
|
|
30
30
|
|
|
31
|
+
Because these files are game-owned, never run `genex controller character --force` over an edited
|
|
32
|
+
fork as a migration strategy. Install a fresh copy elsewhere and port only the named changes.
|
|
33
|
+
|
|
31
34
|
## What you get
|
|
32
35
|
|
|
33
36
|
| Module (under `src/controllers/`) | Exports you use | Job |
|
|
@@ -131,6 +134,11 @@ preset table with provenance, the density/spring scaling rule, and the
|
|
|
131
134
|
out of the box**. Cap it (e.g. `Math.PI / 4`) if steep slopes should slide.
|
|
132
135
|
- The capsule ships with friction `-0.5` **on purpose** (grip is synthesized by
|
|
133
136
|
the controller). Do not "fix" it to a positive value.
|
|
137
|
+
- CCD is enabled by default. For high-energy multiplayer arenas, set
|
|
138
|
+
`maxExternalLinearSpeed` above every intended run/jump/fall speed and provide `isPoseAllowed`.
|
|
139
|
+
`onRecovery` fires after body, root, velocity, and controller caches are restored in the same
|
|
140
|
+
frame; use it to call `physics.snapBodyInterpolation(character.body)`, reset the camera target,
|
|
141
|
+
and publish `room.me.snap(...)`. Bounds and spawn coordinates stay in game code.
|
|
134
142
|
|
|
135
143
|
## Animations + animation packs
|
|
136
144
|
|
|
@@ -209,7 +217,8 @@ body, a `CharacterController`, or any physics for it. Simulating remote
|
|
|
209
217
|
players' physics locally guarantees divergence — every client would compute a
|
|
210
218
|
different world.
|
|
211
219
|
|
|
212
|
-
- Publish your own `currPos` +
|
|
220
|
+
- Publish your own `currPos` + `currQuat` as a four-number quaternion on the fixed 10–20 Hz tick,
|
|
221
|
+
not per frame. Never reduce multiplayer rotation to scalar yaw.
|
|
213
222
|
- To animate remotes, sync the six animation booleans (`isOnGround`,
|
|
214
223
|
`isFalling`, `isMoving`, `runActive`, `jumpActive`, `crouchActive`) and feed
|
|
215
224
|
them to a per-remote `CharacterAnimations` — see the animations reference.
|