@genex-ai/cli-demo 0.65.0-dev.162 → 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 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.65.0-dev.162",
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). 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.
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
- /** Payload for {@link FollowCameraOptions.onAimChange}. */
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: "user-click" | "esc-or-lost" | "paused" | "resumed" | "rejected" | "init";
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 two document listeners exist only when aim is capable.
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 (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).
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.rotate(e.movementX * s, -e.movementY * s, true);
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
- // 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).
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 === true &&
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). 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.
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
- const ret = this._domElement.requestPointerLock() as unknown as Promise<void> | undefined;
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
- if ((err as DOMException)?.name === "SecurityError") {
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
- /** 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;
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
- Decide the bucket first and say it in the build plan:
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). 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).
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; enable the `FollowCamera` aim mode and wire the two UI states:
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
- 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
- },
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 an opt-in pointer-lock aim mode |
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 (opt-in)
129
+ ### Pointer-lock aim (on by default)
128
130
 
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):
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
- pointerLockAim: true,
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 three-bucket rule (mandatory / recommended / never) and the full lock
156
- lifecycle contract live in `$genex-threejs-camera-direction`.
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 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.
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:** full coverage needs the game iframe to permit keyboard
250
- lock; standalone play (`<slug>.genex.technology`) works today regardless.
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`); ship it locked, not drag-to-turn, or aim feels
70
- imprecise before netcode is even in play.
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): **mandatory
200
- pointer lock** first-person of any kind, and any mouse-aimed action
201
- (third-person shooter, turret/range). **Lock by default** — third-person
202
- free-camera action/adventure; drag-orbit only with a stated reason (e.g. a
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. The mechanism and
205
- the full aim contract live in `$genex-threejs-camera-direction`.
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 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.
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); press Esc and assert the
110
- "click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
111
- throws in headless Chromium assert the wiring and the unlocked cue in a
112
- screenshot, and say plainly that the lock itself needs one manual click.
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