@genex-ai/cli-demo 0.65.0-dev.161 → 0.66.0-dev.163
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 +1 -0
- package/package.json +1 -1
- package/templates/controllers/character/aim-cue.ts +124 -0
- package/templates/controllers/character/follow-camera.ts +136 -33
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +39 -16
- package/templates/skills/genex-threejs-character-controller/SKILL.md +7 -1
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +18 -11
- package/templates/skills/genex-threejs-game-feel/SKILL.md +3 -3
- package/templates/skills/genex-threejs-game-ui/SKILL.md +14 -2
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +3 -2
- package/templates/skills/genex-threejs-skill-router/SKILL.md +9 -6
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +4 -3
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +8 -4
package/dist/index.js
CHANGED
|
@@ -12540,6 +12540,7 @@ var TOUCH_KIT = [
|
|
|
12540
12540
|
];
|
|
12541
12541
|
var INPUT_AND_CAMERA = [
|
|
12542
12542
|
"character/follow-camera.ts",
|
|
12543
|
+
"character/aim-cue.ts",
|
|
12543
12544
|
"character/keyboard-input.ts",
|
|
12544
12545
|
"character/touch-joystick.ts",
|
|
12545
12546
|
...TOUCH_KIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.66.0-dev.163",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Ready-made pointer-lock aim UI for the bundled FollowCamera — the reticle +
|
|
2
|
+
// "click to aim" cue that the camera's aim mode expects the game to draw.
|
|
3
|
+
// FollowCamera itself ships no DOM (it only emits `onAimChange` events); this
|
|
4
|
+
// helper turns those events into a minimal always-correct overlay so a game gets
|
|
5
|
+
// the whole aim UX in two lines:
|
|
6
|
+
//
|
|
7
|
+
// const cue = createAimCue();
|
|
8
|
+
// const followCam = new FollowCamera(camera, { domElement, onAimChange: cue.onAimChange });
|
|
9
|
+
//
|
|
10
|
+
// What it draws per aim state:
|
|
11
|
+
// - "locked" → a small centered reticle dot (cursor is hidden by the browser).
|
|
12
|
+
// - "unlocked" → a bottom-center pill, "Click to aim — Esc pauses". Also shown on
|
|
13
|
+
// the "needs-gesture" re-emit (a re-lock that needs a fresh click).
|
|
14
|
+
// - "unavailable" → the same pill, "Drag to look" (lock was refused, e.g. a
|
|
15
|
+
// third-party embed without allow="pointer-lock" — drag still works).
|
|
16
|
+
// - "off" / "paused" → nothing (aim disabled, or a menu owns the cursor).
|
|
17
|
+
//
|
|
18
|
+
// Games that want a custom HUD can skip this and read `onAimChange` directly.
|
|
19
|
+
|
|
20
|
+
import type { FollowCameraAimEvent } from "./follow-camera.ts";
|
|
21
|
+
|
|
22
|
+
export interface AimCueOptions {
|
|
23
|
+
/** DOM parent; default `document.body`. */
|
|
24
|
+
parent?: HTMLElement;
|
|
25
|
+
/** Text for the unlocked pill; default "Click to aim — Esc pauses". */
|
|
26
|
+
hint?: string;
|
|
27
|
+
/** Text for the drag-fallback pill (lock unavailable); default "Drag to look". */
|
|
28
|
+
dragHint?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AimCue {
|
|
32
|
+
/** Wire this to `FollowCameraOptions.onAimChange`. Idempotent per state. */
|
|
33
|
+
onAimChange: (e: FollowCameraAimEvent) => void;
|
|
34
|
+
/** Remove the overlay from the DOM. */
|
|
35
|
+
dispose: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const OVERLAY_STYLE: Partial<CSSStyleDeclaration> = {
|
|
39
|
+
position: "fixed",
|
|
40
|
+
inset: "0",
|
|
41
|
+
zIndex: "9",
|
|
42
|
+
pointerEvents: "none",
|
|
43
|
+
fontFamily: "system-ui, sans-serif",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const RETICLE_STYLE: Partial<CSSStyleDeclaration> = {
|
|
47
|
+
position: "absolute",
|
|
48
|
+
left: "50%",
|
|
49
|
+
top: "50%",
|
|
50
|
+
width: "6px",
|
|
51
|
+
height: "6px",
|
|
52
|
+
marginLeft: "-3px",
|
|
53
|
+
marginTop: "-3px",
|
|
54
|
+
borderRadius: "50%",
|
|
55
|
+
background: "rgba(255,255,255,0.9)",
|
|
56
|
+
boxShadow: "0 0 0 1.5px rgba(0,0,0,0.5)",
|
|
57
|
+
display: "none",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const PILL_STYLE: Partial<CSSStyleDeclaration> = {
|
|
61
|
+
position: "absolute",
|
|
62
|
+
left: "50%",
|
|
63
|
+
bottom: "6%",
|
|
64
|
+
transform: "translateX(-50%)",
|
|
65
|
+
padding: "6px 14px",
|
|
66
|
+
borderRadius: "999px",
|
|
67
|
+
background: "rgba(0,0,0,0.55)",
|
|
68
|
+
color: "#fff",
|
|
69
|
+
fontSize: "13px",
|
|
70
|
+
fontWeight: "500",
|
|
71
|
+
letterSpacing: "0.01em",
|
|
72
|
+
whiteSpace: "nowrap",
|
|
73
|
+
display: "none",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create the aim overlay and return an `onAimChange` handler to wire into
|
|
78
|
+
* FollowCamera plus a `dispose()` that removes it. Safe to call once per game.
|
|
79
|
+
*/
|
|
80
|
+
export function createAimCue(options: AimCueOptions = {}): AimCue {
|
|
81
|
+
const parent = options.parent ?? document.body;
|
|
82
|
+
const hint = options.hint ?? "Click to aim — Esc pauses";
|
|
83
|
+
const dragHint = options.dragHint ?? "Drag to look";
|
|
84
|
+
|
|
85
|
+
const overlay = document.createElement("div");
|
|
86
|
+
Object.assign(overlay.style, OVERLAY_STYLE);
|
|
87
|
+
|
|
88
|
+
const reticle = document.createElement("div");
|
|
89
|
+
Object.assign(reticle.style, RETICLE_STYLE);
|
|
90
|
+
|
|
91
|
+
const pill = document.createElement("div");
|
|
92
|
+
Object.assign(pill.style, PILL_STYLE);
|
|
93
|
+
pill.textContent = hint;
|
|
94
|
+
|
|
95
|
+
overlay.appendChild(reticle);
|
|
96
|
+
overlay.appendChild(pill);
|
|
97
|
+
parent.appendChild(overlay);
|
|
98
|
+
|
|
99
|
+
const onAimChange = (e: FollowCameraAimEvent): void => {
|
|
100
|
+
// Idempotent: the "needs-gesture" re-emit repeats state "unlocked" by design.
|
|
101
|
+
if (e.state === "locked") {
|
|
102
|
+
reticle.style.display = "block";
|
|
103
|
+
pill.style.display = "none";
|
|
104
|
+
} else if (e.state === "unlocked") {
|
|
105
|
+
reticle.style.display = "none";
|
|
106
|
+
pill.textContent = hint;
|
|
107
|
+
pill.style.display = "block";
|
|
108
|
+
} else if (e.state === "unavailable") {
|
|
109
|
+
reticle.style.display = "none";
|
|
110
|
+
pill.textContent = dragHint;
|
|
111
|
+
pill.style.display = "block";
|
|
112
|
+
} else {
|
|
113
|
+
// "off" or "paused": a menu owns the cursor, or aim is disabled.
|
|
114
|
+
reticle.style.display = "none";
|
|
115
|
+
pill.style.display = "none";
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const dispose = (): void => {
|
|
120
|
+
overlay.remove();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return { onAimChange, dispose };
|
|
124
|
+
}
|
|
@@ -66,18 +66,29 @@ export type FollowCameraOptions = {
|
|
|
66
66
|
*/
|
|
67
67
|
colliderMeshes?: THREE.Mesh[];
|
|
68
68
|
/**
|
|
69
|
-
* Pointer-lock aim mode (AG-754).
|
|
70
|
-
*
|
|
71
|
-
* directly (no drag needed). Esc — or any
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
69
|
+
* Pointer-lock aim mode (AG-754). **Default true** on desktop (fine pointer):
|
|
70
|
+
* a left-mouse click on `domElement` requests pointer lock, and while locked
|
|
71
|
+
* raw mouse movement drives the orbit directly (no drag needed). Esc — or any
|
|
72
|
+
* lock loss — returns to "unlocked". Pass `pointerLockAim: false` to opt OUT —
|
|
73
|
+
* do this for cursor-core games (RTS, tower defense, card/board, point-and-click
|
|
74
|
+
* builders) where the OS cursor IS the input, and for orbit showcases.
|
|
75
|
+
* FollowCamera itself ships NO DOM UI; the character controller kit ships a
|
|
76
|
+
* ready-made cue helper (`aim-cue.ts` — `createAimCue()`) that you wire to
|
|
77
|
+
* `onAimChange` to draw a reticle while locked and a "click to aim" cue while
|
|
78
|
+
* unlocked. Touch pointers never trigger lock (mobile controls are unchanged)
|
|
79
|
+
* and coarse-pointer-only devices report "off". First-person is this same mode
|
|
80
|
+
* plus a pinned zoom + eye-height target (see the character-controller wiring
|
|
81
|
+
* reference).
|
|
77
82
|
*/
|
|
78
83
|
pointerLockAim?: boolean;
|
|
79
|
-
/** Radians of view rotation per pixel of locked mouse movement. Default 0.0023. */
|
|
84
|
+
/** Radians of view rotation per pixel of locked mouse movement. Default 0.0023. Mutable via `aimSensitivity` setter. */
|
|
80
85
|
aimSensitivity?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Invert the vertical (pitch) axis in locked aim — mouse-up looks DOWN, for
|
|
88
|
+
* players who prefer flight-sim pitch. Default false. There is deliberately no
|
|
89
|
+
* horizontal-invert option: inverted yaw is a bug class, never a preference.
|
|
90
|
+
*/
|
|
91
|
+
aimInvertY?: boolean;
|
|
81
92
|
/** Fired on every aim-state transition (plus an initial "init" emit when aim is enabled). */
|
|
82
93
|
onAimChange?: (e: FollowCameraAimEvent) => void;
|
|
83
94
|
};
|
|
@@ -94,11 +105,26 @@ export type FollowCameraAimState =
|
|
|
94
105
|
| "paused" // suspended by the game (menu open / driving) — clicks don't re-lock
|
|
95
106
|
| "unavailable"; // lock permanently rejected (e.g. an iframe without allow="pointer-lock") → drag-orbit fallback
|
|
96
107
|
|
|
97
|
-
/**
|
|
108
|
+
/**
|
|
109
|
+
* Payload for {@link FollowCameraOptions.onAimChange}.
|
|
110
|
+
*
|
|
111
|
+
* NOTE: reason `"needs-gesture"` is a **same-state re-emit** — it fires with
|
|
112
|
+
* `state === prev === "unlocked"` when a re-lock attempt could not run because
|
|
113
|
+
* the browser lacked a usable user gesture (a resume outside a click, or Chrome's
|
|
114
|
+
* post-Esc re-lock cooldown). The camera keeps the "unlocked" cue up and re-locks
|
|
115
|
+
* on the next pointerdown/keydown; UI handlers must treat it idempotently.
|
|
116
|
+
*/
|
|
98
117
|
export type FollowCameraAimEvent = {
|
|
99
118
|
state: FollowCameraAimState;
|
|
100
119
|
prev: FollowCameraAimState;
|
|
101
|
-
reason:
|
|
120
|
+
reason:
|
|
121
|
+
| "user-click"
|
|
122
|
+
| "esc-or-lost"
|
|
123
|
+
| "paused"
|
|
124
|
+
| "resumed"
|
|
125
|
+
| "rejected"
|
|
126
|
+
| "needs-gesture"
|
|
127
|
+
| "init";
|
|
102
128
|
};
|
|
103
129
|
|
|
104
130
|
/** Mutable scalar velocity slot for SmoothDamp (Unity-style ref param). */
|
|
@@ -247,13 +273,19 @@ export class FollowCamera {
|
|
|
247
273
|
private _onContextMenu: (e: MouseEvent) => void;
|
|
248
274
|
|
|
249
275
|
// Pointer-lock aim (AG-754). _pausedByGame gates re-lock while a menu or vehicle
|
|
250
|
-
// owns input; the
|
|
276
|
+
// owns input; the document listeners exist only when aim is capable.
|
|
251
277
|
private _aimSensitivity: number;
|
|
278
|
+
private _aimInvertY: boolean;
|
|
252
279
|
private _onAimChange?: (e: FollowCameraAimEvent) => void;
|
|
253
280
|
private _aimState: FollowCameraAimState;
|
|
254
281
|
private _pausedByGame: boolean;
|
|
282
|
+
// Armed only after a re-lock could not run for lack of a user gesture; the next
|
|
283
|
+
// real gesture (canvas pointerdown, or this document keydown) retries the lock.
|
|
284
|
+
// Kept off after a deliberate Esc so a stray keypress never yanks the cursor back.
|
|
285
|
+
private _retryArmed: boolean;
|
|
255
286
|
private _onLockChange: () => void;
|
|
256
287
|
private _onLockError: () => void;
|
|
288
|
+
private _onRetryKeyDown: () => void;
|
|
257
289
|
|
|
258
290
|
constructor(camera: THREE.PerspectiveCamera, options: FollowCameraOptions) {
|
|
259
291
|
this._camera = camera;
|
|
@@ -345,13 +377,14 @@ export class FollowCamera {
|
|
|
345
377
|
if (!this.enabled) return;
|
|
346
378
|
if (this._aimState === "locked") {
|
|
347
379
|
// Locked aim: raw movement deltas drive the orbit directly (no pointer
|
|
348
|
-
// tracking). SIGN
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
380
|
+
// tracking). SIGN: mouse-right looks right, mouse-up looks up — the SAME
|
|
381
|
+
// convention as the drag path below (both pan the VIEW: azimuth uses the
|
|
382
|
+
// negative horizontal delta, pitch the negative vertical). Pinned by the
|
|
383
|
+
// signed projection test in follow-camera-aim.test.ts — do not "reason" a
|
|
384
|
+
// flip here; change the test if the convention itself ever changes.
|
|
353
385
|
const s = this._aimSensitivity;
|
|
354
|
-
this.
|
|
386
|
+
const pitch = this._aimInvertY ? e.movementY * s : -e.movementY * s;
|
|
387
|
+
this.rotate(-e.movementX * s, pitch, true);
|
|
355
388
|
this._userDragRotate = true; // reuse the dragging smoothTime, like manual orbit
|
|
356
389
|
return;
|
|
357
390
|
}
|
|
@@ -424,12 +457,15 @@ export class FollowCamera {
|
|
|
424
457
|
|
|
425
458
|
// ---- pointer-lock aim (AG-754) ----
|
|
426
459
|
this._aimSensitivity = options.aimSensitivity ?? 0.0023;
|
|
460
|
+
this._aimInvertY = options.aimInvertY === true;
|
|
427
461
|
this._onAimChange = options.onAimChange;
|
|
428
462
|
this._pausedByGame = false;
|
|
463
|
+
this._retryArmed = false;
|
|
429
464
|
this._onLockChange = () => {
|
|
430
465
|
const locked =
|
|
431
466
|
typeof document !== "undefined" && document.pointerLockElement === this._domElement;
|
|
432
467
|
if (locked) {
|
|
468
|
+
this._retryArmed = false; // a live lock supersedes any pending gesture retry
|
|
433
469
|
if (this._pausedByGame) {
|
|
434
470
|
// A lock grant that lands AFTER setPaused(true): requestPointerLock is
|
|
435
471
|
// async, so the click's request can resolve once a menu/vehicle has
|
|
@@ -441,6 +477,8 @@ export class FollowCamera {
|
|
|
441
477
|
} else if (this._aimState === "locked") {
|
|
442
478
|
// Lock lost. A game-driven pause routes to "paused"; anything else (Esc,
|
|
443
479
|
// focus loss, tab hide) is the browser's release valve → back to "unlocked".
|
|
480
|
+
// Esc does NOT arm a keydown retry: the player asked for the cursor, so we
|
|
481
|
+
// wait for a deliberate click, not the next stray keypress.
|
|
444
482
|
this._setAimState(
|
|
445
483
|
this._pausedByGame ? "paused" : "unlocked",
|
|
446
484
|
this._pausedByGame ? "paused" : "esc-or-lost",
|
|
@@ -450,11 +488,21 @@ export class FollowCamera {
|
|
|
450
488
|
// pointerlockerror carries no detail; a genuine permission denial is caught on
|
|
451
489
|
// the requestPointerLock() promise instead. Transient errors just stay "unlocked".
|
|
452
490
|
this._onLockError = () => {};
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
491
|
+
// A re-lock that failed for lack of a user gesture (a resume outside a click, or
|
|
492
|
+
// Chrome's post-Esc cooldown) arms this: the next keydown retries the lock, so
|
|
493
|
+
// the player can resume with WASD, not only a click. Only fires while armed +
|
|
494
|
+
// unlocked + not game-paused, so it never fights a deliberate Esc.
|
|
495
|
+
this._onRetryKeyDown = () => {
|
|
496
|
+
if (this._pausedByGame || this._aimState !== "unlocked" || !this._retryArmed) return;
|
|
497
|
+
this._retryArmed = false;
|
|
498
|
+
this._requestLock();
|
|
499
|
+
};
|
|
500
|
+
// Capability gate: aim is ON by default on desktop; opt out with
|
|
501
|
+
// `pointerLockAim: false` (cursor-core games). Also requires a DOM document
|
|
502
|
+
// (node/SSR safe), element API support, and a fine pointer (phones never lock —
|
|
503
|
+
// the mode reports "off" so games don't draw a desktop-only cue).
|
|
456
504
|
const aimCapable =
|
|
457
|
-
options.pointerLockAim
|
|
505
|
+
options.pointerLockAim !== false &&
|
|
458
506
|
typeof document !== "undefined" &&
|
|
459
507
|
typeof this._domElement.requestPointerLock === "function" &&
|
|
460
508
|
(typeof matchMedia !== "function" || matchMedia("(pointer: fine)").matches);
|
|
@@ -462,6 +510,7 @@ export class FollowCamera {
|
|
|
462
510
|
if (aimCapable) {
|
|
463
511
|
document.addEventListener("pointerlockchange", this._onLockChange);
|
|
464
512
|
document.addEventListener("pointerlockerror", this._onLockError);
|
|
513
|
+
document.addEventListener("keydown", this._onRetryKeyDown);
|
|
465
514
|
// Emit the opening state so one onAimChange handler owns ALL aim UI — the
|
|
466
515
|
// "click to aim" cue appears immediately, before any interaction.
|
|
467
516
|
this._onAimChange?.({ state: "unlocked", prev: "off", reason: "init" });
|
|
@@ -612,6 +661,17 @@ export class FollowCamera {
|
|
|
612
661
|
return this._aimState;
|
|
613
662
|
}
|
|
614
663
|
|
|
664
|
+
/**
|
|
665
|
+
* Radians of view rotation per pixel of locked mouse movement (default 0.0023).
|
|
666
|
+
* Mutable so a pause-menu sensitivity slider can retune aim live.
|
|
667
|
+
*/
|
|
668
|
+
get aimSensitivity(): number {
|
|
669
|
+
return this._aimSensitivity;
|
|
670
|
+
}
|
|
671
|
+
set aimSensitivity(v: number) {
|
|
672
|
+
if (Number.isFinite(v) && v > 0) this._aimSensitivity = v;
|
|
673
|
+
}
|
|
674
|
+
|
|
615
675
|
/**
|
|
616
676
|
* Suspend or resume aim without disabling the camera. Call `setPaused(true)`
|
|
617
677
|
* when a menu opens or the player starts driving; call `setPaused(false)` INSIDE
|
|
@@ -623,6 +683,7 @@ export class FollowCamera {
|
|
|
623
683
|
if (this._aimState === "off" || this._aimState === "unavailable") return;
|
|
624
684
|
this._pausedByGame = paused;
|
|
625
685
|
if (paused) {
|
|
686
|
+
this._retryArmed = false; // a menu/vehicle owns input now — no gesture retry
|
|
626
687
|
if (typeof document !== "undefined" && document.pointerLockElement === this._domElement) {
|
|
627
688
|
document.exitPointerLock(); // _onLockChange lands on "paused" (_pausedByGame is set)
|
|
628
689
|
} else {
|
|
@@ -692,8 +753,10 @@ export class FollowCamera {
|
|
|
692
753
|
if (typeof document !== "undefined") {
|
|
693
754
|
document.removeEventListener("pointerlockchange", this._onLockChange);
|
|
694
755
|
document.removeEventListener("pointerlockerror", this._onLockError);
|
|
756
|
+
document.removeEventListener("keydown", this._onRetryKeyDown);
|
|
695
757
|
if (document.pointerLockElement === this._domElement) document.exitPointerLock();
|
|
696
758
|
}
|
|
759
|
+
this._retryArmed = false;
|
|
697
760
|
this._pointers.clear();
|
|
698
761
|
this._orbiting = false;
|
|
699
762
|
this._pinching = false;
|
|
@@ -716,25 +779,65 @@ export class FollowCamera {
|
|
|
716
779
|
}
|
|
717
780
|
|
|
718
781
|
/**
|
|
719
|
-
* Request pointer lock and classify the outcome (AG-754).
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
782
|
+
* Request pointer lock and classify the outcome (AG-754). Outcomes:
|
|
783
|
+
* - No usable user gesture (a resume outside a click, or Chrome's post-Esc
|
|
784
|
+
* cooldown): skip the doomed request, keep the "click to aim" cue up (a
|
|
785
|
+
* same-state "needs-gesture" re-emit), and arm a keydown retry.
|
|
786
|
+
* - SecurityError (permission denied — e.g. an iframe without
|
|
787
|
+
* allow="pointer-lock"): permanent, fall back to drag-orbit ("unavailable").
|
|
788
|
+
* - NotSupportedError from `unadjustedMovement`: retry the plain request (still
|
|
789
|
+
* inside the transient-activation window, so no new gesture is needed).
|
|
790
|
+
* - Any other rejection: transient — treat like the missing-gesture case.
|
|
724
791
|
*/
|
|
725
|
-
private _requestLock(): void {
|
|
792
|
+
private _requestLock(unadjusted = true): void {
|
|
726
793
|
if (typeof document === "undefined") return;
|
|
727
|
-
|
|
794
|
+
// Bail before requesting if the browser reports no active user activation:
|
|
795
|
+
// requestPointerLock would reject, so short-circuit to the cue + gesture retry.
|
|
796
|
+
// (Absent userActivation — older browsers, Node stubs — means "can't tell" → proceed.)
|
|
797
|
+
const ua =
|
|
798
|
+
typeof navigator !== "undefined"
|
|
799
|
+
? (navigator as Navigator & { userActivation?: { isActive?: boolean } }).userActivation
|
|
800
|
+
: undefined;
|
|
801
|
+
if (ua && ua.isActive === false) {
|
|
802
|
+
this._retryArmed = true;
|
|
803
|
+
this._setAimState("unlocked", "needs-gesture", true);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
// Prefer raw device input (no OS mouse acceleration) where supported.
|
|
807
|
+
const request = this._domElement.requestPointerLock as unknown as (
|
|
808
|
+
options?: { unadjustedMovement?: boolean },
|
|
809
|
+
) => Promise<void> | undefined;
|
|
810
|
+
const ret = request.call(this._domElement, unadjusted ? { unadjustedMovement: true } : undefined);
|
|
728
811
|
ret?.catch?.((err: unknown) => {
|
|
729
|
-
|
|
812
|
+
const name = (err as DOMException)?.name;
|
|
813
|
+
if (name === "NotSupportedError" && unadjusted) {
|
|
814
|
+
this._requestLock(false); // retry without the raw-input option
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (name === "SecurityError") {
|
|
730
818
|
this._setAimState("unavailable", "rejected");
|
|
819
|
+
return;
|
|
731
820
|
}
|
|
821
|
+
// A rejection can land AFTER a menu paused aim (requestPointerLock is async,
|
|
822
|
+
// same race as the late grant in _onLockChange). Never pop the "click to aim"
|
|
823
|
+
// cue over an open menu — stay paused; the resume path re-requests anyway.
|
|
824
|
+
if (this._pausedByGame) return;
|
|
825
|
+
this._retryArmed = true;
|
|
826
|
+
this._setAimState("unlocked", "needs-gesture", true);
|
|
732
827
|
});
|
|
733
828
|
}
|
|
734
829
|
|
|
735
|
-
/**
|
|
736
|
-
|
|
737
|
-
|
|
830
|
+
/**
|
|
831
|
+
* Transition aim state and fire onAimChange. Same-state transitions are a no-op
|
|
832
|
+
* UNLESS `forceEmit` (the "needs-gesture" re-emit, which keeps the "unlocked"
|
|
833
|
+
* cue up after a failed silent re-lock — see FollowCameraAimEvent).
|
|
834
|
+
*/
|
|
835
|
+
private _setAimState(
|
|
836
|
+
state: FollowCameraAimState,
|
|
837
|
+
reason: FollowCameraAimEvent["reason"],
|
|
838
|
+
forceEmit = false,
|
|
839
|
+
): void {
|
|
840
|
+
if (state === this._aimState && !forceEmit) return;
|
|
738
841
|
const prev = this._aimState;
|
|
739
842
|
this._aimState = state;
|
|
740
843
|
this._onAimChange?.({ state, prev, reason });
|
|
@@ -35,31 +35,42 @@ rules, floating-origin shot, pointer controls, and implementation limits.
|
|
|
35
35
|
|
|
36
36
|
## Aiming and pointer lock
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
On the bundled `FollowCamera`, **pointer-lock aim is ON by default** on desktop:
|
|
39
|
+
a click locks the pointer and raw mouse movement drives the view. You rarely turn
|
|
40
|
+
it on — you decide whether to turn it OFF. Name the bucket in the build plan:
|
|
39
41
|
|
|
40
42
|
- **MANDATORY** — first-person of any kind (FPS, walking sim, horror) and any
|
|
41
|
-
mouse-aimed action (third-person shooter, turret/range).
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
43
|
+
mouse-aimed action (third-person shooter, turret/range). Lock is on by default;
|
|
44
|
+
leave it on. Shipping this unlocked is a defect, not a style choice — validation
|
|
45
|
+
fails it.
|
|
46
|
+
- **HIGHLY RECOMMENDED** — third-person free-camera action/adventure (the default
|
|
47
|
+
`genex controller character` game). On by default; leave it on. Opt out with
|
|
48
|
+
`pointerLockAim: false` only for a stated reason (a cursor-heavy UI at the core
|
|
49
|
+
of play).
|
|
50
|
+
- **NEVER** — cursor-core games (top-down click-to-move, tower defense, builders,
|
|
51
|
+
card/board/puzzle) and spectator/orbit showcases. These **must pass
|
|
52
|
+
`pointerLockAim: false`** — otherwise the bundled camera grabs the cursor on the
|
|
53
|
+
first click. (Touch needs nothing: pointer lock doesn't exist there and the mode
|
|
54
|
+
no-ops on coarse pointers.)
|
|
49
55
|
|
|
50
56
|
**Mechanism — games on the bundled controller (most games):** do NOT hand-roll
|
|
51
|
-
lock handling
|
|
57
|
+
lock handling. Aim is already enabled; the kit ships a ready-made cue overlay —
|
|
58
|
+
wire `createAimCue()` (from `aim-cue.ts`, installed by `genex controller
|
|
59
|
+
character`) to `onAimChange` and you get the reticle + "click to aim" cue for free:
|
|
52
60
|
|
|
61
|
+
import { createAimCue } from "./controllers/character/aim-cue.ts";
|
|
62
|
+
|
|
63
|
+
const cue = createAimCue(); // reticle when locked, "Click to aim — Esc pauses" when not
|
|
53
64
|
const followCam = new FollowCamera(camera, {
|
|
54
65
|
domElement: renderer.domElement,
|
|
55
|
-
|
|
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
|
-
},
|
|
66
|
+
onAimChange: cue.onAimChange,
|
|
61
67
|
});
|
|
62
68
|
|
|
69
|
+
Cursor-core game? Pass `pointerLockAim: false` instead and skip the cue. Want a
|
|
70
|
+
custom HUD? Read `onAimChange` yourself — `{ state }` is one of `locked` /
|
|
71
|
+
`unlocked` / `unavailable` / `paused` / `off` (the `needs-gesture` re-emit repeats
|
|
72
|
+
`unlocked`, so treat states idempotently).
|
|
73
|
+
|
|
63
74
|
Yaw/pitch re-sync on lock acquire is built in (aim shares the orbit state — the
|
|
64
75
|
view never snaps). First-person is the same mode plus three lines: pin the zoom
|
|
65
76
|
(`minDistance`/`maxDistance` ≈ 0.1), feed an eye-height target to `moveTo`, and
|
|
@@ -89,6 +100,18 @@ grants the permission.
|
|
|
89
100
|
6. If the lock request is rejected (a third-party page embedding the game
|
|
90
101
|
without `allow="pointer-lock"`), the mode falls back to drag-orbit — show
|
|
91
102
|
"Drag to look" instead. Never a dead game.
|
|
103
|
+
7. Gate firing/actions on `followCam.aimState === "locked"` — the click that
|
|
104
|
+
acquires the lock (and any drag in the fallback) must not also shoot.
|
|
105
|
+
8. Do NOT exit the lock between rounds, respawns, or cutscenes — only for real
|
|
106
|
+
menus (`setPaused`). A lock you never released needs no gesture to keep.
|
|
107
|
+
9. A re-lock that can't run without a fresh gesture (a resume outside a click, or
|
|
108
|
+
Chrome's post-Esc cooldown) fires `onAimChange` with reason `needs-gesture` and
|
|
109
|
+
keeps the "click to aim" cue up; the next click or keypress re-locks. Never
|
|
110
|
+
retry on a timer.
|
|
111
|
+
|
|
112
|
+
**Not an OS setting:** pointer `movementX/movementY` is never inverted by the OS
|
|
113
|
+
(trackpad "natural scrolling" only flips the wheel). If look feels inverted it's a
|
|
114
|
+
sign bug in the rig, not a device quirk — fix the sign, don't sniff the trackpad.
|
|
92
115
|
|
|
93
116
|
## Non-negotiable rules
|
|
94
117
|
|
|
@@ -89,7 +89,8 @@ fork as a migration strategy. Install a fresh copy elsewhere and port only the n
|
|
|
89
89
|
| `shared/colliders.ts` | `cuboidCollider`, `collidersFromObject`, … | colliders for level geometry and GLB props |
|
|
90
90
|
| `character/character-controller.ts` | `CharacterController` | the floating-capsule movement brain |
|
|
91
91
|
| `character/presets.ts` | `characterPresets` | six named tunings |
|
|
92
|
-
| `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback and
|
|
92
|
+
| `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback and pointer-lock aim ON by default (opt out with `pointerLockAim: false`) |
|
|
93
|
+
| `character/aim-cue.ts` | `createAimCue` | drop-in reticle + "click to aim" overlay; wire to `FollowCamera`'s `onAimChange` |
|
|
93
94
|
| `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
|
|
94
95
|
| `touch/*` | `TouchJoystick`, `VirtualButton`, `DragZone`, `RotateOverlay` | the shared touch kit — `$genex-threejs-touch-controls` owns the genre recipes |
|
|
95
96
|
| `character/character-animations.ts` | `CharacterAnimations` | animation state machine, directional profiles, speed-matched cadence, `playOneShot`, procedural fallback |
|
|
@@ -107,6 +108,7 @@ import { CharacterAnimations } from "./controllers/character/character-animation
|
|
|
107
108
|
import { loadCharacterClips } from "./controllers/character/animation-packs.ts";
|
|
108
109
|
import { characterPresets } from "./controllers/character/presets.ts";
|
|
109
110
|
import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
111
|
+
import { createAimCue } from "./controllers/character/aim-cue.ts";
|
|
110
112
|
import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
|
|
111
113
|
import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
|
|
112
114
|
import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
|
|
@@ -142,9 +144,13 @@ const anims = new CharacterAnimations(avatar, clips);
|
|
|
142
144
|
addEventListener("pointerdown", () => anims.playOneShot("Punch_Jab")); // punch on click
|
|
143
145
|
|
|
144
146
|
const kb = new KeyboardInput();
|
|
147
|
+
// Pointer-lock aim is ON by default (desktop). The kit's cue overlay gives you the
|
|
148
|
+
// reticle + "click to aim" prompt; pass `pointerLockAim: false` for cursor-core games.
|
|
149
|
+
const aimCue = createAimCue();
|
|
145
150
|
const followCam = new FollowCamera(camera, {
|
|
146
151
|
domElement: renderer.domElement,
|
|
147
152
|
colliderMeshes: staticWallMeshes, // static environment ONLY — never the character mesh
|
|
153
|
+
onAimChange: aimCue.onAimChange,
|
|
148
154
|
});
|
|
149
155
|
|
|
150
156
|
// Fixed-substep phase: input + controller brain, BEFORE world.step().
|
|
@@ -106,6 +106,8 @@ import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
|
106
106
|
const followCam = new FollowCamera(camera, {
|
|
107
107
|
domElement: renderer.domElement,
|
|
108
108
|
colliderMeshes: staticWallMeshes,
|
|
109
|
+
// Pointer-lock aim is ON by default on desktop — see §5.1 to wire the cue, or
|
|
110
|
+
// pass `pointerLockAim: false` for a cursor-core game (RTS, card, builder).
|
|
109
111
|
});
|
|
110
112
|
```
|
|
111
113
|
|
|
@@ -124,24 +126,29 @@ Rules that matter:
|
|
|
124
126
|
- v1 limits (by design): no truck/pan, and the orbit space assumes the up axis
|
|
125
127
|
stays roughly world +Y — far-from-Y custom gravity will misbehave.
|
|
126
128
|
|
|
127
|
-
### Pointer-lock aim (
|
|
129
|
+
### Pointer-lock aim (on by default)
|
|
128
130
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
131
|
+
Aim is enabled by default on desktop: a click locks the pointer and mouse movement
|
|
132
|
+
drives the orbit directly (no drag). FollowCamera ships no DOM of its own, but the
|
|
133
|
+
kit ships a cue overlay — wire `createAimCue()` to `onAimChange` for the reticle +
|
|
134
|
+
"click to aim" prompt:
|
|
132
135
|
|
|
133
136
|
```ts
|
|
137
|
+
import { createAimCue } from "./controllers/character/aim-cue.ts";
|
|
138
|
+
|
|
139
|
+
const aimCue = createAimCue();
|
|
134
140
|
const followCam = new FollowCamera(camera, {
|
|
135
141
|
domElement: renderer.domElement,
|
|
136
142
|
colliderMeshes: staticWallMeshes,
|
|
137
|
-
|
|
138
|
-
onAimChange: ({ state }) => {
|
|
139
|
-
reticle.style.display = state === "locked" ? "" : "none";
|
|
140
|
-
cue.style.display = state === "unlocked" || state === "unavailable" ? "" : "none";
|
|
141
|
-
},
|
|
143
|
+
onAimChange: aimCue.onAimChange, // reticle when locked, cue when not
|
|
142
144
|
});
|
|
143
145
|
```
|
|
144
146
|
|
|
147
|
+
Want a custom HUD? Read `onAimChange: ({ state }) => …` yourself instead of the cue.
|
|
148
|
+
|
|
149
|
+
- **Opt out:** cursor-core games (RTS, tower defense, card, builder) pass
|
|
150
|
+
`pointerLockAim: false` — the camera then stays drag-orbit and never grabs the
|
|
151
|
+
cursor.
|
|
145
152
|
- **Menus / vehicles:** `followCam.setPaused(true)` when a menu opens or the
|
|
146
153
|
player starts driving; `followCam.setPaused(false)` INSIDE the closing
|
|
147
154
|
click/keypress handler (re-lock needs a user gesture). Aim is on-foot only.
|
|
@@ -152,8 +159,8 @@ const followCam = new FollowCamera(camera, {
|
|
|
152
159
|
third-party embed without `allow="pointer-lock"` goes `"unavailable"` and
|
|
153
160
|
drag-orbit stays as the fallback — never a dead camera.
|
|
154
161
|
|
|
155
|
-
The
|
|
156
|
-
|
|
162
|
+
The bucket rule (mandatory / recommended / never) and the full lock lifecycle
|
|
163
|
+
contract live in `$genex-threejs-camera-direction`.
|
|
157
164
|
|
|
158
165
|
## 6. The loop — exact order
|
|
159
166
|
|
|
@@ -35,9 +35,9 @@ no matter how good it looks.
|
|
|
35
35
|
dimmed icon — silence reads as broken input.
|
|
36
36
|
- Camera-aimed shooting wants **pointer lock** — firing at a reticle with an
|
|
37
37
|
unlocked drag-to-turn camera feels imprecise no matter how tight the numbers
|
|
38
|
-
are. The
|
|
39
|
-
`$genex-threejs-camera-direction`; on the bundled controller it's
|
|
40
|
-
|
|
38
|
+
are. The bucket rule + the bundled `FollowCamera` aim mode live in
|
|
39
|
+
`$genex-threejs-camera-direction`; on the bundled controller it's ON by default
|
|
40
|
+
(with a ready-made cue), not hand-rolled events.
|
|
41
41
|
|
|
42
42
|
## Movement: snappy beats realistic
|
|
43
43
|
|
|
@@ -239,6 +239,12 @@ document.addEventListener("keydown", (e) => {
|
|
|
239
239
|
});
|
|
240
240
|
```
|
|
241
241
|
|
|
242
|
+
- **On the bundled `FollowCamera`, it owns the lock — don't fight it.** Do NOT
|
|
243
|
+
call `document.exitPointerLock()` / `canvas.requestPointerLock()` yourself; that
|
|
244
|
+
desyncs its aim state (the cue flips wrong). Instead pause/resume through it:
|
|
245
|
+
`followCam.setPaused(true)` on Escape, `followCam.setPaused(false)` from the
|
|
246
|
+
Resume click, and read `onAimChange` for the cue. The raw calls above are only
|
|
247
|
+
for a hand-rolled camera with no bundled controller.
|
|
242
248
|
- **Degradation is built in.** Where Keyboard Lock is absent (Safari, Firefox) or
|
|
243
249
|
the game isn't fullscreen, the browser still releases pointer lock on Escape —
|
|
244
250
|
so ALSO keep the pointer-lock-loss → pause path (`pointerlockchange`: if
|
|
@@ -246,8 +252,14 @@ document.addEventListener("keydown", (e) => {
|
|
|
246
252
|
only *also* drops fullscreen on browsers without the lock — unavoidable there.
|
|
247
253
|
- **Only for immersive/pointer-lock games.** A top-down or menu-driven game never
|
|
248
254
|
captures the mouse and has no window to shrink — skip all of this.
|
|
249
|
-
- **Dashboard embed:**
|
|
250
|
-
lock
|
|
255
|
+
- **Dashboard embed:** no setup needed — the platform's game frame grants keyboard
|
|
256
|
+
lock (and pointer lock + fullscreen), so Escape-to-pause works the same inside
|
|
257
|
+
`/world/` + `/draft/` as it does standalone (`<slug>.genex.technology`).
|
|
258
|
+
|
|
259
|
+
A mouse-aim game earns its keep with a **look-sensitivity slider** in the pause
|
|
260
|
+
menu — trackpads feel slower than mice, so let the player tune it. On the bundled
|
|
261
|
+
camera the setter is live: `slider.oninput = () => { followCam.aimSensitivity = +slider.value; };`
|
|
262
|
+
(radians per pixel; default `0.0023`, a usable range is ~`0.0008`–`0.005`).
|
|
251
263
|
|
|
252
264
|
## The loader
|
|
253
265
|
|
|
@@ -66,8 +66,9 @@ slow physical projectiles, all against the same damage/defeat rules.)
|
|
|
66
66
|
|
|
67
67
|
**Decisions:**
|
|
68
68
|
- **Aim with pointer lock** — a first-person or mouse-aimed shooter is the MANDATORY pointer-lock
|
|
69
|
-
bucket (see `$genex-threejs-camera-direction`);
|
|
70
|
-
|
|
69
|
+
bucket (see `$genex-threejs-camera-direction`); the bundled camera locks by default, so ship it
|
|
70
|
+
locked (not drag-to-turn) — and gate firing on `aimState === "locked"` so the lock-acquiring
|
|
71
|
+
click doesn't also shoot.
|
|
71
72
|
- **The attacker judges the hit locally** ("favor the shooter"): raycast/cone-check against what
|
|
72
73
|
*you* see, then broadcast ONE attack event naming the `targets` — and draw your own muzzle
|
|
73
74
|
flash / tracer / swing arc **right there**, because `send` never echoes back to you. A high-ping
|
|
@@ -196,13 +196,16 @@ concept-driven — a richer first build beats a grey-box one.
|
|
|
196
196
|
|
|
197
197
|
- Start from the playable game target: player verb, scene scale, camera distance,
|
|
198
198
|
input mode, and frame budget.
|
|
199
|
-
- Pointer bucket (decide before building, state it in the plan):
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
- Pointer bucket (decide before building, state it in the plan): the bundled
|
|
200
|
+
`FollowCamera` locks the pointer by default on desktop, so this is mostly a
|
|
201
|
+
decision to opt OUT. **mandatory pointer lock** — first-person of any kind, and
|
|
202
|
+
any mouse-aimed action (third-person shooter, turret/range); leave it on.
|
|
203
|
+
**Lock by default** — third-person free-camera action/adventure; on already,
|
|
204
|
+
drag-orbit (`pointerLockAim: false`) only with a stated reason (e.g. a
|
|
203
205
|
cursor-heavy UI core). **Never** — cursor-core games (click-to-move, tower
|
|
204
|
-
defense, builder, card/puzzle), orbit showcases, touch-only
|
|
205
|
-
the full aim contract live in
|
|
206
|
+
defense, builder, card/puzzle), orbit showcases, touch-only; these MUST pass
|
|
207
|
+
`pointerLockAim: false`. The mechanism and the full aim contract live in
|
|
208
|
+
`$genex-threejs-camera-direction`.
|
|
206
209
|
- Art direction follows THIS game's concept. The style examples inside skills
|
|
207
210
|
are examples, not defaults — never default to neon/cyberpunk/synthwave (or any
|
|
208
211
|
other single register) unless the concept calls for it.
|
|
@@ -207,9 +207,10 @@ onHandoff: (fromId, toId) => followCam.setPaused(toId !== CHARACTER_ID),
|
|
|
207
207
|
|
|
208
208
|
Enter is clean — exiting a lock needs no gesture. On **exit**, though, `onHandoff`
|
|
209
209
|
runs deferred inside `update()`, NOT in the F-key handler, so `setPaused(false)`'s
|
|
210
|
-
re-lock request has no live user gesture
|
|
211
|
-
|
|
212
|
-
|
|
210
|
+
re-lock request has no live user gesture: aim emits `needs-gesture`, the "click to
|
|
211
|
+
aim" cue stays up, and the next gesture (a click OR any keypress — the walk keys
|
|
212
|
+
count) re-locks automatically. So it recovers on its own; just don't expect the lock
|
|
213
|
+
back on the exact exit frame. Vehicle cameras keep their `alignHeading` behavior.
|
|
213
214
|
|
|
214
215
|
## Multiplayer
|
|
215
216
|
|
|
@@ -106,10 +106,14 @@ everything twice.
|
|
|
106
106
|
fix it).
|
|
107
107
|
6. Aim games get one extra pass (MANDATORY bucket — first-person or mouse-aimed):
|
|
108
108
|
state which pointer bucket the game chose; click the canvas and assert the
|
|
109
|
-
pointer locks (cursor gone, mouse turns the view
|
|
110
|
-
"click to aim/resume" cue
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
pointer locks (cursor gone, mouse-RIGHT turns the view RIGHT — check the axis,
|
|
110
|
+
not just that it moves); press Esc and assert the "click to aim/resume" cue
|
|
111
|
+
appears. The cue should be the bundled `createAimCue` helper (or an equivalent
|
|
112
|
+
`onAimChange`-driven overlay) — a MANDATORY-bucket game with no unlocked cue
|
|
113
|
+
fails. Also confirm a cursor-core game opted OUT (`pointerLockAim: false`) so it
|
|
114
|
+
isn't grabbing the cursor. Headless caveat: `requestPointerLock` throws in
|
|
115
|
+
headless Chromium — assert the wiring and the unlocked cue in a screenshot, and
|
|
116
|
+
say plainly that the lock itself needs one manual click.
|
|
113
117
|
7. **Ask the scene the three things the screenshot cannot answer** (below). Run it
|
|
114
118
|
once, in the same browser you already have open.
|
|
115
119
|
|