@genex-ai/cli-demo 0.41.0 → 0.43.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/dist/index.js +53 -6
- package/package.json +1 -1
- package/templates/controllers/character/follow-camera.ts +166 -0
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +54 -9
- package/templates/skills/genex-threejs-character-controller/SKILL.md +1 -1
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +33 -3
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -0
- package/templates/skills/genex-threejs-game-feel/SKILL.md +5 -4
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +35 -10
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +3 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +8 -1
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +2 -1
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +18 -5
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +8 -0
package/dist/index.js
CHANGED
|
@@ -652,9 +652,6 @@ function tokenizeCommand(input) {
|
|
|
652
652
|
return tokens;
|
|
653
653
|
}
|
|
654
654
|
|
|
655
|
-
// src/lib/project.ts
|
|
656
|
-
import crypto2 from "crypto";
|
|
657
|
-
|
|
658
655
|
// src/utils/colors.ts
|
|
659
656
|
var useColor = Boolean(process.stdout.isTTY) && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb";
|
|
660
657
|
var ESC = String.fromCharCode(27);
|
|
@@ -753,8 +750,21 @@ async function apiFetch(url, init2 = {}) {
|
|
|
753
750
|
}
|
|
754
751
|
return res;
|
|
755
752
|
}
|
|
753
|
+
async function fetchSignedInEmail(apiUrl, token) {
|
|
754
|
+
try {
|
|
755
|
+
const res = await apiFetch(`${apiUrl}/api/auth/get-session`, {
|
|
756
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
757
|
+
});
|
|
758
|
+
if (!res.ok) return null;
|
|
759
|
+
const data = await res.json().catch(() => null);
|
|
760
|
+
return data?.user?.email ?? null;
|
|
761
|
+
} catch {
|
|
762
|
+
return null;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
756
765
|
|
|
757
766
|
// src/lib/project.ts
|
|
767
|
+
import crypto2 from "crypto";
|
|
758
768
|
async function createDraftProject(opts) {
|
|
759
769
|
const { apiUrl, token, colyseusUrl, dashboardUrl, log } = opts;
|
|
760
770
|
log.step("Creating your project\u2026");
|
|
@@ -798,7 +808,7 @@ async function createDraftProject(opts) {
|
|
|
798
808
|
}
|
|
799
809
|
log.success(`Created project ${c.cyan(project.slug)}.`);
|
|
800
810
|
log.plain(
|
|
801
|
-
`
|
|
811
|
+
` \u279C Send this link to the user right now \u2014 their game's page is already live (a placeholder world until the first preview): ${c.cyan(`${new URL(dashboardUrl).origin}/draft/${project.slug}`)}`
|
|
802
812
|
);
|
|
803
813
|
if (project.playUrl) log.dim(` play (after preview/publish): ${project.playUrl}`);
|
|
804
814
|
log.dim(` dashboard: ${dashboardUrl}/dashboard`);
|
|
@@ -821,6 +831,19 @@ async function createDraftProject(opts) {
|
|
|
821
831
|
function randomSuffix() {
|
|
822
832
|
return crypto2.randomBytes(3).toString("hex");
|
|
823
833
|
}
|
|
834
|
+
async function fetchProjectStatus(apiUrl, token, slug) {
|
|
835
|
+
try {
|
|
836
|
+
const res = await apiFetch(`${apiUrl}/api/projects/by-slug/${encodeURIComponent(slug)}`, {
|
|
837
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
838
|
+
});
|
|
839
|
+
if (!res.ok) return null;
|
|
840
|
+
const data = await res.json().catch(() => null);
|
|
841
|
+
if (!data?.project) return null;
|
|
842
|
+
return data.project.status ?? "draft";
|
|
843
|
+
} catch {
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
824
847
|
|
|
825
848
|
// src/lib/ssh.ts
|
|
826
849
|
import fs4 from "fs/promises";
|
|
@@ -1099,6 +1122,8 @@ async function runInit(opts) {
|
|
|
1099
1122
|
await writeGitignore(process.cwd(), log);
|
|
1100
1123
|
const apiUrl = getApiUrl(opts.apiUrl);
|
|
1101
1124
|
const colyseusUrl = getColyseusUrl(opts.colyseusUrl);
|
|
1125
|
+
const signedInEmail = await fetchSignedInEmail(apiUrl, token);
|
|
1126
|
+
if (signedInEmail) log.plain(` signed in as ${c.cyan(signedInEmail)}`);
|
|
1102
1127
|
const projectName = opts.name?.trim() || path8.basename(process.cwd());
|
|
1103
1128
|
const meta = await createDraftProject({
|
|
1104
1129
|
apiUrl,
|
|
@@ -1157,6 +1182,8 @@ async function runLink(opts) {
|
|
|
1157
1182
|
log.plain("");
|
|
1158
1183
|
}
|
|
1159
1184
|
const apiUrl = getApiUrl(opts.apiUrl);
|
|
1185
|
+
const email = await fetchSignedInEmail(apiUrl, token);
|
|
1186
|
+
if (email) log.plain(` signed in as ${c.cyan(email)}`);
|
|
1160
1187
|
log.step(`Looking up ${c.cyan(slug)}\u2026`);
|
|
1161
1188
|
const project = await fetchOwnProject(apiUrl, token, slug, log);
|
|
1162
1189
|
if (!project) {
|
|
@@ -1382,6 +1409,16 @@ async function getUploadToken(ctx, commit, log) {
|
|
|
1382
1409
|
log.error(`Couldn't reach the API at ${ctx.apiUrl}: ${String(err)}`);
|
|
1383
1410
|
return null;
|
|
1384
1411
|
}
|
|
1412
|
+
if (res.status === 401) {
|
|
1413
|
+
log.error("Not authorized (HTTP 401) \u2014 your sign-in expired or was revoked.");
|
|
1414
|
+
log.plain(" Fix: run `genex link <your-game>` and sign in as the game's owner, then re-run this command.");
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
if (res.status === 404) {
|
|
1418
|
+
log.error("This account doesn't own this game (HTTP 404) \u2014 you're signed in as a different account.");
|
|
1419
|
+
log.plain(" Fix: run `genex link <your-game>` and sign in as the OWNER account, then re-run this command.");
|
|
1420
|
+
return null;
|
|
1421
|
+
}
|
|
1385
1422
|
if (!res.ok) {
|
|
1386
1423
|
log.error(`Couldn't get an upload token (HTTP ${res.status}).`);
|
|
1387
1424
|
return null;
|
|
@@ -1831,11 +1868,21 @@ async function runPreview(opts) {
|
|
|
1831
1868
|
process.exitCode = 1;
|
|
1832
1869
|
return;
|
|
1833
1870
|
}
|
|
1871
|
+
const freshStatus = await fetchProjectStatus(apiUrl, token, meta.slug);
|
|
1872
|
+
if (freshStatus && freshStatus !== meta.status) {
|
|
1873
|
+
meta.status = freshStatus;
|
|
1874
|
+
await writeProject(meta);
|
|
1875
|
+
}
|
|
1876
|
+
const published = meta.status === "published";
|
|
1834
1877
|
log.plain("");
|
|
1835
|
-
|
|
1878
|
+
if (published) {
|
|
1879
|
+
log.success("Update deployed \u2014 this game is PUBLISHED, so the new build is already live for everyone.");
|
|
1880
|
+
} else {
|
|
1881
|
+
log.success("Preview deployed \u2014 unlisted draft (run `genex publish` to list it).");
|
|
1882
|
+
}
|
|
1836
1883
|
const dashboard = meta.dashboardOrigins?.[0];
|
|
1837
1884
|
if (dashboard) {
|
|
1838
|
-
const page =
|
|
1885
|
+
const page = published ? "world" : "draft";
|
|
1839
1886
|
log.plain(` your game's page (share this link): ${c.cyan(`${dashboard}/${page}/${meta.slug}`)}`);
|
|
1840
1887
|
}
|
|
1841
1888
|
}
|
package/package.json
CHANGED
|
@@ -65,6 +65,40 @@ export type FollowCameraOptions = {
|
|
|
65
65
|
* Default []. Mutable after construction via the public `colliderMeshes` field.
|
|
66
66
|
*/
|
|
67
67
|
colliderMeshes?: THREE.Mesh[];
|
|
68
|
+
/**
|
|
69
|
+
* Pointer-lock aim mode (AG-754). When true, a left-mouse click on `domElement`
|
|
70
|
+
* requests pointer lock; while locked, raw mouse movement drives the orbit
|
|
71
|
+
* directly (no drag needed). Esc — or any lock loss — returns to "unlocked".
|
|
72
|
+
* Ships NO DOM UI: wire `onAimChange` to render a reticle while locked and a
|
|
73
|
+
* "click to aim" cue while unlocked. Touch pointers never trigger lock (mobile
|
|
74
|
+
* controls are unchanged) and coarse-pointer-only devices report "off". First-
|
|
75
|
+
* person is this same mode plus a pinned zoom + eye-height target (see the
|
|
76
|
+
* character-controller wiring reference). Default false.
|
|
77
|
+
*/
|
|
78
|
+
pointerLockAim?: boolean;
|
|
79
|
+
/** Radians of view rotation per pixel of locked mouse movement. Default 0.0023. */
|
|
80
|
+
aimSensitivity?: number;
|
|
81
|
+
/** Fired on every aim-state transition (plus an initial "init" emit when aim is enabled). */
|
|
82
|
+
onAimChange?: (e: FollowCameraAimEvent) => void;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Aim-mode lifecycle state (AG-754). Games render UI off these via `onAimChange`:
|
|
87
|
+
* a reticle while "locked", a "click to aim" cue while "unlocked", a "drag to
|
|
88
|
+
* look" hint while "unavailable".
|
|
89
|
+
*/
|
|
90
|
+
export type FollowCameraAimState =
|
|
91
|
+
| "off" // pointerLockAim not enabled, no DOM document (node/SSR), or a coarse-pointer-only device
|
|
92
|
+
| "unlocked" // aim available, waiting for a click
|
|
93
|
+
| "locked" // pointer locked, mouse drives the view
|
|
94
|
+
| "paused" // suspended by the game (menu open / driving) — clicks don't re-lock
|
|
95
|
+
| "unavailable"; // lock permanently rejected (e.g. an iframe without allow="pointer-lock") → drag-orbit fallback
|
|
96
|
+
|
|
97
|
+
/** Payload for {@link FollowCameraOptions.onAimChange}. */
|
|
98
|
+
export type FollowCameraAimEvent = {
|
|
99
|
+
state: FollowCameraAimState;
|
|
100
|
+
prev: FollowCameraAimState;
|
|
101
|
+
reason: "user-click" | "esc-or-lost" | "paused" | "resumed" | "rejected" | "init";
|
|
68
102
|
};
|
|
69
103
|
|
|
70
104
|
/** Mutable scalar velocity slot for SmoothDamp (Unity-style ref param). */
|
|
@@ -212,6 +246,15 @@ export class FollowCamera {
|
|
|
212
246
|
private _onWheel: (e: WheelEvent) => void;
|
|
213
247
|
private _onContextMenu: (e: MouseEvent) => void;
|
|
214
248
|
|
|
249
|
+
// Pointer-lock aim (AG-754). _pausedByGame gates re-lock while a menu or vehicle
|
|
250
|
+
// owns input; the two document listeners exist only when aim is capable.
|
|
251
|
+
private _aimSensitivity: number;
|
|
252
|
+
private _onAimChange?: (e: FollowCameraAimEvent) => void;
|
|
253
|
+
private _aimState: FollowCameraAimState;
|
|
254
|
+
private _pausedByGame: boolean;
|
|
255
|
+
private _onLockChange: () => void;
|
|
256
|
+
private _onLockError: () => void;
|
|
257
|
+
|
|
215
258
|
constructor(camera: THREE.PerspectiveCamera, options: FollowCameraOptions) {
|
|
216
259
|
this._camera = camera;
|
|
217
260
|
this._domElement = options.domElement;
|
|
@@ -277,6 +320,12 @@ export class FollowCamera {
|
|
|
277
320
|
this._onPointerDown = (e: PointerEvent) => {
|
|
278
321
|
if (!this.enabled) return;
|
|
279
322
|
if (e.pointerType === "mouse" && e.button !== 0) return;
|
|
323
|
+
// Aim mode: an unlocked left-click on a mouse requests pointer lock, then
|
|
324
|
+
// falls through to the drag path — so if the lock request is rejected, this
|
|
325
|
+
// same click seamlessly becomes a drag-orbit (the graceful-degradation path).
|
|
326
|
+
if (this._aimState === "unlocked" && e.pointerType === "mouse" && e.button === 0) {
|
|
327
|
+
this._requestLock();
|
|
328
|
+
}
|
|
280
329
|
this._domElement.setPointerCapture(e.pointerId);
|
|
281
330
|
this._pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
282
331
|
if (this._pointers.size === 1) {
|
|
@@ -294,6 +343,18 @@ export class FollowCamera {
|
|
|
294
343
|
|
|
295
344
|
this._onPointerMove = (e: PointerEvent) => {
|
|
296
345
|
if (!this.enabled) return;
|
|
346
|
+
if (this._aimState === "locked") {
|
|
347
|
+
// Locked aim: raw movement deltas drive the orbit directly (no pointer
|
|
348
|
+
// tracking). SIGN (AG-754): mouse-right looks right, so azimuth ADDS
|
|
349
|
+
// movementX — the OPPOSITE of the drag path's -dx (drag moves the world,
|
|
350
|
+
// aim moves the view); pitch matches drag (mouse-up looks up = -movementY).
|
|
351
|
+
// Reasoned from the spherical math; verify both axes on the template-world
|
|
352
|
+
// testbed and flip a sign here if a scene disagrees (see the drag sign note).
|
|
353
|
+
const s = this._aimSensitivity;
|
|
354
|
+
this.rotate(e.movementX * s, -e.movementY * s, true);
|
|
355
|
+
this._userDragRotate = true; // reuse the dragging smoothTime, like manual orbit
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
297
358
|
const p = this._pointers.get(e.pointerId);
|
|
298
359
|
if (!p) return;
|
|
299
360
|
const dx = e.clientX - p.x;
|
|
@@ -360,6 +421,51 @@ export class FollowCamera {
|
|
|
360
421
|
this._domElement.addEventListener("pointercancel", this._onPointerUp);
|
|
361
422
|
this._domElement.addEventListener("wheel", this._onWheel, { passive: false });
|
|
362
423
|
this._domElement.addEventListener("contextmenu", this._onContextMenu);
|
|
424
|
+
|
|
425
|
+
// ---- pointer-lock aim (AG-754) ----
|
|
426
|
+
this._aimSensitivity = options.aimSensitivity ?? 0.0023;
|
|
427
|
+
this._onAimChange = options.onAimChange;
|
|
428
|
+
this._pausedByGame = false;
|
|
429
|
+
this._onLockChange = () => {
|
|
430
|
+
const locked =
|
|
431
|
+
typeof document !== "undefined" && document.pointerLockElement === this._domElement;
|
|
432
|
+
if (locked) {
|
|
433
|
+
if (this._pausedByGame) {
|
|
434
|
+
// A lock grant that lands AFTER setPaused(true): requestPointerLock is
|
|
435
|
+
// async, so the click's request can resolve once a menu/vehicle has
|
|
436
|
+
// already paused aim. Drop it and stay paused — never go live in a menu.
|
|
437
|
+
document.exitPointerLock();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this._setAimState("locked", "user-click");
|
|
441
|
+
} else if (this._aimState === "locked") {
|
|
442
|
+
// Lock lost. A game-driven pause routes to "paused"; anything else (Esc,
|
|
443
|
+
// focus loss, tab hide) is the browser's release valve → back to "unlocked".
|
|
444
|
+
this._setAimState(
|
|
445
|
+
this._pausedByGame ? "paused" : "unlocked",
|
|
446
|
+
this._pausedByGame ? "paused" : "esc-or-lost",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
};
|
|
450
|
+
// pointerlockerror carries no detail; a genuine permission denial is caught on
|
|
451
|
+
// the requestPointerLock() promise instead. Transient errors just stay "unlocked".
|
|
452
|
+
this._onLockError = () => {};
|
|
453
|
+
// Capability gate: opt-in flag, a DOM document present (node/SSR safe), the
|
|
454
|
+
// element supports the API, and the device has a fine pointer (phones never
|
|
455
|
+
// lock — the mode reports "off" so games don't draw a desktop-only cue).
|
|
456
|
+
const aimCapable =
|
|
457
|
+
options.pointerLockAim === true &&
|
|
458
|
+
typeof document !== "undefined" &&
|
|
459
|
+
typeof this._domElement.requestPointerLock === "function" &&
|
|
460
|
+
(typeof matchMedia !== "function" || matchMedia("(pointer: fine)").matches);
|
|
461
|
+
this._aimState = aimCapable ? "unlocked" : "off";
|
|
462
|
+
if (aimCapable) {
|
|
463
|
+
document.addEventListener("pointerlockchange", this._onLockChange);
|
|
464
|
+
document.addEventListener("pointerlockerror", this._onLockError);
|
|
465
|
+
// Emit the opening state so one onAimChange handler owns ALL aim UI — the
|
|
466
|
+
// "click to aim" cue appears immediately, before any interaction.
|
|
467
|
+
this._onAimChange?.({ state: "unlocked", prev: "off", reason: "init" });
|
|
468
|
+
}
|
|
363
469
|
}
|
|
364
470
|
|
|
365
471
|
// ---- follow feed (call before update(), every frame the controller is active) ----
|
|
@@ -498,6 +604,36 @@ export class FollowCamera {
|
|
|
498
604
|
return this._orbiting || this._pinching;
|
|
499
605
|
}
|
|
500
606
|
|
|
607
|
+
/**
|
|
608
|
+
* Current pointer-lock aim state (AG-754). "off" means aim is disabled or the
|
|
609
|
+
* device has no fine pointer; "unavailable" means lock was permanently rejected.
|
|
610
|
+
*/
|
|
611
|
+
get aimState(): FollowCameraAimState {
|
|
612
|
+
return this._aimState;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Suspend or resume aim without disabling the camera. Call `setPaused(true)`
|
|
617
|
+
* when a menu opens or the player starts driving; call `setPaused(false)` INSIDE
|
|
618
|
+
* the closing click/keypress handler (browsers only grant re-lock from a user
|
|
619
|
+
* gesture). While paused, canvas clicks do NOT re-lock. No-op when aim is "off"
|
|
620
|
+
* or "unavailable".
|
|
621
|
+
*/
|
|
622
|
+
setPaused(paused: boolean): void {
|
|
623
|
+
if (this._aimState === "off" || this._aimState === "unavailable") return;
|
|
624
|
+
this._pausedByGame = paused;
|
|
625
|
+
if (paused) {
|
|
626
|
+
if (typeof document !== "undefined" && document.pointerLockElement === this._domElement) {
|
|
627
|
+
document.exitPointerLock(); // _onLockChange lands on "paused" (_pausedByGame is set)
|
|
628
|
+
} else {
|
|
629
|
+
this._setAimState("paused", "paused");
|
|
630
|
+
}
|
|
631
|
+
} else {
|
|
632
|
+
this._setAimState("unlocked", "resumed");
|
|
633
|
+
this._requestLock(); // legal because the caller is inside a user-gesture handler
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
501
637
|
// ---- per-frame ----
|
|
502
638
|
|
|
503
639
|
/**
|
|
@@ -553,6 +689,11 @@ export class FollowCamera {
|
|
|
553
689
|
this._domElement.removeEventListener("pointercancel", this._onPointerUp);
|
|
554
690
|
this._domElement.removeEventListener("wheel", this._onWheel);
|
|
555
691
|
this._domElement.removeEventListener("contextmenu", this._onContextMenu);
|
|
692
|
+
if (typeof document !== "undefined") {
|
|
693
|
+
document.removeEventListener("pointerlockchange", this._onLockChange);
|
|
694
|
+
document.removeEventListener("pointerlockerror", this._onLockError);
|
|
695
|
+
if (document.pointerLockElement === this._domElement) document.exitPointerLock();
|
|
696
|
+
}
|
|
556
697
|
this._pointers.clear();
|
|
557
698
|
this._orbiting = false;
|
|
558
699
|
this._pinching = false;
|
|
@@ -574,6 +715,31 @@ export class FollowCamera {
|
|
|
574
715
|
return Math.atan2(this._crossAxis.dot(this._upAxis), dot);
|
|
575
716
|
}
|
|
576
717
|
|
|
718
|
+
/**
|
|
719
|
+
* Request pointer lock and classify the outcome (AG-754). A SecurityError
|
|
720
|
+
* (permission denied — e.g. an iframe without allow="pointer-lock") is permanent,
|
|
721
|
+
* so we fall back to drag-orbit ("unavailable"). Any other rejection (Chrome's
|
|
722
|
+
* post-Esc re-lock cooldown, a missing gesture) is transient: stay "unlocked",
|
|
723
|
+
* the next click retries.
|
|
724
|
+
*/
|
|
725
|
+
private _requestLock(): void {
|
|
726
|
+
if (typeof document === "undefined") return;
|
|
727
|
+
const ret = this._domElement.requestPointerLock() as unknown as Promise<void> | undefined;
|
|
728
|
+
ret?.catch?.((err: unknown) => {
|
|
729
|
+
if ((err as DOMException)?.name === "SecurityError") {
|
|
730
|
+
this._setAimState("unavailable", "rejected");
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Transition aim state and fire onAimChange (no-op if unchanged). */
|
|
736
|
+
private _setAimState(state: FollowCameraAimState, reason: FollowCameraAimEvent["reason"]): void {
|
|
737
|
+
if (state === this._aimState) return;
|
|
738
|
+
const prev = this._aimState;
|
|
739
|
+
this._aimState = state;
|
|
740
|
+
this._onAimChange?.({ state, prev, reason });
|
|
741
|
+
}
|
|
742
|
+
|
|
577
743
|
/**
|
|
578
744
|
* Collision test (upstream _collisionTest): 4 rays from the target's near-plane corners toward
|
|
579
745
|
* the camera along the DAMPED orbit direction (a single center ray would let the near plane
|
|
@@ -35,15 +35,60 @@ rules, floating-origin shot, pointer controls, and implementation limits.
|
|
|
35
35
|
|
|
36
36
|
## Aiming and pointer lock
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
Decide the bucket first and say it in the build plan:
|
|
39
|
+
|
|
40
|
+
- **MANDATORY** — first-person of any kind (FPS, walking sim, horror) and any
|
|
41
|
+
mouse-aimed action (third-person shooter, turret/range). Shipping without
|
|
42
|
+
pointer lock here is a defect, not a style choice — validation fails it.
|
|
43
|
+
- **HIGHLY RECOMMENDED** — third-person free-camera action/adventure (the
|
|
44
|
+
default `genex controller character` game). Lock is the default; keep
|
|
45
|
+
drag-orbit only for a stated reason (a cursor-heavy UI at the core of play).
|
|
46
|
+
- **NEVER** — cursor-core games (top-down click-to-move, tower defense,
|
|
47
|
+
builders, card/puzzle), spectator/orbit showcases, and touch (pointer lock
|
|
48
|
+
does not exist on touch — the bundled mode no-ops there automatically).
|
|
49
|
+
|
|
50
|
+
**Mechanism — games on the bundled controller (most games):** do NOT hand-roll
|
|
51
|
+
lock handling; enable the `FollowCamera` aim mode and wire the two UI states:
|
|
52
|
+
|
|
53
|
+
const followCam = new FollowCamera(camera, {
|
|
54
|
+
domElement: renderer.domElement,
|
|
55
|
+
pointerLockAim: true,
|
|
56
|
+
onAimChange: ({ state }) => {
|
|
57
|
+
reticle.style.display = state === "locked" ? "" : "none";
|
|
58
|
+
cue.textContent = state === "unavailable" ? "Drag to look" : "Click to aim";
|
|
59
|
+
cue.style.display = state === "unlocked" || state === "unavailable" ? "" : "none";
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
Yaw/pitch re-sync on lock acquire is built in (aim shares the orbit state — the
|
|
64
|
+
view never snaps). First-person is the same mode plus three lines: pin the zoom
|
|
65
|
+
(`minDistance`/`maxDistance` ≈ 0.1), feed an eye-height target to `moveTo`, and
|
|
66
|
+
hide the avatar model. Pair aim with `controller.setLockForward(true)` so the
|
|
67
|
+
body faces where the camera looks. Pointer lock needs no setup on Genex —
|
|
68
|
+
standalone the game is the top-level page, and the platform's game frame already
|
|
69
|
+
grants the permission.
|
|
70
|
+
|
|
71
|
+
**Custom rigs only** (no bundled controller): build the pointer-look pattern in
|
|
72
|
+
[references/camera-rigs.md](references/camera-rigs.md) — the same contract applies.
|
|
73
|
+
|
|
74
|
+
**The aim contract (lock lifecycle — non-negotiable):**
|
|
75
|
+
|
|
76
|
+
1. Two states only: locked = playing, unlocked = menu/paused. "Unlocked but
|
|
77
|
+
gameplay continues" is the imprecise-aim defect in disguise.
|
|
78
|
+
2. Opening any menu (inventory/shop/dialog): exit the lock
|
|
79
|
+
(`followCam.setPaused(true)`), cursor returns, gameplay input pauses. Menu
|
|
80
|
+
keys are Tab/I/E — never Esc.
|
|
81
|
+
3. Closing a menu re-locks INSIDE the close click/keypress handler
|
|
82
|
+
(`followCam.setPaused(false)`) — the browser requires a user gesture, so
|
|
83
|
+
menus close by click/keypress, never by timeout.
|
|
84
|
+
4. Esc is the browser's release valve (you can't intercept it; Chrome enforces
|
|
85
|
+
a re-lock cooldown) → treat Esc as pause: show the overlay with a
|
|
86
|
+
"click to resume" button.
|
|
87
|
+
5. Always-visible state: reticle while locked; real cursor + "click to
|
|
88
|
+
aim/resume" cue while unlocked. The cue is also the validation hook.
|
|
89
|
+
6. If the lock request is rejected (a third-party page embedding the game
|
|
90
|
+
without `allow="pointer-lock"`), the mode falls back to drag-orbit — show
|
|
91
|
+
"Drag to look" instead. Never a dead game.
|
|
47
92
|
|
|
48
93
|
## Non-negotiable rules
|
|
49
94
|
|
|
@@ -39,7 +39,7 @@ fork as a migration strategy. Install a fresh copy elsewhere and port only the n
|
|
|
39
39
|
| `shared/colliders.ts` | `cuboidCollider`, `collidersFromObject`, … | colliders for level geometry and GLB props |
|
|
40
40
|
| `character/character-controller.ts` | `CharacterController` | the floating-capsule movement brain |
|
|
41
41
|
| `character/presets.ts` | `characterPresets` | six named tunings |
|
|
42
|
-
| `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback |
|
|
42
|
+
| `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback and an opt-in pointer-lock aim mode |
|
|
43
43
|
| `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
|
|
44
44
|
| `character/touch-joystick.ts` | `TouchJoystick`, `VirtualButton` | mobile controls |
|
|
45
45
|
| `character/character-animations.ts` | `CharacterAnimations` | animation state machine + fuzzy clip binding + `playOneShot` + procedural fallback |
|
|
@@ -121,9 +121,39 @@ Rules that matter:
|
|
|
121
121
|
- Feel: `smoothTime` (0.05 snappy → 0.25 cinematic, default 0.1),
|
|
122
122
|
`initialDistance` (default 4), `initialAzimuthAngle` (default `Math.PI` —
|
|
123
123
|
camera starts behind a +Z-facing character).
|
|
124
|
-
- v1 limits (by design): no
|
|
125
|
-
|
|
126
|
-
|
|
124
|
+
- v1 limits (by design): no truck/pan, and the orbit space assumes the up axis
|
|
125
|
+
stays roughly world +Y — far-from-Y custom gravity will misbehave.
|
|
126
|
+
|
|
127
|
+
### Pointer-lock aim (opt-in)
|
|
128
|
+
|
|
129
|
+
Set `pointerLockAim: true` and a mouse click locks the pointer; while locked,
|
|
130
|
+
mouse movement drives the orbit directly (no drag). The controller ships NO DOM —
|
|
131
|
+
wire `onAimChange` to draw a reticle (locked) and a "click to aim" cue (unlocked):
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
const followCam = new FollowCamera(camera, {
|
|
135
|
+
domElement: renderer.domElement,
|
|
136
|
+
colliderMeshes: staticWallMeshes,
|
|
137
|
+
pointerLockAim: true,
|
|
138
|
+
onAimChange: ({ state }) => {
|
|
139
|
+
reticle.style.display = state === "locked" ? "" : "none";
|
|
140
|
+
cue.style.display = state === "unlocked" || state === "unavailable" ? "" : "none";
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
- **Menus / vehicles:** `followCam.setPaused(true)` when a menu opens or the
|
|
146
|
+
player starts driving; `followCam.setPaused(false)` INSIDE the closing
|
|
147
|
+
click/keypress handler (re-lock needs a user gesture). Aim is on-foot only.
|
|
148
|
+
- **First-person:** the same mode plus `minDistance`/`maxDistance` ≈ 0.1, an
|
|
149
|
+
eye-height target fed to `moveTo`, `avatar.visible = false`, and
|
|
150
|
+
`controller.setLockForward(true)`.
|
|
151
|
+
- **Touch / rejected lock:** touch never locks (the mode reports `"off"`); a
|
|
152
|
+
third-party embed without `allow="pointer-lock"` goes `"unavailable"` and
|
|
153
|
+
drag-orbit stays as the fallback — never a dead camera.
|
|
154
|
+
|
|
155
|
+
The three-bucket rule (mandatory / recommended / never) and the full lock
|
|
156
|
+
lifecycle contract live in `$genex-threejs-camera-direction`.
|
|
127
157
|
|
|
128
158
|
## 6. The loop — exact order
|
|
129
159
|
|
|
@@ -270,6 +270,10 @@ and a gate capture is NOT visual evidence.
|
|
|
270
270
|
|
|
271
271
|
- Validate what the gate can't hide: a clean console, the canvas booting, the
|
|
272
272
|
HUD present in a DOM snapshot, controls registering.
|
|
273
|
+
- Pointer lock can't be acquired headlessly either (`requestPointerLock` throws
|
|
274
|
+
in headless Chromium): for aim games validate the unlocked "click to aim" cue
|
|
275
|
+
and the wiring, not the lock itself (see `$genex-threejs-visual-validation`
|
|
276
|
+
step 6).
|
|
273
277
|
- Do NOT work around the gate: don't dig through the SDK's internals for
|
|
274
278
|
undocumented URL fragments, and don't drive the user's own signed-in
|
|
275
279
|
browser.
|
|
@@ -33,10 +33,11 @@ no matter how good it looks.
|
|
|
33
33
|
itself, or apply them only to hand-rolled movement.)
|
|
34
34
|
- If an action can't fire (cooldown, no ammo), say so instantly — a click, a
|
|
35
35
|
dimmed icon — silence reads as broken input.
|
|
36
|
-
- Camera-aimed shooting wants **pointer lock**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
the
|
|
36
|
+
- Camera-aimed shooting wants **pointer lock** — firing at a reticle with an
|
|
37
|
+
unlocked drag-to-turn camera feels imprecise no matter how tight the numbers
|
|
38
|
+
are. The three-bucket rule + the bundled `FollowCamera` aim mode live in
|
|
39
|
+
`$genex-threejs-camera-direction`; on the bundled controller it's one option
|
|
40
|
+
flag, not hand-rolled events.
|
|
40
41
|
|
|
41
42
|
## Movement: snappy beats realistic
|
|
42
43
|
|
|
@@ -139,21 +139,43 @@ With `open`, `mm.matchmaking.status` only goes `searching`→`waiting`→`playin
|
|
|
139
139
|
|
|
140
140
|
`open` seats you into a LIVE shared room the moment you're matched (`session` goes live, players sync)
|
|
141
141
|
but doesn't "start" anything — so the pre-game lobby is simply **your room before it's grown to the
|
|
142
|
-
size you want**. Set `minPlayers` to your target
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
142
|
+
size you want**. Set `minPlayers` to your target: the SERVER flips `mm.matchmaking.status` from
|
|
143
|
+
`'waiting'` to `'playing'` the instant the roster reaches it. The lobby and the game are ONE `open`
|
|
144
|
+
room — never spin up a second room for it.
|
|
145
|
+
|
|
146
|
+
**MANDATORY — the waiting screen closes on `status === 'playing'`, and you verify it.** The single
|
|
147
|
+
most common lobby bug is a waiting screen that never goes away even with two players seated. Both
|
|
148
|
+
halves of this rule are non-negotiable:
|
|
149
|
+
|
|
150
|
+
1. Drive the overlay's visibility from `mm.matchmaking.status` **re-read every frame in your render
|
|
151
|
+
loop** (it's a view you poll, not an event): `status === 'waiting'` → overlay visible with the
|
|
152
|
+
`players.length / minPlayers` count; `status === 'playing'` → overlay GONE, game visible. Do NOT
|
|
153
|
+
gate dismissal on `matchStart`/`matchEnded` (they NEVER fire for `open`), on a one-time status
|
|
154
|
+
read at connect, or on a host-written `shared` "go" signal as the *only* path — if that signal
|
|
155
|
+
is never written (host bug, host left), every player is stuck on the waiting screen forever.
|
|
156
|
+
A ready-check or countdown is fine as an ADDITION layered on top of `status === 'playing'`
|
|
157
|
+
(host writes it to `shared` so it survives host migration), never as a replacement for it.
|
|
158
|
+
2. **Verify it for real before calling multiplayer done**: open the game in two browser windows
|
|
159
|
+
(one regular + one incognito, so they're two players), join with both, and watch the waiting
|
|
160
|
+
screen disappear on BOTH the moment the count reaches `minPlayers` (with
|
|
161
|
+
`minPlayers: 2, maxPlayers: 2`: the instant the second player joins). If it doesn't close on
|
|
162
|
+
both, the lobby is broken — fix it; do not ship a waiting room you haven't watched close.
|
|
163
|
+
|
|
164
|
+
Two ways to present the lobby:
|
|
165
|
+
|
|
166
|
+
- **A) UI lobby (Dota-style).** While `status === 'waiting'`, render an OVERLAY from `mm.matchmaking`
|
|
167
|
+
instead of the game: the roster + count (`players.length` / target), each player's name, and
|
|
168
|
+
optionally a per-player "ready" toggle (store it in per-player state or a `shared` map). When
|
|
169
|
+
`status` flips to `'playing'`, swap the overlay for the game — the host may additionally write
|
|
170
|
+
e.g. `session.shared.set('phase', { started: true, at: <ts> })` to sequence a countdown or
|
|
171
|
+
ready-gate on top, but the overlay's dismissal must not depend on it alone.
|
|
151
172
|
- **B) Physical lobby (Roblox-style).** The waiting area IS a 3D scene in the SAME room: render a lobby
|
|
152
173
|
and let players walk their avatars around, syncing position with `me.set` on the tick exactly like
|
|
153
174
|
in-game. Show a "N / target — starting soon" sign driven by `players.length`. A "ready pad" is a nice
|
|
154
175
|
affordance: players stand on it, the host counts how many are on it (from their synced positions) and
|
|
155
176
|
writes a `shared` countdown; when it elapses everyone moves their camera/scene into the match — no
|
|
156
|
-
re-matchmaking, they're already together.
|
|
177
|
+
re-matchmaking, they're already together. The transition into the match still keys off
|
|
178
|
+
`status === 'playing'` first; the pad only sequences what happens after quorum.
|
|
157
179
|
|
|
158
180
|
**Private lobbies** (for `preset: 'private'`) don't use `matchmake()` — a host makes an invite code
|
|
159
181
|
and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
|
|
@@ -530,6 +552,9 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
530
552
|
- [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
|
|
531
553
|
- [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
|
|
532
554
|
- [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
|
|
555
|
+
- [ ] Waiting room (if any): overlay driven by `mm.matchmaking.status` read every frame, gone the
|
|
556
|
+
moment it flips to `'playing'` — and you WATCHED it close in two browser windows at
|
|
557
|
+
`minPlayers` (never gated on `matchStart` or a host `shared` signal alone).
|
|
533
558
|
- [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
|
|
534
559
|
|
|
535
560
|
## Troubleshooting auth
|
|
@@ -56,6 +56,9 @@ slow physical projectiles, all against the same damage/defeat rules.)
|
|
|
56
56
|
| Slow physical projectiles (grenades, rockets) | `objects` — one per projectile, **hard cap + `removeConfirmed`** | the thrower |
|
|
57
57
|
|
|
58
58
|
**Decisions:**
|
|
59
|
+
- **Aim with pointer lock** — a first-person or mouse-aimed shooter is the MANDATORY pointer-lock
|
|
60
|
+
bucket (see `$genex-threejs-camera-direction`); ship it locked, not drag-to-turn, or aim feels
|
|
61
|
+
imprecise before netcode is even in play.
|
|
59
62
|
- **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
|
|
60
63
|
*you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
|
|
61
64
|
flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
|
|
@@ -15,7 +15,7 @@ map, execution order, and acceptance gate.
|
|
|
15
15
|
|
|
16
16
|
| Work needed | Load |
|
|
17
17
|
| --- | --- |
|
|
18
|
-
| shot composition, chase/side/orbit rigs, camera handoffs, projection ownership, pointer look, floating origins | `$genex-threejs-camera-direction` |
|
|
18
|
+
| shot composition, chase/side/orbit rigs, camera handoffs, projection ownership, pointer look, mouse-aimed action (shooter, FPS/first-person, sniper, turret, crosshair/reticle), mouse-look, floating origins | `$genex-threejs-camera-direction` |
|
|
19
19
|
| on-foot player movement: walk/run/jump/crouch, third-person character, slopes, stairs, moving platforms, animation binding, extra animation packs (sword/pistol/magic/climb/swim/emotes via `genex controller anims`) | `$genex-threejs-character-controller` |
|
|
20
20
|
| the player drives or flies something: cars, drones, vehicle physics, gearbox, enter/exit between character and vehicle | `$genex-threejs-vehicle-controllers` |
|
|
21
21
|
| anything falls, collides, gets pushed, or needs physics: Rapier world setup, colliders for meshes and GLBs, collision events | `$genex-threejs-physics-rapier` |
|
|
@@ -99,6 +99,13 @@ concept-driven — a richer first build beats a grey-box one.
|
|
|
99
99
|
|
|
100
100
|
- Start from the playable game target: player verb, scene scale, camera distance,
|
|
101
101
|
input mode, and frame budget.
|
|
102
|
+
- Pointer bucket (decide before building, state it in the plan): **mandatory
|
|
103
|
+
pointer lock** — first-person of any kind, and any mouse-aimed action
|
|
104
|
+
(third-person shooter, turret/range). **Lock by default** — third-person
|
|
105
|
+
free-camera action/adventure; drag-orbit only with a stated reason (e.g. a
|
|
106
|
+
cursor-heavy UI core). **Never** — cursor-core games (click-to-move, tower
|
|
107
|
+
defense, builder, card/puzzle), orbit showcases, touch-only. The mechanism and
|
|
108
|
+
the full aim contract live in `$genex-threejs-camera-direction`.
|
|
102
109
|
- Build silhouette, motion, and material readability before adding image effects.
|
|
103
110
|
Never dress a primitive shape in glow or bloom to fake quality — authored
|
|
104
111
|
forms first, then materials, then lighting, then effects last.
|
|
@@ -30,7 +30,8 @@ Three.js release or branch, and do not blindly copy demo architecture.
|
|
|
30
30
|
overlay from the very first asset load — a player must never stare at a black
|
|
31
31
|
screen; the rest of the UI states come at step 10.
|
|
32
32
|
5. Add camera direction when framing, controls, transitions, or scale perception
|
|
33
|
-
affect play
|
|
33
|
+
affect play — or the game aims with the mouse (shooter/FPS/turret): pointer-lock
|
|
34
|
+
bucket decisions live there.
|
|
34
35
|
6. Add procedural animation when object motion needs authored phases,
|
|
35
36
|
convergence, looping, or deterministic timelines.
|
|
36
37
|
7. Add shared fields before writing multiple independent noise layers.
|
|
@@ -192,11 +192,24 @@ followCam.update(delta);
|
|
|
192
192
|
(default 5; 0 disables) and always yields to an active user drag-orbit.
|
|
193
193
|
`cameraTarget`/`cameraUp` are reused internal vectors — copy, never mutate.
|
|
194
194
|
|
|
195
|
-
**Follow camera v1 limits (by design):** no
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
195
|
+
**Follow camera v1 limits (by design):** no truck/pan (the pivot is always the
|
|
196
|
+
followed unit), and the orbit space assumes up ≈ +Y — `camera.up` lerps toward
|
|
197
|
+
the fed up-axis, but a far-from-Y gravity direction will misbehave. For rigs
|
|
198
|
+
beyond this, see `$genex-threejs-camera-direction`.
|
|
199
|
+
|
|
200
|
+
**Pointer-lock aim + vehicles:** `FollowCamera`'s aim mode (see
|
|
201
|
+
`$genex-threejs-camera-direction`) is on-foot only — one shared camera serves
|
|
202
|
+
both character and vehicle, so pause aim while driving and it resumes on foot:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
onHandoff: (fromId, toId) => followCam.setPaused(toId !== CHARACTER_ID),
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Enter is clean — exiting a lock needs no gesture. On **exit**, though, `onHandoff`
|
|
209
|
+
runs deferred inside `update()`, NOT in the F-key handler, so `setPaused(false)`'s
|
|
210
|
+
re-lock request has no live user gesture and the browser refuses it: aim returns to
|
|
211
|
+
**unlocked** (the "click to aim" cue shows) and the player clicks once to re-aim —
|
|
212
|
+
don't assume a seamless re-lock. Vehicle cameras keep their `alignHeading` behavior.
|
|
200
213
|
|
|
201
214
|
## Multiplayer
|
|
202
215
|
|
|
@@ -47,6 +47,12 @@ this is the whole acceptance gate, and it is also the minimum for every game:
|
|
|
47
47
|
floating above detached wheels means the model was box-fit against the
|
|
48
48
|
preset's wheelbase (the vehicle skill's "Custom generated bodies" rules
|
|
49
49
|
fix it).
|
|
50
|
+
6. Aim games get one extra pass (MANDATORY bucket — first-person or mouse-aimed):
|
|
51
|
+
state which pointer bucket the game chose; click the canvas and assert the
|
|
52
|
+
pointer locks (cursor gone, mouse turns the view); press Esc and assert the
|
|
53
|
+
"click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
|
|
54
|
+
throws in headless Chromium — assert the wiring and the unlocked cue in a
|
|
55
|
+
screenshot, and say plainly that the lock itself needs one manual click.
|
|
50
56
|
|
|
51
57
|
Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
|
|
52
58
|
visual-system work — the sequence above.
|
|
@@ -63,6 +69,8 @@ visual-system work — the sequence above.
|
|
|
63
69
|
|
|
64
70
|
## Failure conditions
|
|
65
71
|
|
|
72
|
+
- a MANDATORY-bucket aim game never requests pointer lock, or locks with no
|
|
73
|
+
visible unlocked cue;
|
|
66
74
|
- approval relies on a single frame;
|
|
67
75
|
- post-processing cannot be disabled per pass;
|
|
68
76
|
- random seeds are not reproducible;
|