@genex-ai/cli-demo 1.2.4-dev.363 → 1.3.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/dist/index.js +345 -180
- package/package.json +1 -1
- package/templates/controllers/chat/chat-overlay.ts +305 -0
- package/templates/controllers/voice/voice-proximity.ts +247 -0
- package/templates/skills/genex-ai-skybox/SKILL.md +1 -1
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +1 -1
- package/templates/skills/genex-game-director/references/routing-map.md +2 -0
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +29 -4
- package/templates/skills/genex-threejs-multiplayer/references/realtime-patterns.md +44 -2
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +3 -7
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// Room text chat overlay — the DOM half of the Genex chat lane. Zero dependencies,
|
|
2
|
+
// zero framework, no CSS file: it builds its own elements and styles them inline, the
|
|
3
|
+
// same way the touch kit does, so it drops into any game.
|
|
4
|
+
//
|
|
5
|
+
// The transport is `room.chat` from @genex-ai/multiplayer (the relay stamps the sender,
|
|
6
|
+
// sanitizes the text, and replays the recent history on connect). This file only renders
|
|
7
|
+
// it and collects input.
|
|
8
|
+
//
|
|
9
|
+
// THE ONE THING YOU MUST WIRE: while the player is typing, the GAME MUST NOT READ THE
|
|
10
|
+
// KEYBOARD. Otherwise "w" walks, "s" reverses and space jumps while someone types "was
|
|
11
|
+
// space needed?". Either check `chat.isTyping` in your input code, or use the
|
|
12
|
+
// `onTypingChange` callback to pause your input system:
|
|
13
|
+
//
|
|
14
|
+
// const chat = new ChatOverlay({
|
|
15
|
+
// room,
|
|
16
|
+
// onTypingChange: (typing) => { keyboard.enabled = !typing; },
|
|
17
|
+
// });
|
|
18
|
+
//
|
|
19
|
+
// Rendering is `textContent` ONLY, never innerHTML — every line is another player's raw
|
|
20
|
+
// input. The relay strips control and bidi characters, but it does not and cannot escape
|
|
21
|
+
// markup for you.
|
|
22
|
+
|
|
23
|
+
/** The slice of the multiplayer session this overlay needs. Structural on purpose — the
|
|
24
|
+
* vendored file never imports the SDK, so it cannot drift out of sync with its version. */
|
|
25
|
+
export interface ChatOverlayRoom {
|
|
26
|
+
id: string;
|
|
27
|
+
chat: {
|
|
28
|
+
send(text: string): void;
|
|
29
|
+
on(cb: (message: ChatLine) => void): () => void;
|
|
30
|
+
readonly history: ChatLine[];
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ChatLine {
|
|
35
|
+
id: number;
|
|
36
|
+
from: string;
|
|
37
|
+
name: string;
|
|
38
|
+
text: string;
|
|
39
|
+
at: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ChatOverlayOptions {
|
|
43
|
+
room: ChatOverlayRoom;
|
|
44
|
+
/** Called when typing starts/stops. Pause the game's keyboard input here. */
|
|
45
|
+
onTypingChange?: (typing: boolean) => void;
|
|
46
|
+
/** Lines kept on screen. Default 8. */
|
|
47
|
+
maxLines?: number;
|
|
48
|
+
/** Milliseconds a line stays visible after the chat closes; 0 keeps them forever. Default 9000. */
|
|
49
|
+
fadeAfterMs?: number;
|
|
50
|
+
/** Key that opens the composer. Default "Enter". */
|
|
51
|
+
openKey?: string;
|
|
52
|
+
/** Mount point. Default document.body. */
|
|
53
|
+
parent?: HTMLElement;
|
|
54
|
+
/** Placeholder text in the composer. */
|
|
55
|
+
placeholder?: string;
|
|
56
|
+
/** Colour used for your own name. Default a soft blue. */
|
|
57
|
+
selfColor?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ROOT_STYLE: Partial<CSSStyleDeclaration> = {
|
|
61
|
+
position: "fixed",
|
|
62
|
+
left: "calc(env(safe-area-inset-left, 0px) + 14px)",
|
|
63
|
+
bottom: "calc(env(safe-area-inset-bottom, 0px) + 14px)",
|
|
64
|
+
zIndex: "9000",
|
|
65
|
+
display: "flex",
|
|
66
|
+
flexDirection: "column",
|
|
67
|
+
alignItems: "flex-start",
|
|
68
|
+
gap: "6px",
|
|
69
|
+
maxWidth: "min(46vw, 420px)",
|
|
70
|
+
fontFamily: "system-ui, sans-serif",
|
|
71
|
+
fontSize: "13px",
|
|
72
|
+
lineHeight: "1.45",
|
|
73
|
+
// The log must never eat clicks meant for the game; the composer re-enables them.
|
|
74
|
+
pointerEvents: "none",
|
|
75
|
+
userSelect: "none",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const LOG_STYLE: Partial<CSSStyleDeclaration> = {
|
|
79
|
+
display: "flex",
|
|
80
|
+
flexDirection: "column",
|
|
81
|
+
gap: "3px",
|
|
82
|
+
width: "100%",
|
|
83
|
+
maxHeight: "34vh",
|
|
84
|
+
overflow: "hidden",
|
|
85
|
+
transition: "opacity 260ms ease",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const LINE_STYLE: Partial<CSSStyleDeclaration> = {
|
|
89
|
+
margin: "0",
|
|
90
|
+
padding: "3px 8px",
|
|
91
|
+
borderRadius: "6px",
|
|
92
|
+
background: "rgba(6, 9, 18, 0.55)",
|
|
93
|
+
color: "rgba(255, 255, 255, 0.92)",
|
|
94
|
+
textShadow: "0 1px 2px rgba(0, 0, 0, 0.6)",
|
|
95
|
+
wordBreak: "break-word",
|
|
96
|
+
// A single line must not be able to push the game off screen.
|
|
97
|
+
overflowWrap: "anywhere",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const INPUT_STYLE: Partial<CSSStyleDeclaration> = {
|
|
101
|
+
display: "none",
|
|
102
|
+
width: "min(46vw, 420px)",
|
|
103
|
+
boxSizing: "border-box",
|
|
104
|
+
padding: "7px 10px",
|
|
105
|
+
border: "1px solid rgba(255, 255, 255, 0.28)",
|
|
106
|
+
borderRadius: "7px",
|
|
107
|
+
background: "rgba(6, 9, 18, 0.88)",
|
|
108
|
+
color: "rgba(255, 255, 255, 0.95)",
|
|
109
|
+
font: "inherit",
|
|
110
|
+
outline: "none",
|
|
111
|
+
pointerEvents: "auto",
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const HINT_STYLE: Partial<CSSStyleDeclaration> = {
|
|
115
|
+
padding: "3px 8px",
|
|
116
|
+
borderRadius: "6px",
|
|
117
|
+
background: "rgba(6, 9, 18, 0.45)",
|
|
118
|
+
color: "rgba(255, 255, 255, 0.6)",
|
|
119
|
+
fontSize: "12px",
|
|
120
|
+
cursor: "pointer",
|
|
121
|
+
pointerEvents: "auto",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export class ChatOverlay {
|
|
125
|
+
readonly root: HTMLDivElement;
|
|
126
|
+
private readonly log: HTMLDivElement;
|
|
127
|
+
private readonly input: HTMLInputElement;
|
|
128
|
+
private readonly hint: HTMLDivElement;
|
|
129
|
+
private readonly opts: Required<Omit<ChatOverlayOptions, "room" | "onTypingChange" | "parent">> & {
|
|
130
|
+
room: ChatOverlayRoom;
|
|
131
|
+
onTypingChange?: (typing: boolean) => void;
|
|
132
|
+
};
|
|
133
|
+
private readonly offChat: () => void;
|
|
134
|
+
private readonly onKeyDown: (e: KeyboardEvent) => void;
|
|
135
|
+
private typing = false;
|
|
136
|
+
private fadeTimer: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
private disposed = false;
|
|
138
|
+
|
|
139
|
+
constructor(options: ChatOverlayOptions) {
|
|
140
|
+
const parent = options.parent ?? document.body;
|
|
141
|
+
this.opts = {
|
|
142
|
+
room: options.room,
|
|
143
|
+
onTypingChange: options.onTypingChange,
|
|
144
|
+
maxLines: options.maxLines ?? 8,
|
|
145
|
+
fadeAfterMs: options.fadeAfterMs ?? 9000,
|
|
146
|
+
openKey: options.openKey ?? "Enter",
|
|
147
|
+
placeholder: options.placeholder ?? "Say something…",
|
|
148
|
+
selfColor: options.selfColor ?? "rgb(132, 176, 255)",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
this.root = document.createElement("div");
|
|
152
|
+
Object.assign(this.root.style, ROOT_STYLE);
|
|
153
|
+
|
|
154
|
+
this.log = document.createElement("div");
|
|
155
|
+
Object.assign(this.log.style, LOG_STYLE);
|
|
156
|
+
this.root.appendChild(this.log);
|
|
157
|
+
|
|
158
|
+
this.hint = document.createElement("div");
|
|
159
|
+
Object.assign(this.hint.style, HINT_STYLE);
|
|
160
|
+
// Touch devices have no Enter key to discover, so they get a tappable affordance and
|
|
161
|
+
// pointer users get the shortcut spelled out.
|
|
162
|
+
this.hint.textContent =
|
|
163
|
+
navigator.maxTouchPoints > 0 ? "💬 Tap to chat" : `Press ${this.opts.openKey} to chat`;
|
|
164
|
+
this.hint.addEventListener("click", () => this.open());
|
|
165
|
+
this.root.appendChild(this.hint);
|
|
166
|
+
|
|
167
|
+
this.input = document.createElement("input");
|
|
168
|
+
this.input.type = "text";
|
|
169
|
+
// The relay clamps at 500 anyway; stopping at the browser avoids typing into a void.
|
|
170
|
+
this.input.maxLength = 500;
|
|
171
|
+
this.input.placeholder = this.opts.placeholder;
|
|
172
|
+
this.input.autocomplete = "off";
|
|
173
|
+
this.input.setAttribute("aria-label", "Chat message");
|
|
174
|
+
Object.assign(this.input.style, INPUT_STYLE);
|
|
175
|
+
this.root.appendChild(this.input);
|
|
176
|
+
|
|
177
|
+
parent.appendChild(this.root);
|
|
178
|
+
|
|
179
|
+
// Existing history first (a late joiner opens into a conversation, not a blank pane),
|
|
180
|
+
// then live lines. `chat.on` also fires for history the client hadn't seen, so this is
|
|
181
|
+
// additive rather than a race.
|
|
182
|
+
for (const line of this.opts.room.chat.history) this.append(line);
|
|
183
|
+
this.offChat = this.opts.room.chat.on((line) => this.append(line));
|
|
184
|
+
|
|
185
|
+
this.onKeyDown = (e: KeyboardEvent) => {
|
|
186
|
+
if (this.disposed) return;
|
|
187
|
+
if (!this.typing) {
|
|
188
|
+
if (e.key !== this.opts.openKey) return;
|
|
189
|
+
// Don't steal the key from another text field the game may own.
|
|
190
|
+
if (isTextEntry(e.target)) return;
|
|
191
|
+
e.preventDefault();
|
|
192
|
+
this.open();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// While typing, keys belong to the composer. stopPropagation keeps game listeners
|
|
196
|
+
// bound to window/document from seeing them at all — belt to onTypingChange's braces.
|
|
197
|
+
e.stopPropagation();
|
|
198
|
+
if (e.key === "Escape") {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
this.close();
|
|
201
|
+
} else if (e.key === "Enter") {
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
this.submit();
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
// Capture phase: the game's own key handlers usually bind on window/document in the
|
|
207
|
+
// bubble phase, so intercepting here is what actually keeps "w" out of the movement code.
|
|
208
|
+
window.addEventListener("keydown", this.onKeyDown, true);
|
|
209
|
+
|
|
210
|
+
this.scheduleFade();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** True while the composer has focus — the game must ignore keyboard input meanwhile. */
|
|
214
|
+
get isTyping(): boolean {
|
|
215
|
+
return this.typing;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
open(): void {
|
|
219
|
+
if (this.disposed || this.typing) return;
|
|
220
|
+
this.typing = true;
|
|
221
|
+
this.input.style.display = "block";
|
|
222
|
+
this.hint.style.display = "none";
|
|
223
|
+
this.log.style.opacity = "1";
|
|
224
|
+
if (this.fadeTimer) {
|
|
225
|
+
clearTimeout(this.fadeTimer);
|
|
226
|
+
this.fadeTimer = null;
|
|
227
|
+
}
|
|
228
|
+
this.input.focus();
|
|
229
|
+
this.opts.onTypingChange?.(true);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
close(): void {
|
|
233
|
+
if (this.disposed || !this.typing) return;
|
|
234
|
+
this.typing = false;
|
|
235
|
+
this.input.value = "";
|
|
236
|
+
this.input.blur();
|
|
237
|
+
this.input.style.display = "none";
|
|
238
|
+
this.hint.style.display = "block";
|
|
239
|
+
this.scheduleFade();
|
|
240
|
+
this.opts.onTypingChange?.(false);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Send whatever is in the composer and close it. */
|
|
244
|
+
submit(): void {
|
|
245
|
+
const text = this.input.value;
|
|
246
|
+
// Close first: the line arrives back from the relay, and the game should be taking
|
|
247
|
+
// keyboard input again by the time it renders.
|
|
248
|
+
this.close();
|
|
249
|
+
if (text.trim()) this.opts.room.chat.send(text);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
dispose(): void {
|
|
253
|
+
if (this.disposed) return;
|
|
254
|
+
this.disposed = true;
|
|
255
|
+
if (this.typing) this.opts.onTypingChange?.(false);
|
|
256
|
+
window.removeEventListener("keydown", this.onKeyDown, true);
|
|
257
|
+
this.offChat();
|
|
258
|
+
if (this.fadeTimer) clearTimeout(this.fadeTimer);
|
|
259
|
+
this.root.remove();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private append(line: ChatLine): void {
|
|
263
|
+
const el = document.createElement("p");
|
|
264
|
+
Object.assign(el.style, LINE_STYLE);
|
|
265
|
+
|
|
266
|
+
const who = document.createElement("span");
|
|
267
|
+
who.style.fontWeight = "600";
|
|
268
|
+
if (line.from === this.opts.room.id) who.style.color = this.opts.selfColor;
|
|
269
|
+
// textContent, not innerHTML — both of these are other players' input.
|
|
270
|
+
who.textContent = `${line.name}: `;
|
|
271
|
+
|
|
272
|
+
const body = document.createElement("span");
|
|
273
|
+
body.textContent = line.text;
|
|
274
|
+
|
|
275
|
+
el.appendChild(who);
|
|
276
|
+
el.appendChild(body);
|
|
277
|
+
this.log.appendChild(el);
|
|
278
|
+
|
|
279
|
+
while (this.log.childElementCount > this.opts.maxLines) {
|
|
280
|
+
this.log.firstElementChild?.remove();
|
|
281
|
+
}
|
|
282
|
+
this.log.style.opacity = "1";
|
|
283
|
+
this.scheduleFade();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private scheduleFade(): void {
|
|
287
|
+
if (this.fadeTimer) {
|
|
288
|
+
clearTimeout(this.fadeTimer);
|
|
289
|
+
this.fadeTimer = null;
|
|
290
|
+
}
|
|
291
|
+
if (this.typing || this.opts.fadeAfterMs <= 0) return;
|
|
292
|
+
this.fadeTimer = setTimeout(() => {
|
|
293
|
+
this.fadeTimer = null;
|
|
294
|
+
if (!this.typing) this.log.style.opacity = "0";
|
|
295
|
+
}, this.opts.fadeAfterMs);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Is the event target already a text-entry surface the game owns? */
|
|
300
|
+
function isTextEntry(target: EventTarget | null): boolean {
|
|
301
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
302
|
+
if (target.isContentEditable) return true;
|
|
303
|
+
const tag = target.tagName;
|
|
304
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
305
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// Proximity voice — the spatial half of the Genex voice lane.
|
|
2
|
+
//
|
|
3
|
+
// The SDK (`room.voice`) owns the microphone, the WebRTC mesh and the roster. It hands you
|
|
4
|
+
// a `MediaStream` per participant and deliberately does NOT play it when you join with
|
|
5
|
+
// `{ spatial: true }`. This file is what turns those streams into voices that come from
|
|
6
|
+
// where the speaker actually is: one THREE.PositionalAudio per participant, moved to that
|
|
7
|
+
// player's position every frame.
|
|
8
|
+
//
|
|
9
|
+
// Why proximity and not a flat room mix: in a 3D game, distance-attenuated voice is the
|
|
10
|
+
// entire point. It also softens the 6-participant cap — with falloff, six audible
|
|
11
|
+
// neighbours in a bigger room reads as a populated world rather than as a hard limit.
|
|
12
|
+
//
|
|
13
|
+
// Wiring (three lines plus your per-frame update):
|
|
14
|
+
//
|
|
15
|
+
// const voice = new ProximityVoice({ room, listener, camera });
|
|
16
|
+
// await voice.join(); // MUST be inside a click/tap handler
|
|
17
|
+
// // per frame: voice.update((id) => remoteBodies.get(id)?.position ?? null);
|
|
18
|
+
//
|
|
19
|
+
// `join()` asks for the microphone. Browsers gate that prompt — and audio playback — on a
|
|
20
|
+
// user gesture, so calling it at boot silently fails. Put it behind a button.
|
|
21
|
+
import * as THREE from "three";
|
|
22
|
+
|
|
23
|
+
/** The slice of the multiplayer session this needs. Structural so the vendored file never
|
|
24
|
+
* imports the SDK and cannot drift out of sync with its version. */
|
|
25
|
+
export interface ProximityVoiceRoom {
|
|
26
|
+
id: string;
|
|
27
|
+
voice: {
|
|
28
|
+
join(options?: {
|
|
29
|
+
iceServers?: RTCIceServer[];
|
|
30
|
+
startMuted?: boolean;
|
|
31
|
+
spatial?: boolean;
|
|
32
|
+
}): Promise<void>;
|
|
33
|
+
leave(): void;
|
|
34
|
+
readonly active: boolean;
|
|
35
|
+
readonly participants: ReadonlyArray<{
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
stream: MediaStream | null;
|
|
39
|
+
speaking: boolean;
|
|
40
|
+
muted: boolean;
|
|
41
|
+
}>;
|
|
42
|
+
setMicEnabled(on: boolean): void;
|
|
43
|
+
readonly micEnabled: boolean;
|
|
44
|
+
readonly speaking: boolean;
|
|
45
|
+
mute(playerId: string): void;
|
|
46
|
+
unmute(playerId: string): void;
|
|
47
|
+
};
|
|
48
|
+
on(event: string, cb: (payload: never) => void): () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ProximityVoiceOptions {
|
|
52
|
+
room: ProximityVoiceRoom;
|
|
53
|
+
/** Your THREE.AudioListener — normally attached to the camera. */
|
|
54
|
+
listener: THREE.AudioListener;
|
|
55
|
+
/** Scene node the voice sources are parented to. Defaults to the listener's parent chain root. */
|
|
56
|
+
scene?: THREE.Object3D;
|
|
57
|
+
/** Distance (world units) at which a voice starts falling off. Default 6. */
|
|
58
|
+
refDistance?: number;
|
|
59
|
+
/** Beyond this, a voice is effectively inaudible. Default 45. */
|
|
60
|
+
maxDistance?: number;
|
|
61
|
+
/** Falloff steepness. Default 1.4 — a little sharper than linear reads as "nearby". */
|
|
62
|
+
rolloffFactor?: number;
|
|
63
|
+
/** TURN servers. Without one, players behind symmetric NAT never connect (~10–20%). */
|
|
64
|
+
iceServers?: RTCIceServer[];
|
|
65
|
+
/** Push-to-talk key. Default "KeyV"; pass null for open-mic. */
|
|
66
|
+
pushToTalkKey?: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
type Voice = {
|
|
70
|
+
id: string;
|
|
71
|
+
audio: THREE.PositionalAudio;
|
|
72
|
+
holder: THREE.Object3D;
|
|
73
|
+
stream: MediaStream;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export class ProximityVoice {
|
|
77
|
+
private readonly room: ProximityVoiceRoom;
|
|
78
|
+
private readonly listener: THREE.AudioListener;
|
|
79
|
+
private readonly scene: THREE.Object3D;
|
|
80
|
+
private readonly opts: Required<
|
|
81
|
+
Pick<ProximityVoiceOptions, "refDistance" | "maxDistance" | "rolloffFactor">
|
|
82
|
+
>;
|
|
83
|
+
private readonly iceServers: RTCIceServer[] | undefined;
|
|
84
|
+
private readonly pttKey: string | null;
|
|
85
|
+
private readonly voices = new Map<string, Voice>();
|
|
86
|
+
private readonly offVoice: () => void;
|
|
87
|
+
private onKeyDown: ((e: KeyboardEvent) => void) | null = null;
|
|
88
|
+
private onKeyUp: ((e: KeyboardEvent) => void) | null = null;
|
|
89
|
+
private disposed = false;
|
|
90
|
+
|
|
91
|
+
constructor(options: ProximityVoiceOptions) {
|
|
92
|
+
this.room = options.room;
|
|
93
|
+
this.listener = options.listener;
|
|
94
|
+
this.scene = options.scene ?? topLevelParent(options.listener);
|
|
95
|
+
this.opts = {
|
|
96
|
+
refDistance: options.refDistance ?? 6,
|
|
97
|
+
maxDistance: options.maxDistance ?? 45,
|
|
98
|
+
rolloffFactor: options.rolloffFactor ?? 1.4,
|
|
99
|
+
};
|
|
100
|
+
this.iceServers = options.iceServers;
|
|
101
|
+
this.pttKey = options.pushToTalkKey === undefined ? "KeyV" : options.pushToTalkKey;
|
|
102
|
+
// The SDK re-emits the whole participant list on every change (join, leave, connect,
|
|
103
|
+
// speaking, mute), so reconciling from scratch is both simplest and always correct.
|
|
104
|
+
this.offVoice = this.room.on("voice", () => this.sync());
|
|
105
|
+
// Declares "this game uses voice" to the publish-time bundle scan, which is what earns
|
|
106
|
+
// the game a scoped `allow="microphone …"` on the dashboard frame. A global on purpose:
|
|
107
|
+
// it survives minification, exactly like the quality kit's __GENEX_MODEL_RUNGS__.
|
|
108
|
+
// Rolling your own voice UI instead of this class? Set it yourself, or the embedded
|
|
109
|
+
// build will be denied the microphone by Permissions Policy.
|
|
110
|
+
(window as Window & { __GENEX_VOICE__?: boolean }).__GENEX_VOICE__ = true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** True while this client is transmitting. */
|
|
114
|
+
get transmitting(): boolean {
|
|
115
|
+
return this.room.voice.active && this.room.voice.micEnabled;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get participants(): ProximityVoiceRoom["voice"]["participants"] {
|
|
119
|
+
return this.room.voice.participants;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Ask for the mic and join. MUST be called from a click/tap handler. Rejects when the
|
|
123
|
+
* player denies permission — show that, don't swallow it. */
|
|
124
|
+
async join(): Promise<void> {
|
|
125
|
+
// spatial: true is what stops the SDK from playing the audio flat; this class plays it.
|
|
126
|
+
await this.room.voice.join({
|
|
127
|
+
spatial: true,
|
|
128
|
+
iceServers: this.iceServers,
|
|
129
|
+
startMuted: this.pttKey !== null,
|
|
130
|
+
});
|
|
131
|
+
if (this.pttKey !== null) this.bindPushToTalk(this.pttKey);
|
|
132
|
+
this.sync();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
leave(): void {
|
|
136
|
+
this.room.voice.leave();
|
|
137
|
+
for (const id of [...this.voices.keys()]) this.dropVoice(id);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Per frame: move every voice to its speaker. Return null for a player you cannot place
|
|
141
|
+
* (not spawned yet, culled) — their voice is parked rather than left at a stale spot. */
|
|
142
|
+
update(positionOf: (playerId: string) => THREE.Vector3 | null | undefined): void {
|
|
143
|
+
if (this.disposed) return;
|
|
144
|
+
for (const voice of this.voices.values()) {
|
|
145
|
+
const at = positionOf(voice.id);
|
|
146
|
+
// A voice left at the last known position is worse than a silent one: it sounds like
|
|
147
|
+
// someone is standing where nobody is. Mute rather than lie about where they are.
|
|
148
|
+
const placed = at != null;
|
|
149
|
+
if (placed) voice.holder.position.copy(at);
|
|
150
|
+
if (voice.audio.getVolume() !== (placed ? 1 : 0)) voice.audio.setVolume(placed ? 1 : 0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
dispose(): void {
|
|
155
|
+
if (this.disposed) return;
|
|
156
|
+
this.disposed = true;
|
|
157
|
+
this.unbindPushToTalk();
|
|
158
|
+
this.offVoice();
|
|
159
|
+
this.leave();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- internals ----
|
|
163
|
+
|
|
164
|
+
private sync(): void {
|
|
165
|
+
if (this.disposed) return;
|
|
166
|
+
const live = new Set<string>();
|
|
167
|
+
for (const p of this.room.voice.participants) {
|
|
168
|
+
if (!p.stream) continue; // still negotiating — nothing to attach yet
|
|
169
|
+
live.add(p.id);
|
|
170
|
+
const existing = this.voices.get(p.id);
|
|
171
|
+
// A reconnect can hand back a NEW stream for the same player; rebuild in that case.
|
|
172
|
+
if (existing && existing.stream !== p.stream) this.dropVoice(p.id);
|
|
173
|
+
if (!this.voices.has(p.id)) this.addVoice(p.id, p.stream);
|
|
174
|
+
const voice = this.voices.get(p.id);
|
|
175
|
+
if (voice) voice.audio.setVolume(p.muted ? 0 : voice.audio.getVolume() || 1);
|
|
176
|
+
}
|
|
177
|
+
for (const id of [...this.voices.keys()]) if (!live.has(id)) this.dropVoice(id);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private addVoice(id: string, stream: MediaStream): void {
|
|
181
|
+
const holder = new THREE.Object3D();
|
|
182
|
+
holder.name = `voice:${id}`;
|
|
183
|
+
this.scene.add(holder);
|
|
184
|
+
const audio = new THREE.PositionalAudio(this.listener);
|
|
185
|
+
audio.setRefDistance(this.opts.refDistance);
|
|
186
|
+
audio.setMaxDistance(this.opts.maxDistance);
|
|
187
|
+
audio.setRolloffFactor(this.opts.rolloffFactor);
|
|
188
|
+
audio.setDistanceModel("exponential");
|
|
189
|
+
audio.setMediaStreamSource(stream);
|
|
190
|
+
holder.add(audio);
|
|
191
|
+
this.voices.set(id, { id, audio, holder, stream });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private dropVoice(id: string): void {
|
|
195
|
+
const voice = this.voices.get(id);
|
|
196
|
+
if (!voice) return;
|
|
197
|
+
this.voices.delete(id);
|
|
198
|
+
try {
|
|
199
|
+
voice.audio.disconnect();
|
|
200
|
+
} catch {
|
|
201
|
+
/* already disconnected */
|
|
202
|
+
}
|
|
203
|
+
voice.holder.removeFromParent();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private bindPushToTalk(code: string): void {
|
|
207
|
+
this.unbindPushToTalk();
|
|
208
|
+
this.onKeyDown = (e: KeyboardEvent) => {
|
|
209
|
+
if (e.code !== code || e.repeat) return;
|
|
210
|
+
// Never grab the key while the player is typing — chat's composer owns it then.
|
|
211
|
+
if (isTextEntry(e.target)) return;
|
|
212
|
+
this.room.voice.setMicEnabled(true);
|
|
213
|
+
};
|
|
214
|
+
this.onKeyUp = (e: KeyboardEvent) => {
|
|
215
|
+
if (e.code !== code) return;
|
|
216
|
+
this.room.voice.setMicEnabled(false);
|
|
217
|
+
};
|
|
218
|
+
window.addEventListener("keydown", this.onKeyDown);
|
|
219
|
+
window.addEventListener("keyup", this.onKeyUp);
|
|
220
|
+
// A key held while the tab loses focus never fires keyup — without this the mic
|
|
221
|
+
// stays open after the player alt-tabs away, which is the worst possible bug here.
|
|
222
|
+
window.addEventListener("blur", this.onKeyUp as unknown as EventListener);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private unbindPushToTalk(): void {
|
|
226
|
+
if (this.onKeyDown) window.removeEventListener("keydown", this.onKeyDown);
|
|
227
|
+
if (this.onKeyUp) {
|
|
228
|
+
window.removeEventListener("keyup", this.onKeyUp);
|
|
229
|
+
window.removeEventListener("blur", this.onKeyUp as unknown as EventListener);
|
|
230
|
+
}
|
|
231
|
+
this.onKeyDown = null;
|
|
232
|
+
this.onKeyUp = null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function topLevelParent(node: THREE.Object3D): THREE.Object3D {
|
|
237
|
+
let current: THREE.Object3D = node;
|
|
238
|
+
while (current.parent) current = current.parent;
|
|
239
|
+
return current;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function isTextEntry(target: EventTarget | null): boolean {
|
|
243
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
244
|
+
if (target.isContentEditable) return true;
|
|
245
|
+
const tag = target.tagName;
|
|
246
|
+
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
|
|
247
|
+
}
|
|
@@ -116,7 +116,7 @@ scene.background = texture; // keep the raw texture for the visible sky
|
|
|
116
116
|
|
|
117
117
|
## Troubleshooting
|
|
118
118
|
|
|
119
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
119
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
120
120
|
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
121
121
|
this skybox generation. Tell the user the facts the CLI printed: their balance, this
|
|
122
122
|
generation's cost, and when their credits refill. Then offer to continue the build
|
|
@@ -225,7 +225,7 @@ first one is the one a screenshot of the whole arena will not show you.
|
|
|
225
225
|
|
|
226
226
|
## Troubleshooting
|
|
227
227
|
|
|
228
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
228
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
229
229
|
- **"Out of credits" (`insufficient_credits`)** — the account has no credits left for
|
|
230
230
|
this texture generation. Tell the user the facts the CLI printed: their balance,
|
|
231
231
|
this generation's cost, and when their credits refill. Then offer to continue the
|
|
@@ -151,7 +151,7 @@ set belongs to `$genex-ai-hud` — both build on `npx genex image`/`video`.
|
|
|
151
151
|
|
|
152
152
|
## Troubleshooting
|
|
153
153
|
|
|
154
|
-
- **"Not authorized"** — run `npx @genex-ai/cli-demo@
|
|
154
|
+
- **"Not authorized"** — run `npx @genex-ai/cli-demo@latest init` first (it writes your `GENEX_TOKEN`).
|
|
155
155
|
- **"Prompt rejected"** — the provider's content-safety filter blocked the prompt.
|
|
156
156
|
This is non-retryable; retrying the same wording fails again. Rewrite the prompt.
|
|
157
157
|
- **Nothing plays / black surface** — the first `video.play()` must run inside a user
|
|
@@ -63,6 +63,8 @@ copy demo architecture.
|
|
|
63
63
|
| drawn HUD chrome the game's style wants—one element or a matched set of frames, masks, and icons | `$genex-ai-hud` |
|
|
64
64
|
| the game works but feels flat, floaty, or unresponsive: input response, camera, impacts, cooldowns, difficulty, fail/retry | `$genex-threejs-game-feel` |
|
|
65
65
|
| realtime multiplayer: movement sync, shared ball/NPC, host-run scores/enemies, shots/emotes, persistence | `$genex-threejs-multiplayer` |
|
|
66
|
+
| players talking to each other by TEXT — chat, room chat, "let them type to each other" (`room.chat` + `npx genex controller chat`) | `$genex-threejs-multiplayer` |
|
|
67
|
+
| players TALKING — voice chat, mic, "hear each other", proximity/positional voice (`room.voice` + `npx genex controller voice`; party-sized, max 6 per room — say that number out loud before building the feature) | `$genex-threejs-multiplayer` |
|
|
66
68
|
| player identity, sign-in, guests, saves/progress, per-player state, shared persistent world, leaderboards—mandatory for every game | `$genex-threejs-embed-auth` |
|
|
67
69
|
|
|
68
70
|
## Request-sized execution
|
|
@@ -159,7 +159,7 @@ and re-link the clone to the same live game:
|
|
|
159
159
|
```bash
|
|
160
160
|
git clone <the game's repo url> my-game && cd my-game
|
|
161
161
|
npm install
|
|
162
|
-
npx @genex-ai/cli-demo@
|
|
162
|
+
npx @genex-ai/cli-demo@latest link <slug> # slug = the name in the play URL
|
|
163
163
|
```
|
|
164
164
|
|
|
165
165
|
Don't know the slug? **`npx genex list`** prints every game on your account —
|
|
@@ -186,7 +186,7 @@ Safe to run any time — genex-owned skills are refreshed to the latest version,
|
|
|
186
186
|
and your own files are never touched:
|
|
187
187
|
|
|
188
188
|
```bash
|
|
189
|
-
npx @genex-ai/cli-demo@
|
|
189
|
+
npx @genex-ai/cli-demo@latest init
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
Use `--force` only if you intentionally want your own existing files overwritten
|