@genex-ai/cli-demo 0.54.0-dev.122 → 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,186 @@
1
+ // Invisible one-finger drag region for touch input — the camera-look / paddle-drag
2
+ // primitive of the Genex touch kit (companion to TouchJoystick/VirtualButton).
3
+ //
4
+ // Two read models, pick per genre:
5
+ // - Relative (camera look, steering): call `consumeDelta()` once per frame and apply
6
+ // the returned pixel deltas to yaw/pitch — it drains the accumulator, so frame rate
7
+ // doesn't change sensitivity.
8
+ // - Absolute (paddle, pong, slider): read `x`/`y` — the pointer's normalized position
9
+ // inside the zone (0..1 from the zone's left/top edge) — while `active` is true.
10
+ //
11
+ // Design notes:
12
+ // - Touch/pen pointers only by default: the zone typically covers half the screen, and
13
+ // capturing the desktop mouse there would fight mouse-driven gameplay on
14
+ // touch-screen laptops. Override via `pointerTypes` if a game wants the mouse too.
15
+ // - One pointer drives (tracked by pointerId); extra fingers are ignored, so a
16
+ // joystick drag in the other hand never disturbs the look drag.
17
+ // - The zone sits at zIndex 5 — below the kit's joystick/buttons (10), so widgets
18
+ // layered inside the region stay tappable. It swallows the touches it receives:
19
+ // taps inside the zone do NOT reach the canvas underneath.
20
+
21
+ const DEFAULT_ZONE_STYLE: Partial<CSSStyleDeclaration> = {
22
+ userSelect: "none",
23
+ webkitUserSelect: "none",
24
+ touchAction: "none",
25
+ overscrollBehavior: "none",
26
+ position: "fixed",
27
+ zIndex: "5",
28
+ right: "0",
29
+ top: "0",
30
+ width: "50vw",
31
+ height: "100vh",
32
+ background: "transparent",
33
+ };
34
+
35
+ export interface DragZoneOptions {
36
+ /** DOM parent; default `document.body`. */
37
+ parent?: HTMLElement;
38
+ /**
39
+ * Style overrides merged over the default zone (the right half of the screen).
40
+ * Reshape via left/right/top/bottom/width/height — e.g. a bottom strip for a
41
+ * paddle: `{ right: "0", left: "0", top: "auto", bottom: "0", width: "100vw", height: "30vh" }`.
42
+ */
43
+ zoneStyle?: Partial<CSSStyleDeclaration>;
44
+ /** Pointer types that activate the zone; default `["touch", "pen"]` (never the mouse). */
45
+ pointerTypes?: readonly string[];
46
+ /** Optional hook: fires when a drag starts (true) or ends (false). */
47
+ onChange?: (active: boolean) => void;
48
+ }
49
+
50
+ /**
51
+ * Invisible fixed-position drag surface. Create it behind a touch check and read it
52
+ * each frame:
53
+ *
54
+ * const look = new DragZone(); // right half of the screen
55
+ * look.setVisible(navigator.maxTouchPoints > 0);
56
+ * // per frame:
57
+ * const { dx, dy } = look.consumeDelta();
58
+ * yaw -= dx * 0.005; pitch -= dy * 0.005;
59
+ */
60
+ export class DragZone {
61
+ #zone: HTMLDivElement;
62
+ #pointerTypes: readonly string[];
63
+ #pointerId: number | null = null;
64
+ #lastX = 0;
65
+ #lastY = 0;
66
+ #dx = 0;
67
+ #dy = 0;
68
+ #x = 0;
69
+ #y = 0;
70
+ #onChange: ((active: boolean) => void) | undefined;
71
+ #disposed = false;
72
+
73
+ #onContextMenu = (event: Event): void => {
74
+ event.preventDefault();
75
+ };
76
+
77
+ #onPointerDown = (event: PointerEvent): void => {
78
+ if (this.#pointerId !== null) return; // one pointer drives; ignore extra fingers
79
+ if (!this.#pointerTypes.includes(event.pointerType)) return;
80
+ event.preventDefault();
81
+ this.#pointerId = event.pointerId;
82
+ this.#lastX = event.clientX;
83
+ this.#lastY = event.clientY;
84
+ this.#updatePosition(event.clientX, event.clientY);
85
+ if (typeof this.#zone.setPointerCapture === "function") {
86
+ try {
87
+ this.#zone.setPointerCapture(event.pointerId);
88
+ } catch {
89
+ // pointer already gone — the pointerleave reset path still covers us
90
+ }
91
+ }
92
+ this.#onChange?.(true);
93
+ };
94
+
95
+ #onPointerMove = (event: PointerEvent): void => {
96
+ if (event.pointerId !== this.#pointerId) return;
97
+ this.#dx += event.clientX - this.#lastX;
98
+ this.#dy += event.clientY - this.#lastY;
99
+ this.#lastX = event.clientX;
100
+ this.#lastY = event.clientY;
101
+ this.#updatePosition(event.clientX, event.clientY);
102
+ };
103
+
104
+ #onPointerEnd = (event: PointerEvent): void => {
105
+ if (event.pointerId !== this.#pointerId) return;
106
+ this.#pointerId = null;
107
+ this.#onChange?.(false);
108
+ };
109
+
110
+ constructor(options: DragZoneOptions = {}) {
111
+ this.#pointerTypes = options.pointerTypes ?? ["touch", "pen"];
112
+ this.#onChange = options.onChange;
113
+
114
+ this.#zone = document.createElement("div");
115
+ Object.assign(this.#zone.style, DEFAULT_ZONE_STYLE);
116
+ if (options.zoneStyle) Object.assign(this.#zone.style, options.zoneStyle);
117
+ this.#zone.style.setProperty("-moz-user-select", "none");
118
+ this.#zone.style.setProperty("-ms-user-select", "none");
119
+
120
+ this.#zone.addEventListener("contextmenu", this.#onContextMenu);
121
+ this.#zone.addEventListener("pointerdown", this.#onPointerDown);
122
+ this.#zone.addEventListener("pointermove", this.#onPointerMove);
123
+ this.#zone.addEventListener("pointerup", this.#onPointerEnd);
124
+ this.#zone.addEventListener("pointerleave", this.#onPointerEnd);
125
+ this.#zone.addEventListener("pointercancel", this.#onPointerEnd);
126
+
127
+ (options.parent ?? document.body).appendChild(this.#zone);
128
+ }
129
+
130
+ /** True while a finger is down on the zone. */
131
+ get active(): boolean {
132
+ return this.#pointerId !== null;
133
+ }
134
+
135
+ /** Normalized pointer position across the zone width, 0 (left edge) .. 1 (right edge). */
136
+ get x(): number {
137
+ return this.#x;
138
+ }
139
+
140
+ /** Normalized pointer position down the zone height, 0 (top edge) .. 1 (bottom edge). */
141
+ get y(): number {
142
+ return this.#y;
143
+ }
144
+
145
+ /** The zone element (for ad-hoc styling / conditional display). */
146
+ get element(): HTMLDivElement {
147
+ return this.#zone;
148
+ }
149
+
150
+ /**
151
+ * Drain the pixel deltas accumulated since the last call. Call exactly once per
152
+ * frame; unread motion is never lost, and reading twice a frame halves nothing —
153
+ * the second call just returns zeros.
154
+ */
155
+ consumeDelta(): { dx: number; dy: number } {
156
+ const delta = { dx: this.#dx, dy: this.#dy };
157
+ this.#dx = 0;
158
+ this.#dy = 0;
159
+ return delta;
160
+ }
161
+
162
+ /** Show/hide helper — e.g. show only when `navigator.maxTouchPoints > 0`. */
163
+ setVisible(visible: boolean): void {
164
+ this.#zone.style.display = visible ? "" : "none";
165
+ }
166
+
167
+ /** Remove DOM + listeners. Safe to call mid-drag. */
168
+ dispose(): void {
169
+ if (this.#disposed) return;
170
+ this.#disposed = true;
171
+ this.#pointerId = null;
172
+ this.#zone.removeEventListener("contextmenu", this.#onContextMenu);
173
+ this.#zone.removeEventListener("pointerdown", this.#onPointerDown);
174
+ this.#zone.removeEventListener("pointermove", this.#onPointerMove);
175
+ this.#zone.removeEventListener("pointerup", this.#onPointerEnd);
176
+ this.#zone.removeEventListener("pointerleave", this.#onPointerEnd);
177
+ this.#zone.removeEventListener("pointercancel", this.#onPointerEnd);
178
+ this.#zone.remove();
179
+ }
180
+
181
+ #updatePosition(clientX: number, clientY: number): void {
182
+ const rect = this.#zone.getBoundingClientRect();
183
+ this.#x = rect.width > 0 ? Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1) : 0;
184
+ this.#y = rect.height > 0 ? Math.min(Math.max((clientY - rect.top) / rect.height, 0), 1) : 0;
185
+ }
186
+ }
@@ -0,0 +1,161 @@
1
+ // "Rotate your phone" overlay — the orientation component of the Genex touch kit.
2
+ //
3
+ // The web cannot force an orientation (`screen.orientation.lock()` needs fullscreen
4
+ // and is absent on iOS Safari), so a game declares its one natural orientation and
5
+ // this overlay asks the player to rotate ONLY while the device is held the wrong way.
6
+ // Use it when the wrong orientation genuinely breaks the game (a landscape racer in
7
+ // a portrait sliver); if the game is merely suboptimal sideways, skip the overlay
8
+ // and just resize.
9
+ //
10
+ // Safe by construction:
11
+ // - Shows only on touch devices (`navigator.maxTouchPoints > 0`) whose smaller
12
+ // viewport side is phone/tablet sized — a touch-screen laptop is never told to
13
+ // rotate itself.
14
+ // - Sits above everything (zIndex 9999), swallows input while visible, and hides the
15
+ // instant the orientation matches. `onChange` lets the game pause under it.
16
+
17
+ const OVERLAY_STYLE: Partial<CSSStyleDeclaration> = {
18
+ position: "fixed",
19
+ inset: "0",
20
+ zIndex: "9999",
21
+ display: "none",
22
+ flexDirection: "column",
23
+ alignItems: "center",
24
+ justifyContent: "center",
25
+ gap: "20px",
26
+ background: "rgba(6, 9, 18, 0.94)",
27
+ color: "rgba(255, 255, 255, 0.92)",
28
+ fontFamily: "system-ui, sans-serif",
29
+ fontSize: "15px",
30
+ fontWeight: "500",
31
+ textAlign: "center",
32
+ userSelect: "none",
33
+ touchAction: "none",
34
+ };
35
+
36
+ const PHONE_GLYPH_STYLE: Partial<CSSStyleDeclaration> = {
37
+ width: "30px",
38
+ height: "52px",
39
+ border: "2.5px solid rgba(255, 255, 255, 0.85)",
40
+ borderRadius: "7px",
41
+ boxSizing: "border-box",
42
+ };
43
+
44
+ /** Devices whose smaller viewport side exceeds this are assumed unrotatable (laptops). */
45
+ const MAX_ROTATABLE_MIN_VIEWPORT = 920;
46
+
47
+ export interface RotateOverlayOptions {
48
+ /** The game's one natural orientation — the overlay shows while the device is in the OTHER one. */
49
+ orientation: "landscape" | "portrait";
50
+ /** Overlay text; default "Rotate your phone". */
51
+ message?: string;
52
+ /** DOM parent; default `document.body`. */
53
+ parent?: HTMLElement;
54
+ /** Fires when the overlay appears (true) / disappears (false) — e.g. pause the game. */
55
+ onChange?: (blocking: boolean) => void;
56
+ }
57
+
58
+ /**
59
+ * Self-managing rotate-device overlay. Construct it once at boot and forget it:
60
+ *
61
+ * const rotate = new RotateOverlay({ orientation: "landscape" });
62
+ * // optional: pause while it blocks
63
+ * // new RotateOverlay({ orientation: "landscape", onChange: (b) => (physics.paused = b) });
64
+ */
65
+ export class RotateOverlay {
66
+ #root: HTMLDivElement;
67
+ #natural: "landscape" | "portrait";
68
+ #blocking = false;
69
+ #onChange: ((blocking: boolean) => void) | undefined;
70
+ #portraitQuery: MediaQueryList | null = null;
71
+ #glyphAnimation: Animation | null = null;
72
+ #disposed = false;
73
+
74
+ #onOrientationChange = (): void => {
75
+ this.#evaluate();
76
+ };
77
+
78
+ constructor(options: RotateOverlayOptions) {
79
+ this.#natural = options.orientation;
80
+ this.#onChange = options.onChange;
81
+
82
+ this.#root = document.createElement("div");
83
+ Object.assign(this.#root.style, OVERLAY_STYLE);
84
+
85
+ const glyph = document.createElement("div");
86
+ Object.assign(glyph.style, PHONE_GLYPH_STYLE);
87
+ this.#root.appendChild(glyph);
88
+
89
+ const label = document.createElement("div");
90
+ label.textContent = options.message ?? "Rotate your phone";
91
+ this.#root.appendChild(label);
92
+
93
+ // Rotate the phone glyph 90° toward the natural orientation, pause, repeat.
94
+ const turn = this.#natural === "landscape" ? "90deg" : "-90deg";
95
+ if (typeof glyph.animate === "function") {
96
+ this.#glyphAnimation = glyph.animate(
97
+ [
98
+ { transform: "rotate(0deg)", offset: 0 },
99
+ { transform: "rotate(0deg)", offset: 0.25 },
100
+ { transform: `rotate(${turn})`, offset: 0.6 },
101
+ { transform: `rotate(${turn})`, offset: 1 },
102
+ ],
103
+ { duration: 2200, iterations: Infinity, easing: "ease-in-out" }
104
+ );
105
+ }
106
+
107
+ (options.parent ?? document.body).appendChild(this.#root);
108
+
109
+ if (typeof matchMedia === "function") {
110
+ this.#portraitQuery = matchMedia("(orientation: portrait)");
111
+ this.#portraitQuery.addEventListener("change", this.#onOrientationChange);
112
+ }
113
+ // Belt and braces — rotation signals differ per browser: the media query,
114
+ // the Screen Orientation API, and plain resize (which also catches
115
+ // browser-chrome show/hide). Re-evaluating is idempotent, so listen to all.
116
+ screen.orientation?.addEventListener("change", this.#onOrientationChange);
117
+ window.addEventListener("resize", this.#onOrientationChange);
118
+
119
+ this.#evaluate();
120
+ }
121
+
122
+ /** True while the overlay is shown (device held the wrong way). */
123
+ get blocking(): boolean {
124
+ return this.#blocking;
125
+ }
126
+
127
+ /** The overlay element (for ad-hoc styling). */
128
+ get element(): HTMLDivElement {
129
+ return this.#root;
130
+ }
131
+
132
+ /** Remove DOM + listeners. */
133
+ dispose(): void {
134
+ if (this.#disposed) return;
135
+ this.#disposed = true;
136
+ this.#portraitQuery?.removeEventListener("change", this.#onOrientationChange);
137
+ screen.orientation?.removeEventListener("change", this.#onOrientationChange);
138
+ window.removeEventListener("resize", this.#onOrientationChange);
139
+ this.#glyphAnimation?.cancel();
140
+ this.#root.remove();
141
+ if (this.#blocking) {
142
+ this.#blocking = false;
143
+ this.#onChange?.(false);
144
+ }
145
+ }
146
+
147
+ #evaluate(): void {
148
+ if (this.#disposed) return;
149
+ const isTouch = navigator.maxTouchPoints > 0;
150
+ const rotatable = Math.min(window.innerWidth, window.innerHeight) <= MAX_ROTATABLE_MIN_VIEWPORT;
151
+ const portrait = this.#portraitQuery
152
+ ? this.#portraitQuery.matches
153
+ : window.innerHeight >= window.innerWidth;
154
+ const current: "landscape" | "portrait" = portrait ? "portrait" : "landscape";
155
+ const shouldBlock = isTouch && rotatable && current !== this.#natural;
156
+ if (shouldBlock === this.#blocking) return;
157
+ this.#blocking = shouldBlock;
158
+ this.#root.style.display = shouldBlock ? "flex" : "none";
159
+ this.#onChange?.(shouldBlock);
160
+ }
161
+ }