@genex-ai/cli-demo 0.54.0-dev.123 → 0.55.0-dev.125
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/README.md +1 -1
- package/dist/index.js +40 -10
- package/package.json +1 -1
- package/templates/controllers/character/touch-joystick.ts +5 -387
- package/templates/controllers/touch/drag-zone.ts +186 -0
- package/templates/controllers/touch/rotate-overlay.ts +161 -0
- package/templates/controllers/touch/touch-joystick.ts +541 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +9 -6
- package/templates/skills/genex-threejs-game-ui/SKILL.md +5 -4
- package/templates/skills/genex-threejs-skill-router/SKILL.md +1 -0
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +157 -0
- package/templates/skills/genex-threejs-vehicle-controllers/SKILL.md +2 -1
|
@@ -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
|
+
}
|