@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
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
|
|
|
@@ -184,6 +184,27 @@ GENEX_BROWSER="open -a Safari" genex init
|
|
|
184
184
|
GENEX_BROWSER="'/Applications/My Browser.app/Contents/MacOS/My Browser'" genex init
|
|
185
185
|
```
|
|
186
186
|
|
|
187
|
+
## Crash reporting
|
|
188
|
+
|
|
189
|
+
When a command hits an **unexpected** error, the CLI reports the crash to Sentry
|
|
190
|
+
so we can fix it. Expected failures (missing token, a rejected API request, bad
|
|
191
|
+
input) are just printed — they're not sent. Before anything leaves your machine
|
|
192
|
+
it's scrubbed: your `GENEX_TOKEN` and any credentials, credential-bearing URLs
|
|
193
|
+
(e.g. the per-push git URL), your home-directory paths and hostname, and
|
|
194
|
+
stack-frame locals are all stripped ([`src/lib/sentry-scrub.ts`](src/lib/sentry-scrub.ts)).
|
|
195
|
+
No IP address or account identity is attached. Only the crash, the command name,
|
|
196
|
+
and your CLI/Node/OS versions are sent.
|
|
197
|
+
|
|
198
|
+
Opt out any time:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
export GENEX_TELEMETRY=0 # our switch (on by default)
|
|
202
|
+
export DO_NOT_TRACK=1 # the cross-tool standard — any value disables it
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
Reporting is also off whenever no DSN is baked into the build (the default in
|
|
206
|
+
local/source runs), so nothing is sent during development.
|
|
207
|
+
|
|
187
208
|
## How authorization works
|
|
188
209
|
|
|
189
210
|
The CLI uses a loopback-redirect flow (the same pattern as `gh auth login` and
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
4
|
-
import
|
|
3
|
+
// src/instrument.ts
|
|
4
|
+
import * as Sentry from "@sentry/node";
|
|
5
5
|
|
|
6
6
|
// src/config.ts
|
|
7
7
|
import fs from "fs";
|
|
@@ -89,6 +89,107 @@ function isDir(p) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
// src/lib/sentry-scrub.ts
|
|
93
|
+
import os2 from "os";
|
|
94
|
+
var BEARER = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
|
|
95
|
+
var URL_CRED = /(https?:\/\/)[^/@\s:]+(?::[^/@\s]*)?@/gi;
|
|
96
|
+
var TOKENISH_KV = /((?:token|secret|password|api[_-]?key|authorization)=)[^&#\s"']+/gi;
|
|
97
|
+
var SENSITIVE_KEY = /token|secret|password|api[_-]?key|authorization|bearer/i;
|
|
98
|
+
function scrubString(s) {
|
|
99
|
+
return s.replace(URL_CRED, "$1[redacted]@").replace(BEARER, "Bearer [redacted]").replace(TOKENISH_KV, "$1[redacted]");
|
|
100
|
+
}
|
|
101
|
+
function redactHome(s, home = os2.homedir()) {
|
|
102
|
+
if (!home) return s;
|
|
103
|
+
return s.split(home).join("~");
|
|
104
|
+
}
|
|
105
|
+
var clean = (s) => redactHome(scrubString(s));
|
|
106
|
+
function scrubEvent(event) {
|
|
107
|
+
delete event.user;
|
|
108
|
+
delete event.server_name;
|
|
109
|
+
if (typeof event.message === "string") event.message = clean(event.message);
|
|
110
|
+
for (const ex of event.exception?.values ?? []) {
|
|
111
|
+
if (typeof ex.value === "string") ex.value = clean(ex.value);
|
|
112
|
+
for (const f of ex.stacktrace?.frames ?? []) {
|
|
113
|
+
if (typeof f.filename === "string") f.filename = redactHome(f.filename);
|
|
114
|
+
if (typeof f.abs_path === "string") f.abs_path = redactHome(f.abs_path);
|
|
115
|
+
delete f.vars;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (event.request && typeof event.request.url === "string") {
|
|
119
|
+
event.request.url = clean(event.request.url);
|
|
120
|
+
}
|
|
121
|
+
for (const b of event.breadcrumbs ?? []) {
|
|
122
|
+
if (typeof b.message === "string") b.message = clean(b.message);
|
|
123
|
+
const d = b.data;
|
|
124
|
+
if (d) {
|
|
125
|
+
for (const k of Object.keys(d)) {
|
|
126
|
+
if (typeof d[k] === "string") d[k] = clean(d[k]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const bag of [event.extra, event.tags]) {
|
|
131
|
+
if (!bag) continue;
|
|
132
|
+
for (const k of Object.keys(bag)) {
|
|
133
|
+
if (SENSITIVE_KEY.test(k)) bag[k] = "[Filtered]";
|
|
134
|
+
else if (typeof bag[k] === "string") bag[k] = clean(bag[k]);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return event;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// src/instrument.ts
|
|
141
|
+
var GENEX_CLI_DSN = "https://43efc6f7d16c3e67cad6c60fa8175c20@o4511115493900288.ingest.us.sentry.io/4511706579599360";
|
|
142
|
+
function bakedDsn() {
|
|
143
|
+
return import.meta.url.includes("/dist/") ? GENEX_CLI_DSN : "";
|
|
144
|
+
}
|
|
145
|
+
function resolveDsn() {
|
|
146
|
+
return (process.env.GENEX_SENTRY_DSN || bakedDsn()).trim();
|
|
147
|
+
}
|
|
148
|
+
function isTruthy(v) {
|
|
149
|
+
return v !== void 0 && v.trim() !== "" && !/^(0|false|off|no)$/i.test(v.trim());
|
|
150
|
+
}
|
|
151
|
+
function telemetryDisabled() {
|
|
152
|
+
if (isTruthy(process.env.DO_NOT_TRACK)) return true;
|
|
153
|
+
if (process.env.GENEX_TELEMETRY !== void 0 && !isTruthy(process.env.GENEX_TELEMETRY)) return true;
|
|
154
|
+
if (isTruthy(process.env.GENEX_DISABLE_SENTRY)) return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
var dsn = resolveDsn();
|
|
158
|
+
var sentryEnabled = !!dsn && !telemetryDisabled();
|
|
159
|
+
if (sentryEnabled) {
|
|
160
|
+
Sentry.init({
|
|
161
|
+
dsn,
|
|
162
|
+
// release aligns with the AG-757 `x-genex-cli-version` telemetry stream so
|
|
163
|
+
// the two correlate; environment is prod unless pointed at a non-default API.
|
|
164
|
+
release: `@genex-ai/cli-demo@${getCliVersion()}`,
|
|
165
|
+
environment: getApiUrl() === DEFAULT_API_URL ? "production" : "development",
|
|
166
|
+
// A short-lived CLI has no meaningful tracing workload — errors only.
|
|
167
|
+
tracesSampleRate: 0,
|
|
168
|
+
// No IP / user auto-collection (opposite of the server apps' `userInfo: true`).
|
|
169
|
+
dataCollection: { userInfo: false },
|
|
170
|
+
// Keep @sentry/node's default onUncaughtException + onUnhandledRejection
|
|
171
|
+
// integrations (auto-registered) — they catch the fire-and-forget async
|
|
172
|
+
// paths in lib/updates.ts / lib/deploy.ts that otherwise swallow errors.
|
|
173
|
+
beforeSend(event) {
|
|
174
|
+
scrubEvent(event);
|
|
175
|
+
return event;
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
async function flushSentry(timeoutMs = 2e3) {
|
|
180
|
+
if (!sentryEnabled) return;
|
|
181
|
+
try {
|
|
182
|
+
await Sentry.flush(timeoutMs);
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// src/index.ts
|
|
188
|
+
import * as Sentry2 from "@sentry/node";
|
|
189
|
+
|
|
190
|
+
// src/commands/init.ts
|
|
191
|
+
import path8 from "path";
|
|
192
|
+
|
|
92
193
|
// src/lib/copy-templates.ts
|
|
93
194
|
import fs2 from "fs/promises";
|
|
94
195
|
import path2 from "path";
|
|
@@ -604,10 +705,10 @@ var structuredPrinted = /* @__PURE__ */ new WeakSet();
|
|
|
604
705
|
function printedStructuredError(res) {
|
|
605
706
|
return structuredPrinted.has(res);
|
|
606
707
|
}
|
|
607
|
-
async function apiFetch(url,
|
|
608
|
-
const headers = new Headers(
|
|
708
|
+
async function apiFetch(url, init2 = {}) {
|
|
709
|
+
const headers = new Headers(init2.headers);
|
|
609
710
|
if (!headers.has(CLI_VERSION_HEADER)) headers.set(CLI_VERSION_HEADER, getCliVersion());
|
|
610
|
-
const res = await fetch(url, { ...
|
|
711
|
+
const res = await fetch(url, { ...init2, headers });
|
|
611
712
|
if (res.status === 426) {
|
|
612
713
|
try {
|
|
613
714
|
const body = await res.clone().json();
|
|
@@ -1157,7 +1258,7 @@ async function listOwnSlugs(apiUrl, token, log) {
|
|
|
1157
1258
|
import { spawn as spawn3 } from "child_process";
|
|
1158
1259
|
import crypto3 from "crypto";
|
|
1159
1260
|
import fs9 from "fs/promises";
|
|
1160
|
-
import
|
|
1261
|
+
import os3 from "os";
|
|
1161
1262
|
import path10 from "path";
|
|
1162
1263
|
function run(cmd, args, env) {
|
|
1163
1264
|
return new Promise((resolve) => {
|
|
@@ -1354,7 +1455,7 @@ async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
|
1354
1455
|
log.error("Couldn't save your game's source \u2014 please try again.");
|
|
1355
1456
|
return false;
|
|
1356
1457
|
};
|
|
1357
|
-
const gitDir = await fs9.mkdtemp(path10.join(
|
|
1458
|
+
const gitDir = await fs9.mkdtemp(path10.join(os3.tmpdir(), "genex-source-"));
|
|
1358
1459
|
const base = { GIT_DIR: gitDir };
|
|
1359
1460
|
const ident = {
|
|
1360
1461
|
GIT_AUTHOR_NAME: "genex",
|
|
@@ -2264,7 +2365,12 @@ function formatMb(bytes) {
|
|
|
2264
2365
|
}
|
|
2265
2366
|
|
|
2266
2367
|
// src/commands/controller.ts
|
|
2267
|
-
var CONTROLLER_KINDS = [
|
|
2368
|
+
var CONTROLLER_KINDS = [
|
|
2369
|
+
"character",
|
|
2370
|
+
"car",
|
|
2371
|
+
"drone",
|
|
2372
|
+
"networked-physics"
|
|
2373
|
+
];
|
|
2268
2374
|
var SHARED = [
|
|
2269
2375
|
"shared/math.ts",
|
|
2270
2376
|
"shared/physics-world.ts",
|
|
@@ -2342,6 +2448,23 @@ var CONTROLLER_FILE_SETS = {
|
|
|
2342
2448
|
`const drone = new DroneController({ world: physics.world, body, chassis, propellers, config: dronePresets["camera-drone"].config });`,
|
|
2343
2449
|
`physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
|
|
2344
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
|
+
]
|
|
2345
2468
|
}
|
|
2346
2469
|
};
|
|
2347
2470
|
var CODE_DEST = path13.join("src", "controllers");
|
|
@@ -2356,7 +2479,7 @@ async function runController(opts) {
|
|
|
2356
2479
|
if (!kind || !CONTROLLER_KINDS.includes(kind)) {
|
|
2357
2480
|
log.error(
|
|
2358
2481
|
`Missing or unknown controller type${kind ? ` "${kind}"` : ""}. Usage: ${c.cyan(
|
|
2359
|
-
"genex controller <character|car|drone> [--force]"
|
|
2482
|
+
"genex controller <character|car|drone|networked-physics> [--force]"
|
|
2360
2483
|
)} or ${c.cyan("genex controller anims <tag|clip \u2026>")}`
|
|
2361
2484
|
);
|
|
2362
2485
|
process.exitCode = 1;
|
|
@@ -2407,7 +2530,7 @@ async function runController(opts) {
|
|
|
2407
2530
|
log.plain(c.bold("Next steps"));
|
|
2408
2531
|
log.plain(
|
|
2409
2532
|
` 1. ${c.cyan(
|
|
2410
|
-
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"
|
|
2411
2534
|
)} (three is already in the scaffold).`
|
|
2412
2535
|
);
|
|
2413
2536
|
log.plain(` 2. Load the ${c.cyan(set.skill)} skill for wiring, presets, and tuning.`);
|
|
@@ -2540,7 +2663,8 @@ ${c.bold("Usage")}
|
|
|
2540
2663
|
genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
|
|
2541
2664
|
genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
|
|
2542
2665
|
genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
|
|
2543
|
-
genex controller <type> [--force] Install a physics controller
|
|
2666
|
+
genex controller <type> [--force] Install a physics controller
|
|
2667
|
+
(character|car|drone|networked-physics)
|
|
2544
2668
|
into src/controllers (+ assets into public/assets).
|
|
2545
2669
|
genex controller anims <sel \u2026> Download extra character animation clips by tag or
|
|
2546
2670
|
exact name (sword, stealth, Celebration, \u2026) into
|
|
@@ -2619,6 +2743,8 @@ ${c.bold("Environment")}
|
|
|
2619
2743
|
GENEX_API_URL Overrides the default API base URL.
|
|
2620
2744
|
GENEX_COLYSEUS_URL Overrides the default multiplayer URL.
|
|
2621
2745
|
GENEX_BROWSER Command used to open the browser (falls back to BROWSER).
|
|
2746
|
+
GENEX_TELEMETRY Set to 0 to disable anonymous crash reporting (Sentry).
|
|
2747
|
+
DO_NOT_TRACK Standard opt-out; any value disables crash reporting.
|
|
2622
2748
|
|
|
2623
2749
|
${c.bold("Examples")}
|
|
2624
2750
|
genex init my-game
|
|
@@ -2637,6 +2763,7 @@ ${c.bold("Examples")}
|
|
|
2637
2763
|
genex image "neon graffiti tag, spray-paint style" --transparent
|
|
2638
2764
|
genex video "swirling neon plasma, seamless loop" --loop
|
|
2639
2765
|
genex controller character
|
|
2766
|
+
genex controller networked-physics
|
|
2640
2767
|
genex explore "grass"
|
|
2641
2768
|
genex explore
|
|
2642
2769
|
`;
|
|
@@ -2854,6 +2981,17 @@ async function main() {
|
|
|
2854
2981
|
const updateLog = createLogger({ quiet: parsed.options.quiet });
|
|
2855
2982
|
if (parsed.command !== "init") await syncSkills(updateLog);
|
|
2856
2983
|
const updateCheck = startUpdateCheck();
|
|
2984
|
+
Sentry2.setTag("command", parsed.command);
|
|
2985
|
+
Sentry2.setTag("cli.version", getCliVersion());
|
|
2986
|
+
Sentry2.setTag("node.version", process.versions.node);
|
|
2987
|
+
Sentry2.setTag("os.platform", process.platform);
|
|
2988
|
+
Sentry2.setContext("runtime", {
|
|
2989
|
+
cliVersion: getCliVersion(),
|
|
2990
|
+
node: process.version,
|
|
2991
|
+
platform: process.platform,
|
|
2992
|
+
arch: process.arch,
|
|
2993
|
+
ci: !!process.env.CI
|
|
2994
|
+
});
|
|
2857
2995
|
try {
|
|
2858
2996
|
if (GEN_KINDS.has(parsed.command)) {
|
|
2859
2997
|
await runGenerate(parsed.command, {
|
|
@@ -2891,10 +3029,13 @@ async function main() {
|
|
|
2891
3029
|
}
|
|
2892
3030
|
} finally {
|
|
2893
3031
|
await reportUpdateNudges(updateCheck, updateLog);
|
|
3032
|
+
await flushSentry();
|
|
2894
3033
|
}
|
|
2895
3034
|
}
|
|
2896
|
-
main().catch((err) => {
|
|
3035
|
+
main().catch(async (err) => {
|
|
3036
|
+
Sentry2.captureException(err);
|
|
2897
3037
|
const log = createLogger();
|
|
2898
3038
|
log.error(err instanceof Error ? err.message : String(err));
|
|
3039
|
+
await flushSentry();
|
|
2899
3040
|
process.exitCode = 1;
|
|
2900
3041
|
});
|
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": {
|
|
@@ -35,8 +35,12 @@
|
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@sentry/node": "^10.63.0"
|
|
40
|
+
},
|
|
38
41
|
"devDependencies": {
|
|
39
42
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
|
43
|
+
"@genex-ai/multiplayer": "workspace:*",
|
|
40
44
|
"@pixiv/three-vrm": "^3.5.4",
|
|
41
45
|
"@types/three": "^0.185.0",
|
|
42
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.
|