@genex-ai/cli-demo 0.54.0-dev.123 → 0.55.0-dev.124

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.
@@ -0,0 +1,541 @@
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 wrapper
8
+ // (upstream relied on `pointerleave` alone; that reset path is kept for parity when
9
+ // capture is unavailable), and `pointercancel` resets too (browsers fire it when the
10
+ // OS steals the gesture).
11
+ // - NEW: one active pointer per widget (tracked by pointerId) — a second finger landing
12
+ // on the same widget is ignored instead of re-anchoring the drag.
13
+ // - NEW: safe default positions. When the caller's `wrapperStyle` sets no
14
+ // left/right/top/bottom/inset, the joystick parks bottom-left and the button
15
+ // bottom-right, both offset by `env(safe-area-inset-*)` so nothing hides under a
16
+ // notch or home indicator. Any caller-provided position wins wholesale (the default
17
+ // is skipped entirely, so positioning via `right` never fights a default `left`).
18
+ // - NEW: `floating: true` joystick mode — the wrapper becomes a large touch zone and
19
+ // the stick appears centered under the thumb, with a faint resting hint at the
20
+ // default spot so players can discover it. Static (fixed circle) stays the default.
21
+ // - Restyled defaults (translucent glass instead of upstream's solid white discs,
22
+ // `system-ui` labels with a shadow instead of LightGray-on-white Arial, buttons
23
+ // compress on press instead of inflating). All still overridable per style option.
24
+ // - `VirtualButton.dispose()` resets only ITS OWN state (upstream unmount reset ALL buttons).
25
+ // - Upstream's hard-coded duplicate DOM ids (joystick/base/knob/button/cap) are dropped;
26
+ // instances are the identity now.
27
+ // - The zustand store keys (`id` props) are gone — create one instance per stick/button.
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Shared style plumbing
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function applyStyle(
34
+ element: HTMLElement,
35
+ base: Partial<CSSStyleDeclaration>,
36
+ override?: Partial<CSSStyleDeclaration>
37
+ ): void {
38
+ Object.assign(element.style, base);
39
+ if (override) Object.assign(element.style, override);
40
+ }
41
+
42
+ /** Legacy vendor prefixes upstream shipped via React CSSProperties (Moz/ms). */
43
+ function applyLegacyUserSelectNone(element: HTMLElement): void {
44
+ element.style.setProperty("-moz-user-select", "none");
45
+ element.style.setProperty("-ms-user-select", "none");
46
+ }
47
+
48
+ /** True when the caller's style overrides carry ANY positioning of their own. */
49
+ function hasOwnPosition(style?: Partial<CSSStyleDeclaration>): boolean {
50
+ if (!style) return false;
51
+ return Boolean(style.left || style.right || style.top || style.bottom || style.inset);
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // TouchJoystick
56
+ // ---------------------------------------------------------------------------
57
+
58
+ // Default styles for the joystick wrapper (the 200px interactive hit area).
59
+ // Position is applied separately (safe-area default, unless the caller positions it).
60
+ const DEFAULT_JOYSTICK_WRAPPER_STYLE: Partial<CSSStyleDeclaration> = {
61
+ userSelect: "none",
62
+ webkitUserSelect: "none",
63
+ touchAction: "none",
64
+ overscrollBehavior: "none",
65
+ position: "fixed",
66
+ zIndex: "10",
67
+ height: "200px",
68
+ width: "200px",
69
+ borderRadius: "50%",
70
+ };
71
+
72
+ // Safe default position: bottom-left, clear of the notch/home-indicator insets.
73
+ const DEFAULT_JOYSTICK_POSITION: Partial<CSSStyleDeclaration> = {
74
+ left: "calc(12px + env(safe-area-inset-left))",
75
+ bottom: "calc(12px + env(safe-area-inset-bottom))",
76
+ };
77
+
78
+ // Floating mode turns the wrapper into a large invisible touch zone (bottom-left
79
+ // region of the screen); the visible stick lives on the base, not the wrapper.
80
+ const FLOATING_JOYSTICK_WRAPPER_STYLE: Partial<CSSStyleDeclaration> = {
81
+ left: "0",
82
+ bottom: "0",
83
+ width: "45vw",
84
+ height: "60vh",
85
+ borderRadius: "0",
86
+ // Below buttons/HUD (zIndex 10) so controls layered inside the zone stay tappable.
87
+ zIndex: "5",
88
+ };
89
+
90
+ // Default styles for the joystick base (the 100px reference circle — center math uses THIS).
91
+ const DEFAULT_JOYSTICK_BASE_STYLE: Partial<CSSStyleDeclaration> = {
92
+ width: "100px",
93
+ height: "100px",
94
+ background: "rgba(255, 255, 255, 0.06)",
95
+ border: "1.5px solid rgba(255, 255, 255, 0.4)",
96
+ borderRadius: "50%",
97
+ position: "absolute",
98
+ top: "50%",
99
+ left: "50%",
100
+ transform: "translate(-50%, -50%)",
101
+ touchAction: "none",
102
+ };
103
+
104
+ // Default styles for the joystick knob. The cubic-bezier transition IS the spring-back:
105
+ // the state snaps to 0 instantly on release while the knob overshoots home in CSS.
106
+ const DEFAULT_JOYSTICK_KNOB_STYLE: Partial<CSSStyleDeclaration> = {
107
+ width: "70px",
108
+ height: "70px",
109
+ background: "rgba(255, 255, 255, 0.35)",
110
+ border: "1.5px solid rgba(255, 255, 255, 0.55)",
111
+ borderRadius: "50%",
112
+ position: "absolute",
113
+ top: "50%",
114
+ left: "50%",
115
+ transform: "translate(-50%, -50%)",
116
+ transition: "transform 0.2s cubic-bezier(0.25, 1.5, 0.5, 1)",
117
+ willChange: "transform",
118
+ pointerEvents: "none",
119
+ };
120
+
121
+ /** Resting-hint opacity for the floating stick (fades to 1 while dragging). */
122
+ const FLOATING_REST_OPACITY = "0.35";
123
+
124
+ export interface TouchJoystickOptions {
125
+ /** DOM parent; default `document.body`. */
126
+ parent?: HTMLElement;
127
+ /** Max knob travel in px; default 50. Larger = more finger travel for full deflection. */
128
+ maxRadius?: number;
129
+ /**
130
+ * Floating mode: the wrapper becomes a large invisible touch zone (default: the
131
+ * bottom-left 45vw x 60vh of the screen — reshape via `wrapperStyle`) and the stick
132
+ * appears centered under the thumb, resting as a faint hint at the zone's default
133
+ * spot between touches. Static fixed-circle mode (false) is the default.
134
+ */
135
+ floating?: boolean;
136
+ /**
137
+ * Style overrides merged over the defaults. Without any position here
138
+ * (left/right/top/bottom/inset), the widget parks at its safe-area-aware default
139
+ * (bottom-left); set your own to move it — your position replaces the default
140
+ * entirely. In floating mode this styles the touch ZONE.
141
+ */
142
+ wrapperStyle?: Partial<CSSStyleDeclaration>;
143
+ baseStyle?: Partial<CSSStyleDeclaration>;
144
+ knobStyle?: Partial<CSSStyleDeclaration>;
145
+ /** Optional change hook: fires on every move/reset with the new state. */
146
+ onChange?: (x: number, y: number, active: boolean) => void;
147
+ }
148
+
149
+ /**
150
+ * DOM+CSS touch joystick. Read `x`/`y` each render frame and pass them into the character
151
+ * controller: `setMovement({ ...kb.getCharacterMovement(), joystick: { x: joy.x, y: joy.y } })`.
152
+ * A nonzero joystick overrides the digital keys inside the controller — pass both through and
153
+ * let it pick. Note the controller normalizes direction, so deflection magnitude is not speed.
154
+ */
155
+ export class TouchJoystick {
156
+ #maxRadius: number;
157
+ #floating: boolean;
158
+ #wrapper: HTMLDivElement;
159
+ #base: HTMLDivElement;
160
+ #knob: HTMLDivElement;
161
+ #x = 0;
162
+ #y = 0;
163
+ #active = false;
164
+ /** Live drag's pointerId — a second finger on the widget is ignored. */
165
+ #pointerId: number | null = null;
166
+ #onChange: ((x: number, y: number, active: boolean) => void) | undefined;
167
+ #resizeObserver: ResizeObserver | null = null;
168
+ #disposed = false;
169
+
170
+ #onContextMenu = (event: Event): void => {
171
+ event.preventDefault();
172
+ };
173
+
174
+ #onPointerDown = (event: PointerEvent): void => {
175
+ if (this.#pointerId !== null) return; // one pointer drives; ignore extra fingers
176
+ event.preventDefault();
177
+ event.stopPropagation();
178
+ this.#pointerId = event.pointerId;
179
+ if (this.#floating) this.#anchorStick(event.clientX, event.clientY);
180
+ this.#move(event.clientX, event.clientY);
181
+ // NEW vs upstream: capture keeps the drag working outside the wrapper.
182
+ if (typeof this.#wrapper.setPointerCapture === "function") {
183
+ try {
184
+ this.#wrapper.setPointerCapture(event.pointerId);
185
+ } catch {
186
+ // pointer already gone — the pointerleave reset path still covers us
187
+ }
188
+ }
189
+ };
190
+
191
+ #onPointerMove = (event: PointerEvent): void => {
192
+ if (event.pointerId === this.#pointerId) this.#move(event.clientX, event.clientY);
193
+ };
194
+
195
+ #onPointerEnd = (event: PointerEvent): void => {
196
+ if (event.pointerId === this.#pointerId) this.#reset();
197
+ };
198
+
199
+ #onResize = (): void => {
200
+ // Keep the floating resting hint anchored to the zone whenever the zone's size
201
+ // changes (rotation, browser chrome show/hide, a zero-sized boot in a hidden
202
+ // tab becoming visible) while nobody is touching it.
203
+ if (this.#floating && this.#pointerId === null) this.#parkStick(false);
204
+ };
205
+
206
+ constructor(options: TouchJoystickOptions = {}) {
207
+ this.#maxRadius = options.maxRadius ?? 50;
208
+ this.#floating = options.floating ?? false;
209
+ this.#onChange = options.onChange;
210
+
211
+ this.#wrapper = document.createElement("div");
212
+ applyStyle(this.#wrapper, DEFAULT_JOYSTICK_WRAPPER_STYLE);
213
+ if (this.#floating) applyStyle(this.#wrapper, FLOATING_JOYSTICK_WRAPPER_STYLE);
214
+ else if (!hasOwnPosition(options.wrapperStyle)) {
215
+ applyStyle(this.#wrapper, DEFAULT_JOYSTICK_POSITION);
216
+ }
217
+ if (options.wrapperStyle) applyStyle(this.#wrapper, options.wrapperStyle);
218
+ applyLegacyUserSelectNone(this.#wrapper);
219
+
220
+ this.#base = document.createElement("div");
221
+ applyStyle(this.#base, DEFAULT_JOYSTICK_BASE_STYLE, options.baseStyle);
222
+ this.#wrapper.appendChild(this.#base);
223
+
224
+ this.#knob = document.createElement("div");
225
+ applyStyle(this.#knob, DEFAULT_JOYSTICK_KNOB_STYLE, options.knobStyle);
226
+ this.#base.appendChild(this.#knob);
227
+
228
+ this.#wrapper.addEventListener("contextmenu", this.#onContextMenu);
229
+ this.#wrapper.addEventListener("pointerdown", this.#onPointerDown);
230
+ this.#wrapper.addEventListener("pointermove", this.#onPointerMove);
231
+ this.#wrapper.addEventListener("pointerup", this.#onPointerEnd);
232
+ this.#wrapper.addEventListener("pointerleave", this.#onPointerEnd);
233
+ this.#wrapper.addEventListener("pointercancel", this.#onPointerEnd);
234
+
235
+ (options.parent ?? document.body).appendChild(this.#wrapper);
236
+
237
+ if (this.#floating) {
238
+ this.#parkStick(false);
239
+ // ResizeObserver tracks the zone element itself (fires on first observe too —
240
+ // self-healing when the game boots in a hidden/zero-sized tab); plain window
241
+ // resize is the fallback for engines without it.
242
+ if (typeof ResizeObserver !== "undefined") {
243
+ this.#resizeObserver = new ResizeObserver(this.#onResize);
244
+ this.#resizeObserver.observe(this.#wrapper);
245
+ } else {
246
+ window.addEventListener("resize", this.#onResize);
247
+ }
248
+ }
249
+ }
250
+
251
+ /** Normalized horizontal deflection in [-1, 1] (right-positive). */
252
+ get x(): number {
253
+ return this.#x;
254
+ }
255
+
256
+ /** Normalized vertical deflection in [-1, 1], UP-positive (screen dy is negated once here). */
257
+ get y(): number {
258
+ return this.#y;
259
+ }
260
+
261
+ /** True iff (x, y) !== (0, 0). */
262
+ get active(): boolean {
263
+ return this.#active;
264
+ }
265
+
266
+ /** The wrapper element (for ad-hoc styling / conditional display). */
267
+ get element(): HTMLDivElement {
268
+ return this.#wrapper;
269
+ }
270
+
271
+ /** Show/hide helper — e.g. show only when `navigator.maxTouchPoints > 0`. */
272
+ setVisible(visible: boolean): void {
273
+ this.#wrapper.style.display = visible ? "" : "none";
274
+ }
275
+
276
+ /** Reset state + remove DOM + listeners. Safe to call mid-drag. */
277
+ dispose(): void {
278
+ if (this.#disposed) return;
279
+ this.#disposed = true;
280
+ this.#reset();
281
+ this.#wrapper.removeEventListener("contextmenu", this.#onContextMenu);
282
+ this.#wrapper.removeEventListener("pointerdown", this.#onPointerDown);
283
+ this.#wrapper.removeEventListener("pointermove", this.#onPointerMove);
284
+ this.#wrapper.removeEventListener("pointerup", this.#onPointerEnd);
285
+ this.#wrapper.removeEventListener("pointerleave", this.#onPointerEnd);
286
+ this.#wrapper.removeEventListener("pointercancel", this.#onPointerEnd);
287
+ this.#resizeObserver?.disconnect();
288
+ if (this.#floating) window.removeEventListener("resize", this.#onResize);
289
+ this.#wrapper.remove();
290
+ }
291
+
292
+ /** Floating: snap the stick under the thumb (clamped so the base stays in the zone). */
293
+ #anchorStick(clientX: number, clientY: number): void {
294
+ const rect = this.#wrapper.getBoundingClientRect();
295
+ const half = this.#base.offsetWidth / 2 || 50;
296
+ const localX = Math.min(Math.max(clientX - rect.left, half), Math.max(rect.width - half, half));
297
+ const localY = Math.min(Math.max(clientY - rect.top, half), Math.max(rect.height - half, half));
298
+ this.#base.style.transition = "opacity 0.1s ease";
299
+ this.#base.style.top = `${localY}px`;
300
+ this.#base.style.left = `${localX}px`;
301
+ this.#base.style.opacity = "1";
302
+ }
303
+
304
+ /** Floating: return the stick to its resting-hint spot (bottom-left of the zone). */
305
+ #parkStick(animate: boolean): void {
306
+ const rect = this.#wrapper.getBoundingClientRect();
307
+ const half = this.#base.offsetWidth / 2 || 50;
308
+ const restX = Math.min(half + 12, rect.width / 2 || half);
309
+ const restY = Math.max((rect.height || half * 2) - half - 12, half);
310
+ this.#base.style.transition = animate
311
+ ? "top 0.3s ease, left 0.3s ease, opacity 0.3s ease"
312
+ : "none";
313
+ this.#base.style.top = `${restY}px`;
314
+ this.#base.style.left = `${restX}px`;
315
+ this.#base.style.opacity = FLOATING_REST_OPACITY;
316
+ }
317
+
318
+ #move(clientX: number, clientY: number): void {
319
+ // Center math uses the BASE rect (100px reference circle), not the wrapper.
320
+ const rect = this.#base.getBoundingClientRect();
321
+ const centerX = rect.left + rect.width / 2;
322
+ const centerY = rect.top + rect.height / 2;
323
+ let dx = clientX - centerX;
324
+ let dy = clientY - centerY;
325
+ const distance = Math.hypot(dx, dy);
326
+ // If the distance exceeds the maximum radius, scale down the movement.
327
+ if (distance > this.#maxRadius) {
328
+ dx *= this.#maxRadius / distance;
329
+ dy *= this.#maxRadius / distance;
330
+ }
331
+
332
+ // The -50% self-centering and the pixel offset ride in ONE translate — replacing this
333
+ // with left/top would break the CSS spring-back.
334
+ this.#knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`;
335
+
336
+ this.#x = dx / this.#maxRadius;
337
+ this.#y = -dy / this.#maxRadius; // screen-down-positive dy → game-forward-positive y
338
+ this.#active = !(this.#x === 0 && this.#y === 0);
339
+ this.#onChange?.(this.#x, this.#y, this.#active);
340
+ }
341
+
342
+ #reset(): void {
343
+ this.#pointerId = null;
344
+ // State snaps to 0 immediately; the knob eases back via the CSS transition.
345
+ this.#knob.style.transform = "translate(-50%, -50%)";
346
+ if (this.#floating) this.#parkStick(true);
347
+ this.#x = 0;
348
+ this.#y = 0;
349
+ this.#active = false;
350
+ this.#onChange?.(0, 0, false);
351
+ }
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // VirtualButton
356
+ // ---------------------------------------------------------------------------
357
+
358
+ // Default style for the virtual button wrapper (the 60px hit area — thumb-sized).
359
+ const DEFAULT_BUTTON_WRAPPER_STYLE: Partial<CSSStyleDeclaration> = {
360
+ userSelect: "none",
361
+ webkitUserSelect: "none",
362
+ touchAction: "none",
363
+ overscrollBehavior: "none",
364
+ position: "fixed",
365
+ zIndex: "10",
366
+ height: "60px",
367
+ width: "60px",
368
+ background: "rgba(255, 255, 255, 0.05)",
369
+ borderRadius: "50%",
370
+ };
371
+
372
+ // Safe default position: bottom-right, clear of the safe-area insets. Position
373
+ // every button after the FIRST yourself — two buttons on the default overlap.
374
+ const DEFAULT_BUTTON_POSITION: Partial<CSSStyleDeclaration> = {
375
+ right: "calc(24px + env(safe-area-inset-right))",
376
+ bottom: "calc(48px + env(safe-area-inset-bottom))",
377
+ };
378
+
379
+ // Default style for the virtual button cap (the 45px visible disc with the label).
380
+ const DEFAULT_BUTTON_CAP_STYLE: Partial<CSSStyleDeclaration> = {
381
+ width: "45px",
382
+ height: "45px",
383
+ background: "rgba(255, 255, 255, 0.22)",
384
+ border: "1.5px solid rgba(255, 255, 255, 0.5)",
385
+ borderRadius: "50%",
386
+ position: "absolute",
387
+ top: "50%",
388
+ left: "50%",
389
+ transform: "translate(-50%, -50%)",
390
+ transition: "transform 0.15s ease, background 0.15s ease",
391
+ willChange: "transform",
392
+ display: "flex",
393
+ justifyContent: "center",
394
+ alignItems: "center",
395
+ fontSize: "12px",
396
+ fontWeight: "600",
397
+ fontFamily: "system-ui, sans-serif",
398
+ color: "rgba(255, 255, 255, 0.95)",
399
+ textShadow: "0 1px 2px rgba(0, 0, 0, 0.5)",
400
+ userSelect: "none",
401
+ pointerEvents: "none",
402
+ };
403
+
404
+ /** Pressed-state cap fill (compress + brighten; the resting fill is restored on release). */
405
+ const BUTTON_PRESSED_BACKGROUND = "rgba(255, 255, 255, 0.45)";
406
+
407
+ export interface VirtualButtonOptions {
408
+ /** Text rendered on the cap, e.g. "Jump" (plain text — set via textContent). */
409
+ label?: string;
410
+ /** DOM parent; default `document.body`. */
411
+ parent?: HTMLElement;
412
+ /**
413
+ * Style overrides; without any position here the button parks at its safe-area-aware
414
+ * default (bottom-right) — position every button after the first yourself
415
+ * (e.g. `{ right: "100px", bottom: "48px" }`), or they stack on the same spot.
416
+ */
417
+ wrapperStyle?: Partial<CSSStyleDeclaration>;
418
+ capStyle?: Partial<CSSStyleDeclaration>;
419
+ /** Rising-edge press hook (e.g. an on-screen Enter/Exit button → `mgr.requestInteract()`). */
420
+ onPress?: () => void;
421
+ onRelease?: () => void;
422
+ }
423
+
424
+ /**
425
+ * DOM+CSS virtual button for touch controls. Read `pressed` each frame (e.g.
426
+ * `jump: kb.space || btnJump.pressed`) or use the `onPress`/`onRelease` edge callbacks.
427
+ */
428
+ export class VirtualButton {
429
+ #wrapper: HTMLDivElement;
430
+ #cap: HTMLDivElement;
431
+ /** The cap's resting fill (default or caller override) — restored on release. */
432
+ #capRestBackground: string;
433
+ #pressed = false;
434
+ /** Live press's pointerId — a second finger on the button is ignored. */
435
+ #pointerId: number | null = null;
436
+ #onPress: (() => void) | undefined;
437
+ #onRelease: (() => void) | undefined;
438
+ #disposed = false;
439
+
440
+ #onContextMenu = (event: Event): void => {
441
+ event.preventDefault();
442
+ };
443
+
444
+ #onPointerDown = (event: PointerEvent): void => {
445
+ this.#press(event);
446
+ };
447
+
448
+ // pointerId-filtered like the joystick: with capture active, boundary events are
449
+ // suppressed mid-press, so a thumb drifting off the 60px hit area keeps holding
450
+ // (hold-to-jump / hold-to-brake); pointerleave stays only as the release path for
451
+ // engines where capture is unavailable — degraded there, but never a stuck button.
452
+ #onPointerEnd = (event: PointerEvent): void => {
453
+ if (event.pointerId === this.#pointerId) this.#release();
454
+ };
455
+
456
+ constructor(options: VirtualButtonOptions = {}) {
457
+ this.#onPress = options.onPress;
458
+ this.#onRelease = options.onRelease;
459
+
460
+ this.#wrapper = document.createElement("div");
461
+ applyStyle(this.#wrapper, DEFAULT_BUTTON_WRAPPER_STYLE);
462
+ if (!hasOwnPosition(options.wrapperStyle)) {
463
+ applyStyle(this.#wrapper, DEFAULT_BUTTON_POSITION);
464
+ }
465
+ if (options.wrapperStyle) applyStyle(this.#wrapper, options.wrapperStyle);
466
+ applyLegacyUserSelectNone(this.#wrapper);
467
+
468
+ this.#cap = document.createElement("div");
469
+ applyStyle(this.#cap, DEFAULT_BUTTON_CAP_STYLE, options.capStyle);
470
+ this.#cap.textContent = options.label ?? "";
471
+ this.#capRestBackground = this.#cap.style.background;
472
+ this.#wrapper.appendChild(this.#cap);
473
+
474
+ this.#wrapper.addEventListener("contextmenu", this.#onContextMenu);
475
+ this.#wrapper.addEventListener("pointerdown", this.#onPointerDown);
476
+ this.#wrapper.addEventListener("pointerup", this.#onPointerEnd);
477
+ this.#wrapper.addEventListener("pointerleave", this.#onPointerEnd);
478
+ this.#wrapper.addEventListener("pointercancel", this.#onPointerEnd);
479
+
480
+ (options.parent ?? document.body).appendChild(this.#wrapper);
481
+ }
482
+
483
+ /** True while the button is held. */
484
+ get pressed(): boolean {
485
+ return this.#pressed;
486
+ }
487
+
488
+ /** The wrapper element (for ad-hoc styling / conditional display). */
489
+ get element(): HTMLDivElement {
490
+ return this.#wrapper;
491
+ }
492
+
493
+ /** Show/hide helper. */
494
+ setVisible(visible: boolean): void {
495
+ this.#wrapper.style.display = visible ? "" : "none";
496
+ }
497
+
498
+ /** Release (if held) + remove DOM + listeners. Resets only this button's own state. */
499
+ dispose(): void {
500
+ if (this.#disposed) return;
501
+ this.#disposed = true;
502
+ this.#release();
503
+ this.#wrapper.removeEventListener("contextmenu", this.#onContextMenu);
504
+ this.#wrapper.removeEventListener("pointerdown", this.#onPointerDown);
505
+ this.#wrapper.removeEventListener("pointerup", this.#onPointerEnd);
506
+ this.#wrapper.removeEventListener("pointerleave", this.#onPointerEnd);
507
+ this.#wrapper.removeEventListener("pointercancel", this.#onPointerEnd);
508
+ this.#wrapper.remove();
509
+ }
510
+
511
+ #press(event: PointerEvent): void {
512
+ if (this.#pointerId !== null) return; // one pointer drives; ignore extra fingers
513
+ event.preventDefault();
514
+ event.stopPropagation();
515
+ this.#pointerId = event.pointerId;
516
+ const wasPressed = this.#pressed;
517
+ this.#pressed = true;
518
+ // Compress + brighten (physical buttons press IN — they don't inflate).
519
+ this.#cap.style.transform = "translate(-50%, -50%) scale(0.92)";
520
+ this.#cap.style.background = BUTTON_PRESSED_BACKGROUND;
521
+ // Capture keeps the hold alive while the thumb drifts outside the hit area.
522
+ if (typeof this.#wrapper.setPointerCapture === "function") {
523
+ try {
524
+ this.#wrapper.setPointerCapture(event.pointerId);
525
+ } catch {
526
+ // pointer already gone — the pointerleave reset path still covers us
527
+ }
528
+ }
529
+ // Rising edge (pointerdown cannot re-fire while down, but guard anyway).
530
+ if (!wasPressed) this.#onPress?.();
531
+ }
532
+
533
+ #release(): void {
534
+ if (!this.#pressed) return;
535
+ this.#pointerId = null;
536
+ this.#pressed = false;
537
+ this.#cap.style.transform = "translate(-50%, -50%) scale(1)";
538
+ this.#cap.style.background = this.#capRestBackground;
539
+ this.#onRelease?.();
540
+ }
541
+ }
@@ -58,7 +58,7 @@ fork as a migration strategy. Install a fresh copy elsewhere and port only the n
58
58
  | `character/presets.ts` | `characterPresets` | six named tunings |
59
59
  | `character/follow-camera.ts` | `FollowCamera` | orbit/zoom chase camera with collision pullback and an opt-in pointer-lock aim mode |
60
60
  | `character/keyboard-input.ts` | `KeyboardInput` | WASD/arrows/Shift/Space/F state, no per-frame polling setup |
61
- | `character/touch-joystick.ts` | `TouchJoystick`, `VirtualButton` | mobile controls |
61
+ | `touch/*` | `TouchJoystick`, `VirtualButton`, `DragZone`, `RotateOverlay` | the shared touch kit — `$genex-threejs-touch-controls` owns the genre recipes |
62
62
  | `character/character-animations.ts` | `CharacterAnimations` | animation state machine, directional profiles, speed-matched cadence, `playOneShot`, procedural fallback |
63
63
  | `character/animation-packs.ts` | `loadCharacterClips` | loads the core + installed UAL packs and retargets them to the active VRM |
64
64
  | `character/meshy/meshy-loader.ts` | `loadMeshyCharacter` | loads a Meshy manifest, exact-signature model/clips, locomotion slots, and fallbacks |
@@ -213,13 +213,13 @@ controls are ~6 lines and invisible on desktop. (Designing phone-specific
213
213
  layouts or testing mobile viewports stays ask-only.)
214
214
 
215
215
  ```ts
216
- import { TouchJoystick, VirtualButton } from "./controllers/character/touch-joystick.ts";
216
+ import { TouchJoystick, VirtualButton } from "./controllers/touch/touch-joystick.ts";
217
217
 
218
- const joy = new TouchJoystick({ wrapperStyle: { left: "20px", bottom: "20px" } }); // position is REQUIRED
219
- const btnJump = new VirtualButton({ label: "Jump", wrapperStyle: { right: "30px", bottom: "30px" } });
218
+ const joy = new TouchJoystick({ floating: true }); // stick appears under the thumb; omit for a fixed bottom-left circle
219
+ const btnJump = new VirtualButton({ label: "Jump" }); // first button parks bottom-right by default
220
220
  const btnCrouch = new VirtualButton({
221
221
  label: "Crouch",
222
- wrapperStyle: { right: "100px", bottom: "30px" },
222
+ wrapperStyle: { right: "100px", bottom: "48px" }, // position every button after the first
223
223
  onPress: () => character.setCrouch(!character.crouchActive), // tap = toggle
224
224
  });
225
225
 
@@ -236,7 +236,10 @@ physics.onBeforeStep(() => {
236
236
  Show them only on touch devices: `joy.setVisible(navigator.maxTouchPoints > 0)`.
237
237
  Give the canvas `touch-action: none` so camera drags aren't hijacked by page
238
238
  scrolling. Joystick deflection sets direction only — the controller normalizes
239
- it, so half-deflection is not half-speed.
239
+ it, so half-deflection is not half-speed. Default positions are safe-area-aware
240
+ (bottom-left stick, bottom-right button) — restyle or reposition via the style
241
+ options. The full genre recipes, the drag-zone and rotate-overlay primitives,
242
+ and the style-matching rules live in `$genex-threejs-touch-controls`.
240
243
 
241
244
  ## Multiplayer rule (mandatory)
242
245
 
@@ -356,10 +356,11 @@ Order the HUD by what the player loses the game for ignoring:
356
356
  of a web page floating over a game. Keep it `pointer-events: none`.
357
357
  - **Desktop first.** Verify at desktop sizes and survive window resizes
358
358
  without clipping; don't design phone layouts or test mobile viewports unless
359
- the user asks. Exception: when a bundled controller (character/car/drone) is
360
- installed, DO wire its ready-made touch controls behind
361
- `navigator.maxTouchPoints > 0` — invisible on desktop, and the shared link
362
- isn't dead on a phone (the controller skill has the wiring).
359
+ the user asks. Exception: touch *input* is wired by default when a recipe
360
+ fits a bundled controller's built-in touch controls, or the touch kit +
361
+ recipes in `$genex-threejs-touch-controls` — behind
362
+ `navigator.maxTouchPoints > 0`, invisible on desktop, so the shared link
363
+ isn't dead on a phone (skipping needs a one-line reason, not silence).
363
364
 
364
365
  ## Wire UI to game state, never the reverse
365
366
 
@@ -19,6 +19,7 @@ map, execution order, and acceptance gate.
19
19
  | on-foot player movement: walk/run/jump/crouch, third-person character, slopes, stairs, moving platforms, personal VRM animation, directional locomotion, transitions, action motion | `$genex-threejs-character-controller` |
20
20
  | a custom generated playable humanoid or Meshy animation coverage beyond UAL: search, generate, rig, add exact action IDs, install the same-rig adapter | `$genex-ai-character` + `$genex-threejs-character-controller` |
21
21
  | the player drives or flies something: cars, drones, vehicle physics, gearbox, enter/exit between character and vehicle | `$genex-threejs-vehicle-controllers` |
22
+ | playable on phones: touch/mobile input for any game — joystick, virtual buttons, drag zones, per-genre touch recipes, rotate-device overlay — wired by default for every NEW game when a recipe fits (skip with a one-line reason) | `$genex-threejs-touch-controls` |
22
23
  | anything falls, collides, gets pushed, or needs physics: Rapier world setup, colliders for meshes and GLBs, collision events | `$genex-threejs-physics-rapier` |
23
24
  | launch and docking timelines, procedural transform phases, springs, staging, rotating-frame alignment, debris motion | `$genex-threejs-procedural-animation` |
24
25
  | reusable scalar/vector fields, domain warping, causal masks, procedural normals | `$genex-threejs-procedural-fields` |