@genex-ai/cli-demo 0.11.0 → 0.14.2

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.
Files changed (42) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +203 -4
  3. package/package.json +7 -2
  4. package/templates/controllers/NOTICE.md +65 -0
  5. package/templates/controllers/assets/animation-library.glb +0 -0
  6. package/templates/controllers/assets/character.glb +0 -0
  7. package/templates/controllers/assets/default-avatar.vrm +0 -0
  8. package/templates/controllers/character/character-animations.ts +682 -0
  9. package/templates/controllers/character/character-controller.ts +1636 -0
  10. package/templates/controllers/character/follow-camera.ts +644 -0
  11. package/templates/controllers/character/keyboard-input.ts +277 -0
  12. package/templates/controllers/character/presets.ts +176 -0
  13. package/templates/controllers/character/touch-joystick.ts +387 -0
  14. package/templates/controllers/character/vrm/capsule-fit.ts +52 -0
  15. package/templates/controllers/character/vrm/foot-ik.ts +341 -0
  16. package/templates/controllers/character/vrm/vrm-loader.ts +44 -0
  17. package/templates/controllers/character/vrm/vrm-retarget.ts +195 -0
  18. package/templates/controllers/drone/drone-controller.ts +1073 -0
  19. package/templates/controllers/drone/presets.ts +225 -0
  20. package/templates/controllers/interact/enter-exit.ts +502 -0
  21. package/templates/controllers/shared/colliders.ts +456 -0
  22. package/templates/controllers/shared/math.ts +230 -0
  23. package/templates/controllers/shared/physics-world.ts +622 -0
  24. package/templates/controllers/vehicle/presets.ts +297 -0
  25. package/templates/controllers/vehicle/vehicle-controller.ts +615 -0
  26. package/templates/controllers/vehicle/wheel.ts +1200 -0
  27. package/templates/skills/genex-getting-started/SKILL.md +5 -0
  28. package/templates/skills/genex-threejs-character-controller/SKILL.md +205 -0
  29. package/templates/skills/genex-threejs-character-controller/references/animations.md +235 -0
  30. package/templates/skills/genex-threejs-character-controller/references/tuning-and-presets.md +102 -0
  31. package/templates/skills/genex-threejs-character-controller/references/wiring.md +198 -0
  32. package/templates/skills/genex-threejs-embed-auth/SKILL.md +126 -54
  33. package/templates/skills/genex-threejs-multiplayer/SKILL.md +17 -11
  34. package/templates/skills/genex-threejs-physics-rapier/SKILL.md +128 -0
  35. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +202 -0
  36. package/templates/skills/genex-threejs-physics-rapier/references/physics-setup.md +207 -0
  37. package/templates/skills/genex-threejs-skill-router/SKILL.md +3 -0
  38. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +15 -7
  39. package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +110 -0
  40. package/templates/skills/genex-threejs-vehicle-controllers/references/car.md +162 -0
  41. package/templates/skills/genex-threejs-vehicle-controllers/references/drone.md +150 -0
  42. package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +199 -0
