@genex-ai/cli-demo 0.39.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 +1 -1
- package/dist/index.js +28 -4
- package/package.json +2 -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
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ genex sfx "<prompt>" # generate a sound fx → prints an asset URL
|
|
|
15
15
|
genex texture "<prompt>" # generate a texture → prints an asset URL
|
|
16
16
|
genex image "<prompt>" # generate an image → prints an asset URL
|
|
17
17
|
genex video "<prompt>" # generate a video → prints an asset URL
|
|
18
|
-
genex controller <type> #
|
|
18
|
+
genex controller <type> # character|car|drone|networked-physics → src/controllers/
|
|
19
19
|
genex controller anims <sel…> # download extra character animation clips (by tag or name) → public/assets/anims/
|
|
20
20
|
```
|
|
21
21
|
|
package/dist/index.js
CHANGED
|
@@ -2365,7 +2365,12 @@ function formatMb(bytes) {
|
|
|
2365
2365
|
}
|
|
2366
2366
|
|
|
2367
2367
|
// src/commands/controller.ts
|
|
2368
|
-
var CONTROLLER_KINDS = [
|
|
2368
|
+
var CONTROLLER_KINDS = [
|
|
2369
|
+
"character",
|
|
2370
|
+
"car",
|
|
2371
|
+
"drone",
|
|
2372
|
+
"networked-physics"
|
|
2373
|
+
];
|
|
2369
2374
|
var SHARED = [
|
|
2370
2375
|
"shared/math.ts",
|
|
2371
2376
|
"shared/physics-world.ts",
|
|
@@ -2443,6 +2448,23 @@ var CONTROLLER_FILE_SETS = {
|
|
|
2443
2448
|
`const drone = new DroneController({ world: physics.world, body, chassis, propellers, config: dronePresets["camera-drone"].config });`,
|
|
2444
2449
|
`physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
|
|
2445
2450
|
]
|
|
2451
|
+
},
|
|
2452
|
+
"networked-physics": {
|
|
2453
|
+
code: [
|
|
2454
|
+
...SHARED,
|
|
2455
|
+
"network/pose.ts",
|
|
2456
|
+
"network/networked-pushable.ts",
|
|
2457
|
+
"network/networked-vehicle.ts",
|
|
2458
|
+
"NETWORKING.md",
|
|
2459
|
+
NOTICE
|
|
2460
|
+
],
|
|
2461
|
+
assets: [],
|
|
2462
|
+
skill: "genex-threejs-multiplayer",
|
|
2463
|
+
sketch: [
|
|
2464
|
+
`const box = new NetworkedPushable({ id: "box:1", room: () => room, body, object: mesh });`,
|
|
2465
|
+
`physics.onBeforeStep(() => box.update()); physics.onAfterStep(() => box.publish());`,
|
|
2466
|
+
`contacts.onChange((active) => box.setContact(active)); // retries held claims while contact persists`
|
|
2467
|
+
]
|
|
2446
2468
|
}
|
|
2447
2469
|
};
|
|
2448
2470
|
var CODE_DEST = path13.join("src", "controllers");
|
|
@@ -2457,7 +2479,7 @@ async function runController(opts) {
|
|
|
2457
2479
|
if (!kind || !CONTROLLER_KINDS.includes(kind)) {
|
|
2458
2480
|
log.error(
|
|
2459
2481
|
`Missing or unknown controller type${kind ? ` "${kind}"` : ""}. Usage: ${c.cyan(
|
|
2460
|
-
"genex controller <character|car|drone> [--force]"
|
|
2482
|
+
"genex controller <character|car|drone|networked-physics> [--force]"
|
|
2461
2483
|
)} or ${c.cyan("genex controller anims <tag|clip \u2026>")}`
|
|
2462
2484
|
);
|
|
2463
2485
|
process.exitCode = 1;
|
|
@@ -2508,7 +2530,7 @@ async function runController(opts) {
|
|
|
2508
2530
|
log.plain(c.bold("Next steps"));
|
|
2509
2531
|
log.plain(
|
|
2510
2532
|
` 1. ${c.cyan(
|
|
2511
|
-
kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : "npm i @dimforge/rapier3d-compat"
|
|
2533
|
+
kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : kind === "networked-physics" ? "npm i @dimforge/rapier3d-compat @genex-ai/multiplayer" : "npm i @dimforge/rapier3d-compat"
|
|
2512
2534
|
)} (three is already in the scaffold).`
|
|
2513
2535
|
);
|
|
2514
2536
|
log.plain(` 2. Load the ${c.cyan(set.skill)} skill for wiring, presets, and tuning.`);
|
|
@@ -2641,7 +2663,8 @@ ${c.bold("Usage")}
|
|
|
2641
2663
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
2642
2664
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
2643
2665
|
genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
|
|
2644
|
-
genex controller <type> [--force] Install a physics controller
|
|
2666
|
+
genex controller <type> [--force] Install a physics controller
|
|
2667
|
+
(character|car|drone|networked-physics)
|
|
2645
2668
|
into src/controllers (+ assets into public/assets).
|
|
2646
2669
|
genex controller anims <sel \u2026> Download extra character animation clips by tag or
|
|
2647
2670
|
exact name (sword, stealth, Celebration, \u2026) into
|
|
@@ -2740,6 +2763,7 @@ ${c.bold("Examples")}
|
|
|
2740
2763
|
genex image "neon graffiti tag, spray-paint style" --transparent
|
|
2741
2764
|
genex video "swirling neon plasma, seamless loop" --loop
|
|
2742
2765
|
genex controller character
|
|
2766
|
+
genex controller networked-physics
|
|
2743
2767
|
genex explore "grass"
|
|
2744
2768
|
genex explore
|
|
2745
2769
|
`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.0",
|
|
4
4
|
"description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
|
43
|
+
"@genex-ai/multiplayer": "workspace:*",
|
|
43
44
|
"@pixiv/three-vrm": "^3.5.4",
|
|
44
45
|
"@types/three": "^0.185.0",
|
|
45
46
|
"three": "^0.185.1",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Networking these controllers
|
|
2
|
+
|
|
3
|
+
These controllers simulate **your own player only** (self-authoritative, zero input
|
|
4
|
+
latency). To show OTHER players' rigs in a multiplayer game, publish a small flat state on
|
|
5
|
+
a fixed tick and play it back on a visual-only remote rig — never instantiate a controller
|
|
6
|
+
or a Rapier body for a remote player.
|
|
7
|
+
|
|
8
|
+
Install `genex controller networked-physics` for confirmed-authority Rapier pushable and vehicle
|
|
9
|
+
state machines. They use one local fixed-step body: dynamic only for the confirmed owner and a
|
|
10
|
+
kinematic proxy driven directly from the SDK-smoothed remote state otherwise. Do not add a second
|
|
11
|
+
remote lerp or shadow physics simulation.
|
|
12
|
+
|
|
13
|
+
Use ordinary `set` for continuous movement. Use `me.snap` / `objects.snap` only for a deliberate
|
|
14
|
+
respawn, teleport, goal reset, or vehicle mode edge. Irreversible impulses, seats, reset feedback,
|
|
15
|
+
release, and removal must wait for the confirmed object-control result. The networked helpers watch
|
|
16
|
+
the relay-owned object `epoch` and direct-place Rapier followers on snaps, so a reset cannot create a
|
|
17
|
+
synthetic kinematic sweep through bystanders.
|
|
18
|
+
|
|
19
|
+
Vehicle seat rules the helper enforces: idle vehicles stay unowned, so a present owner IS the current
|
|
20
|
+
driver — `NetworkedVehicle.enter()` refuses an owned vehicle unless the game passes
|
|
21
|
+
`{ steal: true }`, and `onSeatLost` fires if the relay moves the seat without a clean `exit()`
|
|
22
|
+
(steal, orphan repair, host reset). When a driver disconnects, the relay parks the orphaned vehicle
|
|
23
|
+
on the host — the host should call `releaseOrphanIfHost()` (each vehicle, when it observes itself as
|
|
24
|
+
a non-driving owner) to snap the pose clean and return the vehicle to the idle-unowned state.
|
|
25
|
+
|
|
26
|
+
The complete recipe (what to publish per controller, remote playback, and genuine simultaneous
|
|
27
|
+
contested physics via the host) lives in the multiplayer skill:
|
|
28
|
+
`genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
|
|
29
|
+
any networking code.
|
|
@@ -19,6 +19,14 @@ export type { ControllerUserData };
|
|
|
19
19
|
|
|
20
20
|
const clamp = THREE.MathUtils.clamp;
|
|
21
21
|
|
|
22
|
+
function finiteVec(v: { x: number; y: number; z: number }): boolean {
|
|
23
|
+
return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function finiteQuat(q: { x: number; y: number; z: number; w: number }): boolean {
|
|
27
|
+
return finiteVec(q) && Number.isFinite(q.w) && Math.hypot(q.x, q.y, q.z, q.w) > 1e-6;
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
/** Default platform mass-ratio falloff curve: flat 0 until half the character's
|
|
23
31
|
* mass, then rising to full inheritance at equal-or-heavier platforms. */
|
|
24
32
|
const DEFAULT_CURVE_DATA: CurveData = {
|
|
@@ -72,6 +80,12 @@ type ResolvedMovementInput = {
|
|
|
72
80
|
/** Crouch input interpretation — see {@link CharacterControllerOptions.crouchMode}. */
|
|
73
81
|
export type CrouchMode = "toggle" | "hold";
|
|
74
82
|
|
|
83
|
+
export interface CharacterRecoveryEvent {
|
|
84
|
+
reason: "non-finite" | "out-of-bounds";
|
|
85
|
+
position: { x: number; y: number; z: number };
|
|
86
|
+
linearVelocity: { x: number; y: number; z: number };
|
|
87
|
+
}
|
|
88
|
+
|
|
75
89
|
/**
|
|
76
90
|
* Options for {@link CharacterController}. Every value has a tuned default —
|
|
77
91
|
* start from a preset in `./presets.ts` and only override what feels wrong.
|
|
@@ -97,6 +111,20 @@ export interface CharacterControllerOptions {
|
|
|
97
111
|
density?: number;
|
|
98
112
|
/** Allow the body to sleep when at rest. Default `true`. */
|
|
99
113
|
canSleep?: boolean;
|
|
114
|
+
/** Continuous collision detection for high-speed prop/vehicle impacts. Default `true`. */
|
|
115
|
+
ccd?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Optional absolute body-speed ceiling in m/s. Set it comfortably above intended run/jump/fall
|
|
118
|
+
* speeds; it is a last-resort external-impulse guard, not locomotion tuning.
|
|
119
|
+
*/
|
|
120
|
+
maxExternalLinearSpeed?: number;
|
|
121
|
+
/** Optional game-owned arena/bounds predicate. Non-finite poses are rejected regardless. */
|
|
122
|
+
isPoseAllowed?: (position: Readonly<{ x: number; y: number; z: number }>) => boolean;
|
|
123
|
+
/**
|
|
124
|
+
* Called after an unsafe pose was atomically restored to the constructor spawn. Pair this with
|
|
125
|
+
* `physics.snapBodyInterpolation(body)`, camera reset, and `room.me.snap(...)` when networked.
|
|
126
|
+
*/
|
|
127
|
+
onRecovery?: (event: CharacterRecoveryEvent) => void;
|
|
100
128
|
/** Initial gravity scale while airborne and not falling. Default `1`. */
|
|
101
129
|
gravityScale?: number;
|
|
102
130
|
/**
|
|
@@ -314,6 +342,11 @@ export class CharacterController {
|
|
|
314
342
|
private readonly counterMoveImpFactor: number;
|
|
315
343
|
private readonly initialGravityScale: number;
|
|
316
344
|
private readonly massRatioFallOffCurve: CurveLUT;
|
|
345
|
+
private readonly maxExternalLinearSpeed: number | undefined;
|
|
346
|
+
private readonly isPoseAllowed: CharacterControllerOptions["isPoseAllowed"];
|
|
347
|
+
private readonly onRecovery: CharacterControllerOptions["onRecovery"];
|
|
348
|
+
private readonly recoveryPosition = new THREE.Vector3();
|
|
349
|
+
private readonly recoveryRotation = new THREE.Quaternion();
|
|
317
350
|
|
|
318
351
|
// ── input state ──
|
|
319
352
|
private readonly movementState: ResolvedMovementInput = {
|
|
@@ -531,6 +564,9 @@ export class CharacterController {
|
|
|
531
564
|
this.applyCounterMoveImp = options.applyCounterMoveImp ?? true;
|
|
532
565
|
this.counterMoveImpFactor = options.counterMoveImpFactor ?? 1;
|
|
533
566
|
this.initialGravityScale = options.gravityScale ?? 1;
|
|
567
|
+
this.maxExternalLinearSpeed = options.maxExternalLinearSpeed;
|
|
568
|
+
this.isPoseAllowed = options.isPoseAllowed;
|
|
569
|
+
this.onRecovery = options.onRecovery;
|
|
534
570
|
|
|
535
571
|
const curveData = options.massRatioFallOffCurveData ?? DEFAULT_CURVE_DATA;
|
|
536
572
|
this.massRatioFallOffCurve = bakeCurveLUT(curveData.points, curveData.samples ?? 50);
|
|
@@ -547,7 +583,10 @@ export class CharacterController {
|
|
|
547
583
|
.setCanSleep(options.canSleep ?? true)
|
|
548
584
|
.setGravityScale(this.initialGravityScale);
|
|
549
585
|
this._body = world.createRigidBody(bodyDesc);
|
|
586
|
+
this._body.enableCcd(options.ccd ?? true);
|
|
550
587
|
this._body.userData = options.userData ?? {};
|
|
588
|
+
this.recoveryPosition.set(position.x, position.y, position.z);
|
|
589
|
+
this.recoveryRotation.copy(rotation);
|
|
551
590
|
|
|
552
591
|
// Capsule args order matches the JSX args: (halfHeight, radius).
|
|
553
592
|
const colliderDesc = RAPIER.ColliderDesc.capsule(capsuleHalfHeight, this.capsuleRadius)
|
|
@@ -838,6 +877,27 @@ export class CharacterController {
|
|
|
838
877
|
this.lastInputDir.set(0, 0, 1).applyQuaternion(this.unparkQuat);
|
|
839
878
|
this.parked = false;
|
|
840
879
|
this._body.wakeUp();
|
|
880
|
+
// Camera/gameplay getters read these caches on the same exit frame, before
|
|
881
|
+
// the next controller update. Keep body, root, and cached truth aligned.
|
|
882
|
+
this.updateCharacterInfo();
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Atomically restore the body, visual root, velocities, and controller pose cache. The caller owns
|
|
887
|
+
* camera/interpolation/network discontinuity state; use `onRecovery` to update those in this frame.
|
|
888
|
+
*/
|
|
889
|
+
recover(position = this.recoveryPosition, rotation = this.recoveryRotation): void {
|
|
890
|
+
const p = finiteVec(position) ? position : this.recoveryPosition;
|
|
891
|
+
const q = finiteQuat(rotation) ? rotation : this.recoveryRotation;
|
|
892
|
+
this._body.setTranslation(p, false);
|
|
893
|
+
this._body.setRotation(q, false);
|
|
894
|
+
this._body.setLinvel(this.fixedZero, false);
|
|
895
|
+
this._body.setAngvel(this.fixedZero, false);
|
|
896
|
+
this.root.position.copy(p);
|
|
897
|
+
this.root.quaternion.copy(q);
|
|
898
|
+
this.lastInputDir.set(0, 0, 1).applyQuaternion(q);
|
|
899
|
+
this.updateCharacterInfo();
|
|
900
|
+
this._body.wakeUp();
|
|
841
901
|
}
|
|
842
902
|
|
|
843
903
|
/**
|
|
@@ -851,6 +911,7 @@ export class CharacterController {
|
|
|
851
911
|
// Skip the whole controller loop when disabled or parked
|
|
852
912
|
if (!this.enabled || this.parked) return;
|
|
853
913
|
const characterBody = this._body;
|
|
914
|
+
if (this.recoverUnsafePose()) return;
|
|
854
915
|
let isSleeping = characterBody.isSleeping();
|
|
855
916
|
|
|
856
917
|
// Correct frame rate difference
|
|
@@ -1028,6 +1089,35 @@ export class CharacterController {
|
|
|
1028
1089
|
this.currentAngVelOnUp.copy(this.currentAngVel).projectOnVector(this.characterYAxis);
|
|
1029
1090
|
}
|
|
1030
1091
|
|
|
1092
|
+
/** Recover before any impulses or publication can observe an unsafe pose. */
|
|
1093
|
+
private recoverUnsafePose(): boolean {
|
|
1094
|
+
const p = this._body.translation();
|
|
1095
|
+
const q = this._body.rotation();
|
|
1096
|
+
const v = this._body.linvel();
|
|
1097
|
+
const av = this._body.angvel();
|
|
1098
|
+
const finite = finiteVec(p) && finiteQuat(q) && finiteVec(v) && finiteVec(av);
|
|
1099
|
+
const allowed = finite && (this.isPoseAllowed?.(p) ?? true);
|
|
1100
|
+
if (!finite || !allowed) {
|
|
1101
|
+
const event: CharacterRecoveryEvent = {
|
|
1102
|
+
reason: finite ? "out-of-bounds" : "non-finite",
|
|
1103
|
+
position: { x: p.x, y: p.y, z: p.z },
|
|
1104
|
+
linearVelocity: { x: v.x, y: v.y, z: v.z },
|
|
1105
|
+
};
|
|
1106
|
+
this.recover();
|
|
1107
|
+
this.onRecovery?.(event);
|
|
1108
|
+
return true;
|
|
1109
|
+
}
|
|
1110
|
+
const max = this.maxExternalLinearSpeed;
|
|
1111
|
+
if (max !== undefined && Number.isFinite(max) && max > 0) {
|
|
1112
|
+
const speed = Math.hypot(v.x, v.y, v.z);
|
|
1113
|
+
if (speed > max) {
|
|
1114
|
+
const scale = max / speed;
|
|
1115
|
+
this._body.setLinvel({ x: v.x * scale, y: v.y * scale, z: v.z * scale }, true);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1031
1121
|
/**
|
|
1032
1122
|
* Update gravity/upAxis direction and value (upstream l.499-513; the custom
|
|
1033
1123
|
* gravity-field branch is dropped per the v1 port scope — world gravity may
|
|
@@ -356,6 +356,9 @@ export class DroneController {
|
|
|
356
356
|
this.addPropeller(propellerOptions);
|
|
357
357
|
}
|
|
358
358
|
}
|
|
359
|
+
// Cache the supplied body's real pose immediately. Without this, getters
|
|
360
|
+
// and the chassis can remain at zero until the first awake physics step.
|
|
361
|
+
this.updateVehicleInfo();
|
|
359
362
|
}
|
|
360
363
|
|
|
361
364
|
// ---- per-frame ----
|
|
@@ -595,6 +598,11 @@ export class DroneController {
|
|
|
595
598
|
|
|
596
599
|
// ---- internals ----
|
|
597
600
|
|
|
601
|
+
/** Refresh cached pose/velocity/axes immediately after an external body placement. */
|
|
602
|
+
syncFromBody(): void {
|
|
603
|
+
this.updateVehicleInfo();
|
|
604
|
+
}
|
|
605
|
+
|
|
598
606
|
/** Update vehicle collider pos/vel/quat/axis from the rigid body. */
|
|
599
607
|
private updateVehicleInfo(): void {
|
|
600
608
|
const translation = this.bodyRef.translation();
|
|
@@ -68,6 +68,8 @@ export interface VehicleUnitLike extends FollowTargetLike {
|
|
|
68
68
|
readonly bodyXAxis: THREE.Vector3;
|
|
69
69
|
/** World-space body Z axis (live vector). */
|
|
70
70
|
readonly bodyZAxis: THREE.Vector3;
|
|
71
|
+
/** Refresh cached pose/axes after an external body placement. */
|
|
72
|
+
syncFromBody(): void;
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
/**
|
|
@@ -479,10 +481,8 @@ export class EnterExitManager {
|
|
|
479
481
|
* Port of the upstream `computeExitTransform` — exact math and order.
|
|
480
482
|
* Writes `this.exitPos` / `this.exitRot`.
|
|
481
483
|
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
* produces NaN — upstream does not guard this either (realistic trigger:
|
|
485
|
-
* exiting a nose-down drone along its up axis).
|
|
484
|
+
* A flipped/nose-down vehicle can make bodyZ parallel to up. Establish a
|
|
485
|
+
* finite fallback basis before normalizing so an exit can never create NaN.
|
|
486
486
|
*/
|
|
487
487
|
private computeExitTransform(
|
|
488
488
|
vehicle: VehicleUnitLike,
|
|
@@ -491,7 +491,16 @@ export class EnterExitManager {
|
|
|
491
491
|
): void {
|
|
492
492
|
this.exitPos.copy(vehicle.currPos).addScaledVector(exitDirection, exitLength);
|
|
493
493
|
const up = vehicle.upAxis;
|
|
494
|
-
this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up)
|
|
494
|
+
this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up);
|
|
495
|
+
if (this.exitZAxis.lengthSq() < 1e-6) {
|
|
496
|
+
this.exitZAxis.copy(vehicle.bodyXAxis).projectOnPlane(up);
|
|
497
|
+
if (this.exitZAxis.lengthSq() < 1e-6) {
|
|
498
|
+
if (Math.abs(up.y) < 0.9) this.exitZAxis.set(0, 1, 0);
|
|
499
|
+
else this.exitZAxis.set(1, 0, 0);
|
|
500
|
+
this.exitZAxis.projectOnPlane(up);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
this.exitZAxis.normalize();
|
|
495
504
|
// Cross order matters: X = up x Z. Swapping mirrors the spawn basis.
|
|
496
505
|
this.exitXAxis.crossVectors(up, this.exitZAxis);
|
|
497
506
|
// makeBasis takes COLUMN vectors X, Y, Z.
|
|
@@ -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
|
+
}
|