@genex-ai/cli-demo 0.42.0 → 0.44.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/package.json +1 -1
- package/templates/controllers/character/follow-camera.ts +166 -0
- package/templates/skills/genex-ai-model/SKILL.md +4 -2
- 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 +70 -23
- package/templates/skills/genex-threejs-game-feel/SKILL.md +5 -4
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +40 -11
- 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 +21 -5
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
|
|
@@ -58,8 +58,10 @@ side — reads as broken at a glance.
|
|
|
58
58
|
- **Verify it, don't assume it.** Orientation **is** visible in a still — in your
|
|
59
59
|
self-check screenshot confirm the hero faces its travel direction AND that NPCs driven
|
|
60
60
|
by chase/aim code face their target (an enemy rotated 90° from its victim is this
|
|
61
|
-
pipeline's most common visible bug).
|
|
62
|
-
|
|
61
|
+
pipeline's most common visible bug). On an unpublished draft, capture real gameplay in
|
|
62
|
+
local test mode (`?genex_local_test=1` on the dev server — the embed-auth skill's
|
|
63
|
+
"Self-testing a draft" section); if you still can't capture gameplay, say so plainly
|
|
64
|
+
instead of skipping the check silently.
|
|
63
65
|
|
|
64
66
|
## Load it into the scene
|
|
65
67
|
|
|
@@ -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
|
|
|
@@ -23,6 +23,9 @@ for both, using the SDK's token. The SDK handles every context with one
|
|
|
23
23
|
zero clicks; everyone else arrives as a guest with a small dismissible
|
|
24
24
|
"sign in to save progress" popover (rendered by the SDK — don't build your
|
|
25
25
|
own). Nobody ever hits a login wall on a published game.
|
|
26
|
+
- **Local test mode (your own self-testing):** `?genex_local_test=1` on the
|
|
27
|
+
local dev server boots a credential-less local session — see "Self-testing
|
|
28
|
+
a draft" below.
|
|
26
29
|
|
|
27
30
|
While identity is resolving (or blocked) the SDK shows its own full-screen
|
|
28
31
|
overlay over the game, so never build a separate "connecting" screen for auth.
|
|
@@ -102,7 +105,7 @@ boot-path gate; `waitForAuth()` guards saves only.
|
|
|
102
105
|
- `getAuthState()` → `"pending" | "authenticated" | "guest" | "blocked"` —
|
|
103
106
|
synchronous.
|
|
104
107
|
- `getUser()` → `{ id, name, image? } | null` — non-null once authenticated OR
|
|
105
|
-
guest. Guest ids are prefixed `guest
|
|
108
|
+
guest. Guest ids are prefixed `guest:` (local test mode: `local:test`).
|
|
106
109
|
- `getEmbedToken()` → `string | undefined` — the raw token, for the RARE
|
|
107
110
|
advanced case of calling the Genex API by hand. The state/leaderboard
|
|
108
111
|
helpers below attach it automatically — prefer them; never hand-roll fetch
|
|
@@ -138,7 +141,8 @@ session is blocked):
|
|
|
138
141
|
- `getLeaderboard({ board?, limit?, order? }?)` → `Promise<{ items, me }>` —
|
|
139
142
|
top entries (verified display names — never trust client-side name input
|
|
140
143
|
for this) + the signed-in player's own `{ rank, score }`. Works for guests
|
|
141
|
-
too (`me: null`). `limit` caps at 100 server-side.
|
|
144
|
+
too (`me: null`). `limit` caps at 100 server-side. Local test mode resolves
|
|
145
|
+
`{ items: [], me: null }` locally.
|
|
142
146
|
|
|
143
147
|
Server write limits (per player, per minute): **60 player-saves, 120
|
|
144
148
|
world-saves, 30 score submits**. A debounced ~1/sec checkpoint never gets near
|
|
@@ -256,30 +260,63 @@ SDK's own top-right "sign in to save progress" popover. The return trip
|
|
|
256
260
|
carries a one-time pass (or an inert guest marker) in the URL that the SDK
|
|
257
261
|
consumes and removes immediately. Unpublished drafts are the exception:
|
|
258
262
|
strangers can't play them, so a draft link shows the SDK's sign-in gate
|
|
259
|
-
instead. Don't code around any
|
|
260
|
-
be affected, and `isEmbedded()`
|
|
261
|
-
concerns.
|
|
263
|
+
instead (for self-testing, see local test mode below). Don't code around any
|
|
264
|
+
of this: no `?`/`#` URL params of yours will be affected, and `isEmbedded()`
|
|
265
|
+
/ the return-trip handling are internal SDK concerns.
|
|
262
266
|
|
|
263
|
-
###
|
|
267
|
+
### Self-testing a draft: local test mode
|
|
264
268
|
|
|
265
269
|
An unpublished draft shows the sign-in gate to any browser that isn't signed
|
|
266
270
|
in as the owner — **including your own test browser** (Playwright, headless
|
|
267
|
-
Chrome)
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
271
|
+
Chrome) — and the hosted draft URL applies the same identity rule. The one
|
|
272
|
+
supported way to see and play the game yourself is **local test mode**: open
|
|
273
|
+
the local dev server with the explicit opt-in marker —
|
|
274
|
+
|
|
275
|
+
```
|
|
276
|
+
http://localhost:5173/?genex_local_test=1
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
(any port; append with `&` if the URL already has a query). On an exact http
|
|
280
|
+
loopback origin (`localhost`, `127.0.0.1`, `[::1]`) the SDK skips the
|
|
281
|
+
identity flow entirely and boots a guest-like session: no redirect, no
|
|
282
|
+
overlay, `waitForPlayer()` resolves with the unmistakable local identity
|
|
283
|
+
`{ id: "local:test", name: "Local Tester" }`, and the console prints a
|
|
284
|
+
"local test mode" notice. Requires `@genex-ai/embed-sdk` 0.5.0+ — on an older
|
|
285
|
+
project the marker does nothing; apply the pending platform update first (any
|
|
286
|
+
`genex` command prints the update nudge and how to apply it), then retry.
|
|
287
|
+
|
|
288
|
+
**It validates:** rendering, camera, controls, HUD, game feel, and real
|
|
289
|
+
gameplay screenshots — everything local.
|
|
290
|
+
|
|
291
|
+
**It does NOT validate** (nothing online exists in this mode; no credential
|
|
292
|
+
is ever minted): real sign-in (`waitForAuth()` stays pending, exactly like a
|
|
293
|
+
guest), saves (they queue in memory), leaderboards (`getLeaderboard()`
|
|
294
|
+
resolves `{ items: [], me: null }` locally), score submits, and multiplayer
|
|
295
|
+
(`getColyseusAuth()` is `undefined`, so `connect()` fails at the relay —
|
|
296
|
+
expected in this mode, not a bug to chase).
|
|
297
|
+
|
|
298
|
+
Rules:
|
|
299
|
+
|
|
300
|
+
- **Label the evidence** in your handoff: "validated in local test mode —
|
|
301
|
+
auth, saves, and multiplayer not exercised." Presenting a local-test
|
|
302
|
+
capture as full validation is an over-claim.
|
|
303
|
+
- Pointer lock still can't be acquired headlessly (`requestPointerLock`
|
|
304
|
+
throws in headless Chromium): for aim games validate the unlocked "click
|
|
305
|
+
to aim" cue and the wiring, not the lock itself (see
|
|
306
|
+
`$genex-threejs-visual-validation` step 6).
|
|
307
|
+
- The marker is inert on any hosted URL, on https, and inside any iframe —
|
|
308
|
+
local test mode cannot open the hosted draft; hosted draft access stays
|
|
309
|
+
owner-only. Do NOT work around that gate: no undocumented URL fragments,
|
|
310
|
+
no auth mocks, and never drive the user's own signed-in browser.
|
|
311
|
+
- Opening localhost WITHOUT the marker keeps the normal real-auth flow (the
|
|
312
|
+
identity bounce) — that's for testing real sign-in, not for self-testing.
|
|
313
|
+
- The owner's draft run-through stays a required beat, not a fallback: push
|
|
314
|
+
`genex preview` at each playable milestone and hand it off plainly ("check
|
|
315
|
+
the draft — you can now X; ping me if something feels off"), then keep
|
|
316
|
+
building while they look. Hosted QA catches what local test mode can't:
|
|
317
|
+
real identity, saves, multiplayer, feel. Once the game is **published**,
|
|
318
|
+
any fresh browser gets in as a guest, so full visual validation also works
|
|
319
|
+
without the marker.
|
|
283
320
|
|
|
284
321
|
## Checklist
|
|
285
322
|
|
|
@@ -300,6 +337,8 @@ and a gate capture is NOT visual evidence.
|
|
|
300
337
|
- [ ] No token value is ever logged or sent to analytics.
|
|
301
338
|
- [ ] No custom sign-in prompt, guest badge, or auth overlay — the SDK popover/
|
|
302
339
|
overlay and the dashboard own all of that UX.
|
|
340
|
+
- [ ] Self-test evidence captured in local test mode is labeled as such in the
|
|
341
|
+
handoff ("local test mode — auth, saves, and multiplayer not exercised").
|
|
303
342
|
|
|
304
343
|
## Troubleshooting
|
|
305
344
|
|
|
@@ -312,7 +351,15 @@ and a gate capture is NOT visual evidence.
|
|
|
312
351
|
- **State is `"blocked"` / `waitForPlayer()` rejects** — an unpublished draft
|
|
313
352
|
opened by a non-owner, or auth infrastructure was unreachable. The SDK
|
|
314
353
|
overlay (or the dashboard, when embedded) shows the sign-in prompt; the game
|
|
315
|
-
just stays paused behind it. Don't retry in a loop.
|
|
354
|
+
just stays paused behind it. Don't retry in a loop. Self-testing a draft
|
|
355
|
+
locally? Use local test mode (`?genex_local_test=1`) instead.
|
|
356
|
+
- **Local test mode doesn't activate** — check all four: the value is exactly
|
|
357
|
+
`genex_local_test=1`, the origin is http loopback (`localhost`/`127.0.0.1`/
|
|
358
|
+
`[::1]` — not https, not a LAN IP), the page is not inside an iframe, and
|
|
359
|
+
`@genex-ai/embed-sdk` is 0.5.0+ (older: apply the pending platform update).
|
|
360
|
+
- **Multiplayer `connect()` fails in local test mode** — by design: no relay
|
|
361
|
+
credential exists there. Validate multiplayer on the hosted draft (the
|
|
362
|
+
owner's session) or the published game, and say plainly when it wasn't.
|
|
316
363
|
- **Multiplayer join rejected with 401** — `connect()` ran before
|
|
317
364
|
`waitForPlayer()` resolved, without `auth: getColyseusAuth()!`, or with a
|
|
318
365
|
stale cached token on reconnect (read it fresh each call).
|
|
@@ -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):
|
|
@@ -429,7 +451,11 @@ from any still capture whether movement feels smooth.** Don't try — it leads t
|
|
|
429
451
|
|
|
430
452
|
1. **Trust the SDK's smoothing.** Draw `state` directly; don't add your own.
|
|
431
453
|
2. **Verify it *runs*:** two clients, distinct meshes, both move, no console errors, each sees the
|
|
432
|
-
other (and the ball, if any). That's all a capture can prove.
|
|
454
|
+
other (and the ball, if any). That's all a capture can prove. Local test mode (the embed-auth
|
|
455
|
+
skill's `?genex_local_test=1`) can NOT do this: it mints no relay credential, so `connect()`
|
|
456
|
+
fails there by design and two local-test tabs never see each other — run the two-client check
|
|
457
|
+
on the published game (or have the owner open their draft), and if multiplayer wasn't
|
|
458
|
+
exercised, say exactly that in your handoff instead of implying it was.
|
|
433
459
|
3. **Then say plainly:** *"Multiplayer smoothness depends on your network and can only be felt by a
|
|
434
460
|
person — open it in two tabs or with a friend and tell me how it feels."* Stop there.
|
|
435
461
|
|
|
@@ -530,6 +556,9 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
530
556
|
- [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
|
|
531
557
|
- [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
|
|
532
558
|
- [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
|
|
559
|
+
- [ ] Waiting room (if any): overlay driven by `mm.matchmaking.status` read every frame, gone the
|
|
560
|
+
moment it flips to `'playing'` — and you WATCHED it close in two browser windows at
|
|
561
|
+
`minPlayers` (never gated on `matchStart` or a host `shared` signal alone).
|
|
533
562
|
- [ ] Picked the matching recipe from [references/genre-recipes.md](references/genre-recipes.md).
|
|
534
563
|
|
|
535
564
|
## 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
|
|
|
@@ -25,18 +25,26 @@ evidence, temporal checks, budgets, and explicit rejection criteria.
|
|
|
25
25
|
## Interaction smoke check (the game fast path)
|
|
26
26
|
|
|
27
27
|
For plain game tasks — nothing from the procedural/visual-system pack loaded —
|
|
28
|
-
this is the whole acceptance gate, and it is also the
|
|
28
|
+
this is the whole acceptance gate, and it is also the *ceiling*: a smoke check,
|
|
29
|
+
not a certification. Run it ONCE per milestone to catch obvious breakage, fix
|
|
30
|
+
what's clearly broken, and hand the feel/polish judgment to the player — don't
|
|
31
|
+
loop re-testing the same build, and don't try to exercise every button and edge
|
|
32
|
+
case yourself. The player deciding "does it feel right?" is faster and truer
|
|
33
|
+
than you clicking everything twice.
|
|
29
34
|
|
|
30
35
|
1. Load the page in a real browser; the canvas renders (no black screen, no
|
|
31
|
-
console errors).
|
|
36
|
+
console errors). For an unpublished draft, open the dev server in local
|
|
37
|
+
test mode — `http://localhost:5173/?genex_local_test=1` (the embed-auth
|
|
38
|
+
skill's "Self-testing a draft" section) — so you see the game, not the
|
|
39
|
+
sign-in gate.
|
|
32
40
|
2. Press each documented control once (keys, pointer); assert a **visible
|
|
33
41
|
response** to every one — the player moves, the camera turns, the button
|
|
34
42
|
fires.
|
|
35
43
|
3. Capture one screenshot of live gameplay — of the **game**, not a sign-in
|
|
36
44
|
gate or loading screen. A capture of the SDK's "Sign in to play" overlay is
|
|
37
|
-
NOT gameplay evidence
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
NOT gameplay evidence. Evidence captured in local test mode must be labeled
|
|
46
|
+
as such in the handoff ("local test mode — auth, saves, and multiplayer not
|
|
47
|
+
exercised"); presenting it as full validation is an over-claim.
|
|
40
48
|
4. In that screenshot, check oriented models: the hero faces its travel
|
|
41
49
|
direction, and NPCs driven by chase/aim code face their target. A model
|
|
42
50
|
rotated 90° reads as broken — `$genex-ai-model` has the one-time facing
|
|
@@ -47,6 +55,12 @@ this is the whole acceptance gate, and it is also the minimum for every game:
|
|
|
47
55
|
floating above detached wheels means the model was box-fit against the
|
|
48
56
|
preset's wheelbase (the vehicle skill's "Custom generated bodies" rules
|
|
49
57
|
fix it).
|
|
58
|
+
6. Aim games get one extra pass (MANDATORY bucket — first-person or mouse-aimed):
|
|
59
|
+
state which pointer bucket the game chose; click the canvas and assert the
|
|
60
|
+
pointer locks (cursor gone, mouse turns the view); press Esc and assert the
|
|
61
|
+
"click to aim/resume" cue appears. Headless caveat: `requestPointerLock`
|
|
62
|
+
throws in headless Chromium — assert the wiring and the unlocked cue in a
|
|
63
|
+
screenshot, and say plainly that the lock itself needs one manual click.
|
|
50
64
|
|
|
51
65
|
Everything deeper (baselines, seed sweeps, mosaics, budgets) belongs to
|
|
52
66
|
visual-system work — the sequence above.
|
|
@@ -63,6 +77,8 @@ visual-system work — the sequence above.
|
|
|
63
77
|
|
|
64
78
|
## Failure conditions
|
|
65
79
|
|
|
80
|
+
- a MANDATORY-bucket aim game never requests pointer lock, or locks with no
|
|
81
|
+
visible unlocked cue;
|
|
66
82
|
- approval relies on a single frame;
|
|
67
83
|
- post-processing cannot be disabled per pass;
|
|
68
84
|
- random seeds are not reproducible;
|