@@ -0,0 +1,387 @@
1
+ // SPDX-FileCopyrightText: 2023-2026 Erdong Chen
2
+ // SPDX-License-Identifier: MIT
3
+ // Vanilla-TS port of the ecctrl controller's touch input (Joystick + VirtualButton DOM/CSS
4
+ // widgets; React/zustand shells removed — per-instance state + callbacks instead of stores).
5
+ //
6
+ // Port notes / deliberate deviations from upstream:
7
+ // - NEW: `setPointerCapture` on pointerdown keeps drags alive outside the 200px wrapper
8
+ // (upstream relied on `pointerleave` alone; that reset path is kept for parity when
9
+ // capture is unavailable).
10
+ // - `VirtualButton.dispose()` resets only ITS OWN state (upstream unmount reset ALL buttons).
11
+ // - Upstream's hard-coded duplicate DOM ids (joystick/base/knob/button/cap) are dropped;
12
+ // instances are the identity now.
13
+ // - The zustand store keys (`id` props) are gone — create one instance per stick/button.
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Shared style plumbing
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function applyStyle(
20
+ element: HTMLElement,
21
+ base: Partial<CSSStyleDeclaration>,
22
+ override?: Partial<CSSStyleDeclaration>
23
+ ): void {
24
+ Object.assign(element.style, base);
25
+ if (override) Object.assign(element.style, override);
26
+ }
27
+
28
+ /** Legacy vendor prefixes upstream shipped via React CSSProperties (Moz/ms). */
29
+ function applyLegacyUserSelectNone(element: HTMLElement): void {
30
+ element.style.setProperty("-moz-user-select", "none");
31
+ element.style.setProperty("-ms-user-select", "none");
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // TouchJoystick
36
+ // ---------------------------------------------------------------------------
37
+
38
+ // Default styles for the joystick wrapper (the 200px interactive hit area).
39
+ // NOTE: upstream ships NO position (left/bottom commented out) — position via wrapperStyle.
40
+ const DEFAULT_JOYSTICK_WRAPPER_STYLE: Partial<CSSStyleDeclaration> = {
41
+ userSelect: "none",
42
+ webkitUserSelect: "none",
43
+ touchAction: "none",
44
+ overscrollBehavior: "none",
45
+ position: "fixed",
46
+ zIndex: "10",
47
+ height: "200px",
48
+ width: "200px",
49
+ borderRadius: "50%",
50
+ };
51
+
52
+ // Default styles for the joystick base (the 100px reference circle — center math uses THIS).
53
+ const DEFAULT_JOYSTICK_BASE_STYLE: Partial<CSSStyleDeclaration> = {
54
+ width: "100px",
55
+ height: "100px",
56
+ background: "rgba(0, 0, 0, 0.1)",
57
+ border: "2px solid white",
58
+ borderRadius: "50%",
59
+ position: "absolute",
60
+ top: "50%",
61
+ left: "50%",
62
+ transform: "translate(-50%, -50%)",
63
+ touchAction: "none",
64
+ };
65
+
66
+ // Default styles for the joystick knob. The cubic-bezier transition IS the spring-back:
67
+ // the state snaps to 0 instantly on release while the knob overshoots home in CSS.
68
+ const DEFAULT_JOYSTICK_KNOB_STYLE: Partial<CSSStyleDeclaration> = {
69
+ width: "70px",
70
+ height: "70px",
71
+ background: "rgba(255, 255, 255, 0.8)",
72
+ borderRadius: "50%",
73
+ position: "absolute",
74
+ top: "50%",
75
+ left: "50%",
76
+ transform: "translate(-50%, -50%)",
77
+ transition: "transform 0.2s cubic-bezier(0.25, 1.5, 0.5, 1)",
78
+ willChange: "transform",
79
+ pointerEvents: "none",
80
+ };
81
+
82
+ export interface TouchJoystickOptions {
83
+ /** DOM parent; default `document.body`. */
84
+ parent?: HTMLElement;
85
+ /** Max knob travel in px; default 50. Larger = more finger travel for full deflection. */
86
+ maxRadius?: number;
87
+ /**
88
+ * Style overrides merged over the defaults. Positioning (`left`/`bottom`/`right`) MUST come
89
+ * through here — no default position ships; e.g. `{ left: "0", bottom: "0" }` for a
90
+ * bottom-left movement stick.
91
+ */
92
+ wrapperStyle?: Partial<CSSStyleDeclaration>;
93
+ baseStyle?: Partial<CSSStyleDeclaration>;
94
+ knobStyle?: Partial<CSSStyleDeclaration>;
95
+ /** Optional change hook: fires on every move/reset with the new state. */
96
+ onChange?: (x: number, y: number, active: boolean) => void;
97
+ }
98
+
99
+ /**
100
+ * DOM+CSS touch joystick. Read `x`/`y` each render frame and pass them into the character
101
+ * controller: `setMovement({ ...kb.getCharacterMovement(), joystick: { x: joy.x, y: joy.y } })`.
102
+ * A nonzero joystick overrides the digital keys inside the controller — pass both through and
103
+ * let it pick. Note the controller normalizes direction, so deflection magnitude is not speed.
104
+ */
105
+ export class TouchJoystick {
106
+ #maxRadius: number;
107
+ #wrapper: HTMLDivElement;
108
+ #base: HTMLDivElement;
109
+ #knob: HTMLDivElement;
110
+ #x = 0;
111
+ #y = 0;
112
+ #active = false;
113
+ /** Pointer-drag latch — distinct from the public `active` (deflection ≠ 0) flag. */
114
+ #pointerActive = false;
115
+ #onChange: ((x: number, y: number, active: boolean) => void) | undefined;
116
+ #disposed = false;
117
+
118
+ #onContextMenu = (event: Event): void => {
119
+ event.preventDefault();
120
+ };
121
+
122
+ #onPointerDown = (event: PointerEvent): void => {
123
+ event.preventDefault();
124
+ event.stopPropagation();
125
+ this.#move(event.clientX, event.clientY);
126
+ this.#pointerActive = true;
127
+ // NEW vs upstream: capture keeps the drag working outside the 200px wrapper.
128
+ if (typeof this.#wrapper.setPointerCapture === "function") {
129
+ try {
130
+ this.#wrapper.setPointerCapture(event.pointerId);
131
+ } catch {
132
+ // pointer already gone — the pointerleave reset path still covers us
133
+ }
134
+ }
135
+ };
136
+
137
+ #onPointerMove = (event: PointerEvent): void => {
138
+ if (this.#pointerActive) this.#move(event.clientX, event.clientY);
139
+ };
140
+
141
+ #onPointerEnd = (): void => {
142
+ this.#reset();
143
+ };
144
+
145
+ constructor(options: TouchJoystickOptions = {}) {
146
+ this.#maxRadius = options.maxRadius ?? 50;
147
+ this.#onChange = options.onChange;
148
+
149
+ this.#wrapper = document.createElement("div");
150
+ applyStyle(this.#wrapper, DEFAULT_JOYSTICK_WRAPPER_STYLE, options.wrapperStyle);
151
+ applyLegacyUserSelectNone(this.#wrapper);
152
+
153
+ this.#base = document.createElement("div");
154
+ applyStyle(this.#base, DEFAULT_JOYSTICK_BASE_STYLE, options.baseStyle);
155
+ this.#wrapper.appendChild(this.#base);
156
+
157
+ this.#knob = document.createElement("div");
158
+ applyStyle(this.#knob, DEFAULT_JOYSTICK_KNOB_STYLE, options.knobStyle);
159
+ this.#base.appendChild(this.#knob);
160
+
161
+ this.#wrapper.addEventListener("contextmenu", this.#onContextMenu);
162
+ this.#wrapper.addEventListener("pointerdown", this.#onPointerDown);
163
+ this.#wrapper.addEventListener("pointermove", this.#onPointerMove);
164
+ this.#wrapper.addEventListener("pointerup", this.#onPointerEnd);
165
+ this.#wrapper.addEventListener("pointerleave", this.#onPointerEnd);
166
+
167
+ (options.parent ?? document.body).appendChild(this.#wrapper);
168
+ }
169
+
170
+ /** Normalized horizontal deflection in [-1, 1] (right-positive). */
171
+ get x(): number {
172
+ return this.#x;
173
+ }
174
+
175
+ /** Normalized vertical deflection in [-1, 1], UP-positive (screen dy is negated once here). */
176
+ get y(): number {
177
+ return this.#y;
178
+ }
179
+
180
+ /** True iff (x, y) !== (0, 0). */
181
+ get active(): boolean {
182
+ return this.#active;
183
+ }
184
+
185
+ /** The wrapper element (for ad-hoc styling / conditional display). */
186
+ get element(): HTMLDivElement {
187
+ return this.#wrapper;
188
+ }
189
+
190
+ /** Show/hide helper — e.g. show only when `navigator.maxTouchPoints > 0`. */
191
+ setVisible(visible: boolean): void {
192
+ this.#wrapper.style.display = visible ? "" : "none";
193
+ }
194
+
195
+ /** Reset state + remove DOM + listeners. Safe to call mid-drag. */
196
+ dispose(): void {
197
+ if (this.#disposed) return;
198
+ this.#disposed = true;
199
+ this.#reset();
200
+ this.#wrapper.removeEventListener("contextmenu", this.#onContextMenu);
201
+ this.#wrapper.removeEventListener("pointerdown", this.#onPointerDown);
202
+ this.#wrapper.removeEventListener("pointermove", this.#onPointerMove);
203
+ this.#wrapper.removeEventListener("pointerup", this.#onPointerEnd);
204
+ this.#wrapper.removeEventListener("pointerleave", this.#onPointerEnd);
205
+ this.#wrapper.remove();
206
+ }
207
+
208
+ #move(clientX: number, clientY: number): void {
209
+ // Center math uses the BASE rect (100px reference circle), not the 200px wrapper.
210
+ const rect = this.#base.getBoundingClientRect();
211
+ const centerX = rect.left + rect.width / 2;
212
+ const centerY = rect.top + rect.height / 2;
213
+ let dx = clientX - centerX;
214
+ let dy = clientY - centerY;
215
+ const distance = Math.hypot(dx, dy);
216
+ // If the distance exceeds the maximum radius, scale down the movement.
217
+ if (distance > this.#maxRadius) {
218
+ dx *= this.#maxRadius / distance;
219
+ dy *= this.#maxRadius / distance;
220
+ }
221
+
222
+ // The -50% self-centering and the pixel offset ride in ONE translate — replacing this
223
+ // with left/top would break the CSS spring-back.
224
+ this.#knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
225
+
226
+ this.#x = dx / this.#maxRadius;
227
+ this.#y = -dy / this.#maxRadius; // screen-down-positive dy → game-forward-positive y
228
+ this.#active = !(this.#x === 0 && this.#y === 0);
229
+ this.#onChange?.(this.#x, this.#y, this.#active);
230
+ }
231
+
232
+ #reset(): void {
233
+ this.#pointerActive = false;
234
+ // State snaps to 0 immediately; the knob eases back via the CSS transition.
235
+ this.#knob.style.transform = "translate(-50%, -50%)";
236
+ this.#x = 0;
237
+ this.#y = 0;
238
+ this.#active = false;
239
+ this.#onChange?.(0, 0, false);
240
+ }
241
+ }
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // VirtualButton
245
+ // ---------------------------------------------------------------------------
246
+
247
+ // Default style for the virtual button wrapper (the 60px hit area).
248
+ const DEFAULT_BUTTON_WRAPPER_STYLE: Partial<CSSStyleDeclaration> = {
249
+ userSelect: "none",
250
+ webkitUserSelect: "none",
251
+ touchAction: "none",
252
+ overscrollBehavior: "none",
253
+ position: "fixed",
254
+ zIndex: "10",
255
+ height: "60px",
256
+ width: "60px",
257
+ background: "rgba(0, 0, 0, 0.1)",
258
+ borderRadius: "50%",
259
+ };
260
+
261
+ // Default style for the virtual button cap (the 45px visible disc with the label).
262
+ const DEFAULT_BUTTON_CAP_STYLE: Partial<CSSStyleDeclaration> = {
263
+ width: "45px",
264
+ height: "45px",
265
+ background: "rgba(255, 255, 255, 0.8)",
266
+ borderRadius: "50%",
267
+ position: "absolute",
268
+ top: "50%",
269
+ left: "50%",
270
+ transform: "translate(-50%, -50%)",
271
+ transition: "transform 0.2s cubic-bezier(0.25, 1.5, 0.5, 1)",
272
+ willChange: "transform",
273
+ display: "flex",
274
+ justifyContent: "center",
275
+ alignItems: "center",
276
+ fontSize: "12px",
277
+ fontWeight: "bold",
278
+ fontFamily: "Arial, sans-serif",
279
+ color: "LightGray",
280
+ userSelect: "none",
281
+ pointerEvents: "none",
282
+ };
283
+
284
+ export interface VirtualButtonOptions {
285
+ /** Text rendered on the cap, e.g. "Jump" (plain text — set via textContent). */
286
+ label?: string;
287
+ /** DOM parent; default `document.body`. */
288
+ parent?: HTMLElement;
289
+ /** Style overrides; position the button via `wrapperStyle` (e.g. `{ right: "40px", bottom: "90px" }`). */
290
+ wrapperStyle?: Partial<CSSStyleDeclaration>;
291
+ capStyle?: Partial<CSSStyleDeclaration>;
292
+ /** Rising-edge press hook (e.g. an on-screen Enter/Exit button → `mgr.requestInteract()`). */
293
+ onPress?: () => void;
294
+ onRelease?: () => void;
295
+ }
296
+
297
+ /**
298
+ * DOM+CSS virtual button for touch controls. Read `pressed` each frame (e.g.
299
+ * `jump: kb.space || btnJump.pressed`) or use the `onPress`/`onRelease` edge callbacks.
300
+ */
301
+ export class VirtualButton {
302
+ #wrapper: HTMLDivElement;
303
+ #cap: HTMLDivElement;
304
+ #pressed = false;
305
+ #onPress: (() => void) | undefined;
306
+ #onRelease: (() => void) | undefined;
307
+ #disposed = false;
308
+
309
+ #onContextMenu = (event: Event): void => {
310
+ event.preventDefault();
311
+ };
312
+
313
+ #onPointerDown = (event: PointerEvent): void => {
314
+ this.#press(event);
315
+ };
316
+
317
+ #onPointerEnd = (): void => {
318
+ this.#release();
319
+ };
320
+
321
+ constructor(options: VirtualButtonOptions = {}) {
322
+ this.#onPress = options.onPress;
323
+ this.#onRelease = options.onRelease;
324
+
325
+ this.#wrapper = document.createElement("div");
326
+ applyStyle(this.#wrapper, DEFAULT_BUTTON_WRAPPER_STYLE, options.wrapperStyle);
327
+ applyLegacyUserSelectNone(this.#wrapper);
328
+
329
+ this.#cap = document.createElement("div");
330
+ applyStyle(this.#cap, DEFAULT_BUTTON_CAP_STYLE, options.capStyle);
331
+ this.#cap.textContent = options.label ?? "";
332
+ this.#wrapper.appendChild(this.#cap);
333
+
334
+ this.#wrapper.addEventListener("contextmenu", this.#onContextMenu);
335
+ this.#wrapper.addEventListener("pointerdown", this.#onPointerDown);
336
+ this.#wrapper.addEventListener("pointerup", this.#onPointerEnd);
337
+ this.#wrapper.addEventListener("pointerleave", this.#onPointerEnd);
338
+
339
+ (options.parent ?? document.body).appendChild(this.#wrapper);
340
+ }
341
+
342
+ /** True while the button is held. */
343
+ get pressed(): boolean {
344
+ return this.#pressed;
345
+ }
346
+
347
+ /** The wrapper element (for ad-hoc styling / conditional display). */
348
+ get element(): HTMLDivElement {
349
+ return this.#wrapper;
350
+ }
351
+
352
+ /** Show/hide helper. */
353
+ setVisible(visible: boolean): void {
354
+ this.#wrapper.style.display = visible ? "" : "none";
355
+ }
356
+
357
+ /** Release (if held) + remove DOM + listeners. Resets only this button's own state. */
358
+ dispose(): void {
359
+ if (this.#disposed) return;
360
+ this.#disposed = true;
361
+ this.#release();
362
+ this.#wrapper.removeEventListener("contextmenu", this.#onContextMenu);
363
+ this.#wrapper.removeEventListener("pointerdown", this.#onPointerDown);
364
+ this.#wrapper.removeEventListener("pointerup", this.#onPointerEnd);
365
+ this.#wrapper.removeEventListener("pointerleave", this.#onPointerEnd);
366
+ this.#wrapper.remove();
367
+ }
368
+
369
+ #press(event: PointerEvent): void {
370
+ event.preventDefault();
371
+ event.stopPropagation();
372
+ const wasPressed = this.#pressed;
373
+ this.#pressed = true;
374
+ this.#cap.style.transform = "translate(-50%, -50%) scale(1.3)";
375
+ this.#cap.style.opacity = "0.5";
376
+ // Rising edge (pointerdown cannot re-fire while down, but guard anyway).
377
+ if (!wasPressed) this.#onPress?.();
378
+ }
379
+
380
+ #release(): void {
381
+ if (!this.#pressed) return;
382
+ this.#pressed = false;
383
+ this.#cap.style.transform = "translate(-50%, -50%) scale(1)";
384
+ this.#cap.style.opacity = "1";
385
+ this.#onRelease?.();
386
+ }
387
+ }
@@ -0,0 +1,52 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Auto-fit a Rapier capsule to a loaded avatar's bounds (Genex AG-747). Library
3
+ // avatars vary in height and proportion; deriving the collider from the model —
4
+ // instead of a fixed preset — is what lets many avatars work with no manual
5
+ // tuning. Spread the result into CharacterControllerOptions AND apply
6
+ // `modelOffsetY` to the model, or the avatar hovers above the ground (the
7
+ // single most common wiring bug):
8
+ //
9
+ // const { scene } = await loadVrm("./assets/avatar.vrm");
10
+ // const fit = capsuleFromModel(scene);
11
+ // const character = new CharacterController(world, camera, {
12
+ // ...characterPresets["default"].options, ...fit, position,
13
+ // });
14
+ // character.root.add(scene);
15
+ // scene.position.y = fit.modelOffsetY; // feet on the ground, not at capsule center
16
+ import * as THREE from "three";
17
+
18
+ export interface CapsuleFit {
19
+ /** Cylinder half-height — total capsule height = 2*(halfHeight + radius). */
20
+ capsuleHalfHeight: number;
21
+ capsuleRadius: number;
22
+ /** The model's world-space height in metres (handy for scaling jump velocity). */
23
+ height: number;
24
+ /**
25
+ * Local Y for the model under `character.root`. The root tracks the capsule
26
+ * CENTER, and the controller float-spring keeps the capsule bottom hovering
27
+ * `floatHeight` above the ground — so the model (feet at its origin) must
28
+ * drop by `halfHeight + radius + floatHeight` to stand on the floor.
29
+ */
30
+ modelOffsetY: number;
31
+ }
32
+
33
+ /**
34
+ * Derive a snug upright capsule from the model's world bounding box.
35
+ *
36
+ * Pass `floatHeight` if you override the controller default (0.2) — e.g. the
37
+ * "heavy-body-reference" preset uses 0.3 — so `modelOffsetY` stays correct.
38
+ */
39
+ export function capsuleFromModel(model: THREE.Object3D, floatHeight = 0.2): CapsuleFit {
40
+ model.updateWorldMatrix(true, true);
41
+ const box = new THREE.Box3().setFromObject(model);
42
+ const size = box.getSize(new THREE.Vector3());
43
+
44
+ const height = Math.max(size.y, 0.1);
45
+ const radius = THREE.MathUtils.clamp(Math.min(size.x, size.z) * 0.5, 0.15, 0.35);
46
+ const capsuleHalfHeight = Math.max(height / 2 - radius, 0.05);
47
+ // box.min.y is ~0 for a feet-origin VRM; subtracting it also grounds models
48
+ // whose origin sits elsewhere (e.g. a center-origin placeholder mesh).
49
+ const modelOffsetY = -(capsuleHalfHeight + radius + floatHeight) - box.min.y;
50
+
51
+ return { capsuleHalfHeight, capsuleRadius: radius, height, modelOffsetY };
52
+ }