@genex-ai/cli-demo 0.39.0 → 0.41.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 +34 -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 +273 -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 +92 -34
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +51 -29
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +39 -21
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +25 -6
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +28 -9
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +33 -10
- package/templates/controllers/shared/NETWORKING.md +0 -11
|
@@ -23,8 +23,9 @@ The genre where a single contested object is the whole game.
|
|
|
23
23
|
| Goal celebration / whistle | `send` | whoever scored / the host |
|
|
24
24
|
|
|
25
25
|
**Decisions:**
|
|
26
|
-
- **
|
|
27
|
-
`room.objects.
|
|
26
|
+
- **Confirm the ball claim on contact**, not every frame: when your player's collider touches the
|
|
27
|
+
ball, await `room.objects.claimConfirmed("ball")` and apply the kick impulse only after
|
|
28
|
+
`accepted`. If the result is `held`, retry after `retryAfterMs` only while contact still exists.
|
|
28
29
|
- **Only the owner simulates** the ball (`if (objects.get("ball")?.isMine) objects.set("ball", …)`
|
|
29
30
|
in the tick). Everyone draws `objects.get("ball").state`. Non-owner writes are dropped by the
|
|
30
31
|
relay, so two players kicking at once resolve to one owner — no fighting.
|
|
@@ -39,36 +40,56 @@ a contested kick settles on one owner within a snapshot; the owner leaving doesn
|
|
|
39
40
|
|
|
40
41
|
---
|
|
41
42
|
|
|
42
|
-
## Recipe 2 — Shooter / arena (
|
|
43
|
+
## Recipe 2 — Shooter / arena PvP (hitscan, melee, projectiles)
|
|
43
44
|
|
|
44
|
-
Fast players, instant hits, a live scoreboard. No shared physics object needed for hitscan.
|
|
45
|
+
Fast players, instant hits, a live scoreboard. No shared physics object needed for hitscan or melee.
|
|
46
|
+
(This is the exact model the netcode-park reference runs live: fists/swords, a hitscan rifle, and
|
|
47
|
+
slow physical projectiles, all against the same damage/defeat rules.)
|
|
45
48
|
|
|
46
49
|
| Thing | Channel | Authority |
|
|
47
50
|
| --- | --- | --- |
|
|
48
|
-
| Player pos + rotation + hp | `me.set` / `players` (hp read via `stateRaw`) | each player |
|
|
49
|
-
| A shot being fired | `send("
|
|
51
|
+
| Player pos + rotation + hp + `life` | `me.set` / `players` (hp/life read via `stateRaw`) | each player |
|
|
52
|
+
| A shot / swing being fired | `send("combat:attack", { from, seq, slot, targets, … })` | the attacker |
|
|
50
53
|
| Damage applied | victim's own `me.set` (hp) | the victim |
|
|
51
|
-
|
|
|
52
|
-
|
|
|
54
|
+
| A defeat (kill credit) | `send("combat:defeat", { victim, attacker, life })` | the victim |
|
|
55
|
+
| Score / round | `shared` (exactly-once marker — see SKILL.md host section) | `room.isHost` |
|
|
56
|
+
| Slow physical projectiles (grenades, rockets) | `objects` — one per projectile, **hard cap + `removeConfirmed`** | the thrower |
|
|
53
57
|
|
|
54
58
|
**Decisions:**
|
|
55
|
-
- **The
|
|
56
|
-
|
|
57
|
-
because `send` never echoes back to you.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
- **
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
- **
|
|
66
|
-
|
|
67
|
-
`
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
59
|
+
- **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
|
|
60
|
+
*you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
|
|
61
|
+
flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
|
|
62
|
+
victim occasionally "dies behind a wall"; that's inherent to server-less shooters — set the
|
|
63
|
+
expectation, don't chase it with more smoothing.
|
|
64
|
+
- **Melee is the same event, different hit-test:** a range + cone check over `players`'
|
|
65
|
+
`stateRaw` positions (plus a line-of-sight ray), targets sorted by distance — single-target for
|
|
66
|
+
a punch, every candidate in the arc for a sword sweep.
|
|
67
|
+
- **Skip dead targets up front:** ignore players whose `stateRaw.hp <= 0` in every hit-test, so a
|
|
68
|
+
corpse can't be re-hit during its respawn window.
|
|
69
|
+
- **The victim applies its own damage** on receiving an attack event that names it — each player
|
|
70
|
+
owns their own hp, so there's no write conflict. **Dedupe per attack:** the attacker stamps every
|
|
71
|
+
event with a `seq`; the victim keeps a small bounded set of handled `${from}:${seq}` keys and
|
|
72
|
+
ignores repeats (events can arrive more than once around blips).
|
|
73
|
+
- **Defeat is exactly-once:** at hp 0 the VICTIM broadcasts `combat:defeat { victim, attacker,
|
|
74
|
+
life }` — `life` is a counter in its player state that increments on every respawn, so everyone
|
|
75
|
+
(including the scoring host) dedupes defeats on the `${victim}:${life}` key. The HOST turns that
|
|
76
|
+
event into a point with the exactly-once marker write from the multiplayer skill's host section.
|
|
77
|
+
- **Respawn is a discontinuity:** reset hp, bump `life`, place the body at the spawn point, then
|
|
78
|
+
publish with `room.me.snap(state)` — never `set`, or remotes glide the corpse across the map.
|
|
79
|
+
- **Hit-tests read `stateRaw`, not `state`** — the raw latest position, not the render-delayed
|
|
80
|
+
smoothed one. Same for reading remote hp/life (discrete values; `state` would lerp them).
|
|
81
|
+
- **Hitscan/melee use `send`, never objects** — a shot is an event, not continuous state. Reserve
|
|
82
|
+
`objects` for a *slow, visible* projectile players can dodge (a rocket): `claim` on spawn,
|
|
83
|
+
`objects.set` its arc at ~30 Hz, `removeConfirmed` on impact/expiry, and a **hard per-player
|
|
84
|
+
cap** that removes the oldest live projectile before spawning a new one (netcode-park ships
|
|
85
|
+
cap = 2). Uncapped per-projectile spawns walk into the room's 128-object cap AND the message
|
|
86
|
+
budget; the cap is what kept projectiles smooth for everyone in live testing.
|
|
87
|
+
- **Scoreboard is host-only and keyed by the stable `uid`**, never the session id (see the
|
|
88
|
+
multiplayer skill's host section) — a reload must keep the player's points and color.
|
|
89
|
+
|
|
90
|
+
**Acceptance feel:** movement is smooth for 4–8 players; hits register on what you aimed at; a kill
|
|
91
|
+
scores exactly one point even if the host changes mid-fight; the scoreboard survives a reload; one
|
|
92
|
+
player on a throttled/backgrounded tab doesn't drag others.
|
|
72
93
|
|
|
73
94
|
---
|
|
74
95
|
|
|
@@ -85,9 +106,10 @@ and it's the host, so it survives players joining and leaving.
|
|
|
85
106
|
| Spawn flashes, hit sparks | `send` | the host / whoever hit |
|
|
86
107
|
|
|
87
108
|
**Decisions:**
|
|
88
|
-
- **The host owns and simulates the enemies.** On spawn, the host
|
|
89
|
-
|
|
90
|
-
|
|
109
|
+
- **The host owns and simulates the enemies.** On spawn, the host uses
|
|
110
|
+
`claimConfirmed(id, { authority: "host" })`; after acceptance its tick runs the AI and
|
|
111
|
+
`objects.set`s each enemy. Non-host clients never simulate enemies — they just draw
|
|
112
|
+
`objects.get("enemy:n").state` (smoothed) and read `stateRaw` for hit-tests.
|
|
91
113
|
- **One object per enemy** (flat `{x,y,z,hp}`) so each smooths independently. For a big horde keep
|
|
92
114
|
the count modest (≈8–16 active); it's casual, not a bullet-hell server.
|
|
93
115
|
- **Host migration keeps the game alive:** if the host leaves, its enemies are reassigned to the
|
|
@@ -98,7 +120,7 @@ and it's the host, so it survives players joining and leaving.
|
|
|
98
120
|
`send` never echoes to the sender — so when the **host itself** shoots an enemy it owns, it must
|
|
99
121
|
apply that damage to its local enemy sim **directly**, not via `send` (which wouldn't come back).
|
|
100
122
|
Rule of thumb: if `objects.get("enemy:3")?.isMine`, apply the hit locally; otherwise `send` it.
|
|
101
|
-
Enemy death: the host `objects.
|
|
123
|
+
Enemy death: the host awaits `objects.removeConfirmed("enemy:3")` before finalizing rewards.
|
|
102
124
|
- **Waves/score are host-only** in `shared`; late joiners read the current wave on connect.
|
|
103
125
|
|
|
104
126
|
**Acceptance feel:** enemies move smoothly for everyone; killing the host's tab mid-wave promotes a
|
|
@@ -19,6 +19,17 @@ ownership to "whoever touched last" is itself the wrong model.
|
|
|
19
19
|
|
|
20
20
|
## Tier 1 — pushable / ownable body (claim-on-touch + a Rapier proxy)
|
|
21
21
|
|
|
22
|
+
Install the product helper first:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
genex controller networked-physics
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`NetworkedPushable` owns the confirmed-claim/retry, dynamic-to-kinematic switch, visible-proxy pose
|
|
29
|
+
plus raw-momentum handoff seed,
|
|
30
|
+
single-smoothed follower, moved-only publishing, keepalive, release-on-rest, finite pose validation,
|
|
31
|
+
and host reset snap. Prefer that helper over copying this explanatory state machine.
|
|
32
|
+
|
|
22
33
|
The object is a REAL Rapier body on every client, and its body TYPE follows ownership:
|
|
23
34
|
|
|
24
35
|
- **You own it → a `dynamic` body.** Your (dynamic) character capsule collides with it and Rapier's
|
|
@@ -45,14 +56,16 @@ const body = physics.createBody({
|
|
|
45
56
|
userData: { controller: { excludeCharacterRay: true } },
|
|
46
57
|
});
|
|
47
58
|
ballCollider(physics.world, body, RADIUS, { friction: 0.5, restitution: 0.35, density: 0.6 });
|
|
48
|
-
let
|
|
59
|
+
let touching = false, wasMine = false, pendingClaim: Promise<unknown> | null = null, retryAt = 0;
|
|
49
60
|
const MAX_SPEED = 24;
|
|
50
61
|
|
|
51
62
|
// The host seeds the canonical spawn ONCE so the object exists on `objects` for late joiners.
|
|
52
|
-
function ensureInit() {
|
|
63
|
+
async function ensureInit() {
|
|
53
64
|
if (room.isHost && room.objects.get("ball") === undefined) {
|
|
54
|
-
room.objects.
|
|
55
|
-
|
|
65
|
+
const result = await room.objects.claimConfirmed("ball", { authority: "host" });
|
|
66
|
+
if (!result.accepted) return;
|
|
67
|
+
room.objects.snap("ball", { x, y, z, q: [0, 0, 0, 1], vx: 0, vy: 0, vz: 0 });
|
|
68
|
+
await room.objects.releaseConfirmed("ball");
|
|
56
69
|
}
|
|
57
70
|
}
|
|
58
71
|
|
|
@@ -61,18 +74,17 @@ function updateBall(dt: number) {
|
|
|
61
74
|
let v = room.objects.get<BallState>("ball");
|
|
62
75
|
let mine = !!v?.isMine;
|
|
63
76
|
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// at ground level while the ball centre is at y≈RADIUS, so a 3D distance would eat into your reach.
|
|
77
|
+
// Keep contact validity alive. One rejected collision edge must not consume the attempt forever:
|
|
78
|
+
// retry after the relay hold delay while STILL touching, with at most one request in flight.
|
|
67
79
|
const dx = ballMesh.position.x - feet.x, dz = ballMesh.position.z - feet.z;
|
|
68
|
-
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
touching = onFoot && Math.hypot(dx, dz) < RADIUS + REACH;
|
|
81
|
+
if (touching && !mine && !pendingClaim && performance.now() >= retryAt) {
|
|
82
|
+
pendingClaim = room.objects.claimConfirmed("ball").then((result) => {
|
|
83
|
+
if (!result.accepted && touching && (result.reason === "held" || result.reason === "rate-limited")) {
|
|
84
|
+
retryAt = performance.now() + (result.retryAfterMs ?? 50); // both carry a server retry delay — honor it
|
|
85
|
+
} else retryAt = 0;
|
|
86
|
+
}).finally(() => { pendingClaim = null; });
|
|
74
87
|
}
|
|
75
|
-
wasTouching = touching;
|
|
76
88
|
|
|
77
89
|
if (mine && !wasMine) {
|
|
78
90
|
// Gained ownership → dynamic. SEED velocity from the wire (NEVER zero, or a rolling ball
|
|
@@ -121,15 +133,16 @@ function publishBall() {
|
|
|
121
133
|
|
|
122
134
|
Rules that make it correct:
|
|
123
135
|
|
|
124
|
-
- **
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
- **Sustained-contact confirmed claim:** keep one request pending; after `held`, retry at
|
|
137
|
+
`retryAfterMs` only while contact still exists. Never spam every frame and never let one rejected
|
|
138
|
+
collision edge permanently disable the interaction.
|
|
127
139
|
- **Never zero velocity on claim** — seed `linvel` from the published `vx/vy/vz`, or a rolling ball snaps
|
|
128
140
|
to a dead stop on every handoff.
|
|
129
141
|
- **Publish velocity, not just position** — the next owner seeds from it, so the object keeps its motion
|
|
130
142
|
across the handoff.
|
|
131
143
|
- **Release-on-rest** (optional): once you own an object that's been at rest + untouched a couple of
|
|
132
|
-
seconds, `room.objects.
|
|
144
|
+
seconds, `await room.objects.releaseConfirmed("ball")` so ownership doesn't pile up on a lone
|
|
145
|
+
wanderer — its last
|
|
133
146
|
state persists on the wire for the next toucher, who re-claims.
|
|
134
147
|
- **Distributed by construction:** each client publishes only what it currently owns, so the message
|
|
135
148
|
budget spreads across players instead of funnelling every object through one host's send budget. This
|
|
@@ -168,13 +181,14 @@ room.inputs.on((fromId, payload) => {
|
|
|
168
181
|
if (p?.obj && Array.isArray(p.push)) pending.push(p as { obj: string; push: number[] });
|
|
169
182
|
});
|
|
170
183
|
|
|
171
|
-
room.onHostTick(30, (dtMs) => {
|
|
184
|
+
room.onHostTick(30, async (dtMs) => {
|
|
172
185
|
// 1) First tick after election: adopt the objects + seed the physics world from the
|
|
173
186
|
// last published truth (stateRaw) — NEVER from zero, or the world teleports.
|
|
174
187
|
for (const id of CONTESTED_IDS) {
|
|
175
188
|
const view = room.objects.get(id);
|
|
176
|
-
if (!view
|
|
177
|
-
room.objects.
|
|
189
|
+
if (!view?.isMine) {
|
|
190
|
+
const result = await room.objects.claimConfirmed(id, { authority: "host" });
|
|
191
|
+
if (!result.accepted) continue;
|
|
178
192
|
seedRapierBody(id, view?.stateRaw); // position/rotation/velocity from the wire
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -201,6 +215,10 @@ for (const id of CONTESTED_IDS) {
|
|
|
201
215
|
|
|
202
216
|
Rules that make it correct:
|
|
203
217
|
|
|
218
|
+
- `onHostTick` pauses during reconnect and stops on demotion/leave/terminal disconnect. Bound the input
|
|
219
|
+
queue, attribute input from the callback's authenticated `fromId` (never payload `from`), and discard
|
|
220
|
+
stale pre-failover inputs when a new host adopts raw state.
|
|
221
|
+
|
|
204
222
|
- **Sim internals that must survive migration** (velocities, cooldowns, aggro) either live in
|
|
205
223
|
the published object state or get mirrored at low rate into a dedicated object
|
|
206
224
|
(`objects.set("sim", …)`) the next host reads on adoption. Positions/rotations come free
|
|
@@ -104,9 +104,10 @@ ownership changes (no snap), and the object survives its owner leaving (reassign
|
|
|
104
104
|
// ball.ts
|
|
105
105
|
const ball = { x: 0, y: 0.5, z: 0, vx: 0, vy: 0, vz: 0 }; // the owner's local sim
|
|
106
106
|
|
|
107
|
-
//
|
|
108
|
-
function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
-
room.objects.
|
|
107
|
+
// Confirm on the contact edge; retry a `held` result only while contact remains.
|
|
108
|
+
async function kick(dir: THREE.Vector3, power: number) {
|
|
109
|
+
const result = await room.objects.claimConfirmed("ball");
|
|
110
|
+
if (!result.accepted) return;
|
|
110
111
|
ball.vx += dir.x * power; ball.vz += dir.z * power;
|
|
111
112
|
}
|
|
112
113
|
|
|
@@ -129,13 +130,31 @@ function drawBall() {
|
|
|
129
130
|
|
|
130
131
|
Rules that keep it correct:
|
|
131
132
|
|
|
132
|
-
- **
|
|
133
|
+
- **Confirm ownership before an irreversible interaction.** On `held`, respect `retryAfterMs` and
|
|
134
|
+
retry only while the original contact/action is still valid.
|
|
133
135
|
- **Keep object fields flat** (`x/y/z`, a 4-number quaternion). Nested objects snap instead of glide.
|
|
134
136
|
- **Only the owner's `set` lands** — the relay drops writes from non-owners, so you never get two
|
|
135
137
|
clients fighting over the ball. You don't need to check "am I owner" before drawing, only before
|
|
136
138
|
simulating.
|
|
137
|
-
- **Transient objects** (bullets, pickups): `room.objects.
|
|
138
|
-
|
|
139
|
+
- **Transient objects** (bullets, pickups): the owner awaits `room.objects.removeConfirmed(id)`
|
|
140
|
+
when they expire, so destruction is acknowledged and idempotent across reconnects. **Always cap
|
|
141
|
+
them**: every spawned id counts against the room's 128-object limit until removed, and each live
|
|
142
|
+
projectile publishing at 30 Hz spends 30/s of your bulk budget. Keep a small per-player cap
|
|
143
|
+
(2–3, netcode-park ships 2) and remove the OLDEST before spawning past it:
|
|
144
|
+
|
|
145
|
+
```ts
|
|
146
|
+
const live = new Map<string, Projectile>(); // insertion order = age
|
|
147
|
+
function spawnProjectile(id: string, state: Record<string, unknown>) {
|
|
148
|
+
while (live.size >= MAX_LIVE_PROJECTILES) {
|
|
149
|
+
const oldest = live.keys().next().value!;
|
|
150
|
+
live.delete(oldest);
|
|
151
|
+
void room.objects.removeConfirmed(oldest); // acknowledged — no ghost debris for late joiners
|
|
152
|
+
}
|
|
153
|
+
room.objects.claim(id);
|
|
154
|
+
room.objects.set(id, state);
|
|
155
|
+
live.set(id, makeProjectile(id));
|
|
156
|
+
}
|
|
157
|
+
```
|
|
139
158
|
|
|
140
159
|
## Host authority (scores, rounds, world)
|
|
141
160
|
|
|
@@ -29,6 +29,9 @@ Existing files are never overwritten (use `--force` to refresh). For the
|
|
|
29
29
|
on-foot character that enters these vehicles, also run
|
|
30
30
|
`npx genex controller character` and load `$genex-threejs-character-controller`.
|
|
31
31
|
|
|
32
|
+
These files become game-owned. Do not use `--force` as an upgrade mechanism after editing them;
|
|
33
|
+
install into a scratch project and port the targeted `syncFromBody`/transition changes.
|
|
34
|
+
|
|
32
35
|
## The loop contract (get this right first)
|
|
33
36
|
|
|
34
37
|
Every controller exposes `update(dt?)` that must run once per **fixed physics
|
|
@@ -72,7 +75,11 @@ games with no on-foot character.
|
|
|
72
75
|
VELOCITY vs POSITION control modes, the PD gain mass-scaling rule, presets.
|
|
73
76
|
- [references/enter-exit.md](references/enter-exit.md) — `EnterExitManager`,
|
|
74
77
|
park/unpark, sensor tuning, seat animations (`Sitting_Enter` /
|
|
75
|
-
`Driving_Loop` / `Sitting_Exit`), follow-camera handoff
|
|
78
|
+
`Driving_Loop` / `Sitting_Exit`), follow-camera handoff — **including the
|
|
79
|
+
touch interact button**: on phones there is no F key, so the enter prompt
|
|
80
|
+
must be tappable (and an Exit button shown while driving) or the shared
|
|
81
|
+
link is unenterable on mobile. Wire it whenever you wire the touch
|
|
82
|
+
joysticks (`navigator.maxTouchPoints > 0`).
|
|
76
83
|
|
|
77
84
|
## Presets at a glance
|
|
78
85
|
|
|
@@ -106,20 +113,32 @@ On enter/exit: the on-foot character **disappears into the vehicle on enter
|
|
|
106
113
|
behavior, not a bug. Don't leave the character mesh standing next to a car it
|
|
107
114
|
is supposedly driving; see [references/enter-exit.md](references/enter-exit.md).
|
|
108
115
|
|
|
109
|
-
## Multiplayer: vehicle occupancy is
|
|
116
|
+
## Multiplayer: vehicle occupancy is confirmed object ownership
|
|
110
117
|
|
|
111
118
|
If the game is multiplayer, **who is in what vehicle must be synced** — a
|
|
112
119
|
remote player vanishing into an apparently empty car reads as a bug. Load
|
|
113
120
|
`$genex-threejs-multiplayer` before writing any networking code, then:
|
|
114
121
|
|
|
115
|
-
-
|
|
116
|
-
|
|
122
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`. A seat is the vehicle
|
|
123
|
+
object's confirmed owner—not a writable `shared` key. Enter only after `claimConfirmed` accepts;
|
|
124
|
+
two simultaneous entrants therefore produce one driver and one clean loser.
|
|
125
|
+
- **Occupied means owned.** Idle vehicles stay unowned, so a present owner IS the current driver —
|
|
126
|
+
`enter()` refuses an owned vehicle by default (the relay's short hold only protects the first
|
|
127
|
+
~300 ms of a drive, so without this gate any walk-up claim would hijack a moving car and dump its
|
|
128
|
+
driver). Pass `enter({ steal: true })` only when carjacking is an intended mechanic, and wire
|
|
129
|
+
`onSeatLost` so a forcibly dismounted driver returns to on-foot controls instead of a dead seat.
|
|
130
|
+
- Publish `driving: "car" | null` in the player's state for remote visuals. Use `me.snap` on entry/exit
|
|
131
|
+
mode edges, and continuous `objects.set` only from the confirmed driver.
|
|
117
132
|
- Same authority rule as the character: the **local player simulates the
|
|
118
|
-
physics of whatever they occupy**; remote vehicles are
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
133
|
+
physics of whatever they occupy**; remote vehicles are visuals only —
|
|
134
|
+
draw the SDK-smoothed `objects.get(id).state` directly on a plain mesh
|
|
135
|
+
(never re-lerp/buffer it) and never run `VehicleController`/
|
|
136
|
+
`DroneController` for a remote player.
|
|
137
|
+
- Idle vehicles remain unowned at their last raw pose. The host may explicitly claim+snap+release to
|
|
138
|
+
seed/reset, but must not continuously adopt or republish parked vehicles. Persist raw idle truth.
|
|
139
|
+
- On accepted entry, seed the body from `stateRaw`, enable/register/wake it, call `syncFromBody`, and
|
|
140
|
+
`snapBodyInterpolation` before controls/camera read it. On exit, publish final pose, player-snap the
|
|
141
|
+
character, unregister, then wait for confirmed release.
|
|
123
142
|
|
|
124
143
|
## Boundaries and troubleshooting
|
|
125
144
|
|
|
@@ -70,6 +70,20 @@ physics.onCollisionEvent((h1, h2, started) =>
|
|
|
70
70
|
// Interact: EDGE-triggered (key transition, never a held-key poll — that
|
|
71
71
|
// would enter and exit every frame). kb.onInteract is the F key, pre-guarded.
|
|
72
72
|
kb.onInteract(() => mgr.requestInteract());
|
|
73
|
+
|
|
74
|
+
// TOUCH DEVICES: there is no F key on a phone — make the SAME prompt tappable, or the
|
|
75
|
+
// game is unenterable on the shared link. One button, the exact same code path as the key
|
|
76
|
+
// (claim-before-park in multiplayer included); it doubles as the Exit button while driving.
|
|
77
|
+
if (navigator.maxTouchPoints > 0) {
|
|
78
|
+
promptEl.style.pointerEvents = "auto"; // the prompt div IS the button
|
|
79
|
+
promptEl.addEventListener("pointerdown", (e) => {
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
onInteractPressed(); // whatever your F-key handler calls
|
|
82
|
+
});
|
|
83
|
+
// While driving there's no proximity prompt — show a fixed "Exit" button instead:
|
|
84
|
+
exitBtn.style.display = mgrIsDriving ? "block" : "none"; // toggle in onHandoff
|
|
85
|
+
exitBtn.addEventListener("pointerdown", () => mgr.requestInteract());
|
|
86
|
+
}
|
|
73
87
|
```
|
|
74
88
|
|
|
75
89
|
Loop order per fixed substep (inside `physics.onBeforeStep`): **`mgr.update(dt)`
|
|
@@ -98,9 +112,9 @@ registered vehicle wins the prompt.
|
|
|
98
112
|
reappears. Raise it if the character spawns inside a wide chassis. The
|
|
99
113
|
default equals the default sensor radius so immediate re-entry stays
|
|
100
114
|
possible — intended.
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
115
|
+
- Flipped/nose-down vehicles are supported: the manager projects the live forward axis and falls back
|
|
116
|
+
to body-X/world-Z before normalization, so the exit basis remains finite. Still choose an
|
|
117
|
+
`exitLength` that clears the actual chassis collider.
|
|
104
118
|
|
|
105
119
|
## Seat visuals: Sitting_Enter / Driving_Loop / Sitting_Exit
|
|
106
120
|
|
|
@@ -186,15 +200,24 @@ far-from-Y gravity direction will misbehave. For rigs beyond this, see
|
|
|
186
200
|
|
|
187
201
|
## Multiplayer
|
|
188
202
|
|
|
189
|
-
Occupancy
|
|
203
|
+
Occupancy uses confirmed object ownership — remote players must see who is in what. With
|
|
190
204
|
`$genex-threejs-multiplayer` (load it before writing any networking code):
|
|
191
205
|
|
|
192
206
|
- Put `driving: "car" | "drone" | null` in each player's synced state; on
|
|
193
207
|
remote change, show/hide that player's on-foot avatar and seat their
|
|
194
208
|
visual in the vehicle (the seat-visual pattern above, minus the physics).
|
|
195
|
-
- The occupant simulates the vehicle and publishes its pose; everyone else
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
209
|
+
- The occupant simulates the vehicle and publishes its pose; everyone else draws the
|
|
210
|
+
SDK-smoothed `objects.get(vehicleId).state` directly on a plain mesh — the SDK already
|
|
211
|
+
interpolates it. Never buffer/lerp that `state` a second time, and never run the vehicle
|
|
212
|
+
controllers for a remote player's vehicle.
|
|
213
|
+
- Install `genex controller networked-physics` and use `NetworkedVehicle`: gate entry on
|
|
214
|
+
`NetworkedVehicle.enter()` / `objects.claimConfirmed(vehicleId)` **before** parking the
|
|
215
|
+
character or switching the `EnterExitManager` active unit — a rejected seat must leave the
|
|
216
|
+
on-foot prompt and controls untouched. Do NOT put the claim inside `onOccupantEnter` after the
|
|
217
|
+
park: `performInteract` has no rollback, so a rejection there strands a hidden character wired
|
|
218
|
+
to a vehicle it never won. Never use `shared.set("occupant:car", ...)` as a lock—any client can
|
|
219
|
+
overwrite it. An accepted seat seeds from raw pose, calls `syncFromBody` +
|
|
220
|
+
`snapBodyInterpolation`, and becomes the only publisher.
|
|
221
|
+
- Exit publishes final vehicle truth, uses `me.snap` for the player mode edge, unregisters the local
|
|
222
|
+
body, and waits for `releaseConfirmed`. Idle vehicles stay unowned; host seed/reset is the narrow
|
|
223
|
+
`{ authority: "host" }` claim+snap+release path.
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
The complete recipe (what to publish per controller, remote playback, contested-contact
|
|
9
|
-
physics via the host) lives in the multiplayer skill:
|
|
10
|
-
`genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
|
|
11
|
-
any networking code.
|