@genex-ai/cli-demo 1.36.5-dev.858 → 1.36.6-dev.861
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 +890 -397
- package/package.json +1 -1
- package/templates/controllers/character/aim-cue.ts +2 -1
- package/templates/controllers/character/follow-camera.ts +4 -3
- package/templates/skills/genex-ai-video/SKILL.md +8 -3
- package/templates/skills/genex-game-director/SKILL.md +1 -0
- package/templates/skills/genex-llm-in-games/SKILL.md +9 -0
- package/templates/skills/genex-monetization/SKILL.md +26 -0
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +3 -3
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +2 -2
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +158 -36
- package/templates/skills/genex-threejs-embed-auth/references/shells.md +207 -0
- package/templates/skills/genex-threejs-game-ui/SKILL.md +22 -14
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +46 -8
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +63 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# Genex shells: what a game can do where, and what to call instead
|
|
2
|
+
|
|
3
|
+
One build of a Genex game runs in four places, and the browser gives a page
|
|
4
|
+
different powers in each. A call that works in one is refused, dropped or wrong
|
|
5
|
+
in another, usually with no error at all. This page is the one place that says
|
|
6
|
+
which. **Find the row before you write a browser or device API**: `navigator.*`,
|
|
7
|
+
`window.open`, a download link, a dialog, anything that reads the URL.
|
|
8
|
+
|
|
9
|
+
| `getShell()` | Where the game is | What that is |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `"page"` | its page on Genex (and the owner's draft workspace) | a sandboxed frame inside a Genex page. **Most players are here**: it is the link you hand out. Any frame reads as `"page"`, whoever frames it: inside a page that is not Genex's, `openExternal()` and `saveFile()` answer `unsupported` and the "On its Genex page" column does not describe it. |
|
|
12
|
+
| `"app"` | the Genex mobile app | one web view that shows the game and never leaves it. A game opened as the whole page of any React Native web view reads as `"app"`. |
|
|
13
|
+
| `"standalone"` | its own link: the game's address, a preview address, a creator's own domain | an ordinary browser tab |
|
|
14
|
+
| `"local"` | a dev server in local test mode | an ordinary browser tab on `localhost` |
|
|
15
|
+
|
|
16
|
+
**Local test mode behaves like the game's own link, never like its Genex
|
|
17
|
+
page.** Nothing you run locally is inside the frame, so a call that passes your
|
|
18
|
+
own test can still be dead for players. `npx genex preview` reads the game's
|
|
19
|
+
source and warns about the calls it recognises; this page covers the rest.
|
|
20
|
+
|
|
21
|
+
The SDK calls below are `@genex-ai/embed-sdk` **0.32.0+**
|
|
22
|
+
(`npm i @genex-ai/embed-sdk@latest` if an import is missing). Three rules hold
|
|
23
|
+
for all of them:
|
|
24
|
+
|
|
25
|
+
- **Call them from a click or tap handler, before any `await`.** A browser lets
|
|
26
|
+
a page share, copy, open a tab or go fullscreen only while a click is being
|
|
27
|
+
handled, and one click buys one action.
|
|
28
|
+
- **They resolve; they never throw.** `share()` resolves `{ status, url }`, the
|
|
29
|
+
others `{ ok: true }` or `{ ok: false, error }`, and `haptic()` returns
|
|
30
|
+
nothing. The `error` words are listed under "What a call answers".
|
|
31
|
+
- **`supports(name)` says whether to draw the button at all**: `"share"`,
|
|
32
|
+
`"copy"`, `"awake"`, `"haptic"`, `"open"`, `"save"`, `"fullscreen"`. On the
|
|
33
|
+
Genex page ask after `waitForPlayer()`. It is a hint; the call's own answer
|
|
34
|
+
is the truth.
|
|
35
|
+
|
|
36
|
+
A game that cannot carry the SDK still lives by the three shell columns: they
|
|
37
|
+
say what the raw browser API does there. Where the last column is an SDK call
|
|
38
|
+
and there is no raw way, the fallback is always the same one: show the text,
|
|
39
|
+
the link or the code on screen.
|
|
40
|
+
|
|
41
|
+
## Works for every game — use the browser API
|
|
42
|
+
|
|
43
|
+
| The game wants | On its Genex page | In the Genex app | On its own link | Do this |
|
|
44
|
+
| --- | --- | --- | --- | --- |
|
|
45
|
+
| Pointer lock: mouse look, a hidden cursor | yes, from a click inside the game; Escape always releases it | a touch screen: nothing to lock | yes | `canvas.requestPointerLock()` in the click handler, or the bundled `FollowCamera`, which owns the lock. Pause when the lock is lost (`pointerlockchange`): Escape ends it, and so does the SDK before it asks Genex for a link, a file, a purchase or a model-call approval, because the player needs the cursor for that sheet. |
|
|
46
|
+
| A gamepad | yes | not checked: keep the touch controls | yes | `navigator.getGamepads()` each frame. A pad shows up after its first button press. |
|
|
47
|
+
| Tilt and motion: `deviceorientation`, `devicemotion` | passed to every game, but the browser has the last word: an iPhone asks the player, and only from a tap inside the game, and a browser may send a frame no sensor events at all | no Genex gate; the same rules | the same rules | Ask from the Start tap, treat a refusal or silence as "no sensor", keep the on-screen controls: the tilt recipe in `$genex-threejs-touch-controls`. |
|
|
48
|
+
| WebXR | passed to every game; not tried on a headset | do not rely on it | the browser's own | `navigator.xr`. A refused session is an ordinary answer. |
|
|
49
|
+
| A `<form>`: chat box, name entry, Enter to send | yes, `submit` fires | yes | yes | Handle `submit` and call `preventDefault()`. A form that is allowed to navigate replaces the game. |
|
|
50
|
+
| One orientation | the page does not refuse `screen.orientation.lock()`; whether a lock takes is the browser's and the device's | the app lets the device rotate freely while a game is open | the same as the page | Never depend on a lock. Design for one orientation and show the rotate overlay (`$genex-threejs-touch-controls`). |
|
|
51
|
+
| Sound | the page lets a game start sound without a tap of its own once the visitor has clicked anywhere on Genex; not every browser honours that | media may play inline, without a second tap | no click has happened yet when the game opens | Start audio on the first tap or key inside the game, in every shell. Muted inline video (`muted`, `playsInline`) may start by itself. |
|
|
52
|
+
| The keyboard | keys reach the game only while its frame has focus. The page gives it focus when the game starts and when a Genex sheet closes, unless the visitor is using something else on the page; a click in the game always does | no hardware keyboard to count on | yes | Start on a click or tap as well as a key: listen for `pointerdown` and `keydown`, and write "Click or press any key". Key labels for the player's layout (`navigator.keyboard.getLayoutMap()`) are passed too, where the browser has them. |
|
|
53
|
+
| The player picking a file: `<input type="file">` | yes | do not rely on it: Android refuses the picker | yes | The player chooses; the game reads what it is handed. |
|
|
54
|
+
| Device storage: `localStorage`, IndexedDB | works, in a store of its own: not the one the same game has on its own link | another store again, gone when the app is removed | one store per address: the live game, a preview and a creator's domain each have their own | Settings only (volume, quality, key binds). Progress goes through `savePlayerState()`; a local copy is a cache for this device and this shell, never the save. |
|
|
55
|
+
|
|
56
|
+
## Needs a declaration — microphone, camera, location
|
|
57
|
+
|
|
58
|
+
| The game wants | On its Genex page | In the Genex app | On its own link | Do this |
|
|
59
|
+
| --- | --- | --- | --- | --- |
|
|
60
|
+
| The microphone: voice chat, `getUserMedia({ audio })`, speech recognition | only a build that declared `microphone`. An undeclared build is refused at once and the player is never asked | refused for every game, declared or not | the browser's own permission prompt | Declare it (below). Voice chat is `room.voice.join()` in `$genex-threejs-multiplayer`. Hide the button when `getShell() === "app"`. |
|
|
61
|
+
| The camera: `getUserMedia({ video })` | only a build that declared `camera` | refused for every game | the browser's own permission prompt | Declare it. The game must play without it. |
|
|
62
|
+
| The player's location: `navigator.geolocation` | only a build that declared `geolocation` | refused for every game | the browser's own permission prompt | Declare it. Offer a way to play without it, such as picking a place. |
|
|
63
|
+
|
|
64
|
+
**The declaration is made when the build ships, never at run time.**
|
|
65
|
+
`npx genex preview` and `npx genex publish` read the game's own source and
|
|
66
|
+
declare what they find: `.voice.join()` called with nothing or with an options
|
|
67
|
+
object written at the call, `SpeechRecognition` or `getUserMedia` with `audio`
|
|
68
|
+
for the microphone (the voice pack from `npx genex controller voice` declares
|
|
69
|
+
it by itself); `facingMode` or `getUserMedia` with `video` for the camera;
|
|
70
|
+
`navigator.geolocation` for location. A call the scan cannot see
|
|
71
|
+
(`voice.join(opts)` with the options in a variable, `getUserMedia(constraints)`)
|
|
72
|
+
is declared by hand, in the game's `package.json`:
|
|
73
|
+
|
|
74
|
+
```jsonc
|
|
75
|
+
"genex": {
|
|
76
|
+
"capabilities": ["microphone", "camera", "geolocation"] // only the ones the game uses
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The names are exactly those three. A misspelt name or a value that is not a
|
|
81
|
+
list declares nothing, and the command says so. Both commands print the result
|
|
82
|
+
before they upload (`On its Genex page this build is given: microphone.`), so
|
|
83
|
+
a missing declaration shows before a player meets it. The declaration belongs
|
|
84
|
+
to the build: preview or publish again after changing it.
|
|
85
|
+
|
|
86
|
+
A declaration lets the Genex page pass the request on to the browser. What the
|
|
87
|
+
player is asked, and when, is the browser's business, and the answer can be no
|
|
88
|
+
in every shell: the player refuses, the app always does, and Genex can withdraw
|
|
89
|
+
a capability from one game. **So every one of these needs a path that works
|
|
90
|
+
without it.**
|
|
91
|
+
|
|
92
|
+
## Goes through an SDK call
|
|
93
|
+
|
|
94
|
+
The raw API is in the shell columns; the call that is right in all of them is
|
|
95
|
+
in the last one.
|
|
96
|
+
|
|
97
|
+
| The game wants | On its Genex page | In the Genex app | On its own link | Call this |
|
|
98
|
+
| --- | --- | --- | --- | --- |
|
|
99
|
+
| To share itself, or an invite | `navigator.share` is passed to every game, but `location.href` there is the frame's bare address, never the page to send people to | a share sheet only where the app's web view has one | `navigator.share` after a tap, where the browser has a sheet | `share({ text, params })`. It builds the link itself, never from the current query or fragment: the game's page on Genex where Genex serves the live build or frames the game, otherwise the address the game is on (a preview address, a creator's own domain, a dev server), plus your `params`. It opens the sheet where there is one, copies the link where there is not (`copied`), and hands the link back as `unavailable` when neither works: show it. |
|
|
100
|
+
| To copy text: a room code, a seed | `navigator.clipboard.writeText` is passed to every game, after a tap. Reading the clipboard is not | depends on the app's web view | yes, after a tap | `copyText(text)`: the clipboard, then the older selection copy. To take text IN, let the player paste into an `<input>`; the `paste` event needs no permission. |
|
|
101
|
+
| The screen to stay on: tilt play, a cutscene | `navigator.wakeLock` is passed to every game; the browser drops the lock whenever the tab is hidden | the screen already stays on while a game is open | yes, with the same drop | `keepAwake(true)`, later `keepAwake(false)`. It takes the lock again when the player returns, and resolves `{ ok: true }` in the app. |
|
|
102
|
+
| A vibration | `navigator.vibrate` where the browser has it: Android browsers, after the first tap. An iPhone has none | nothing | the same as the page | `haptic(kind)`: `"selection"`, `"light"`, `"medium"`, `"heavy"`, `"success"`, `"warning"`, `"error"`. Never the only signal that something happened. |
|
|
103
|
+
| To open a link: Discord, a creator's site, credits | `window.open()` returns `null` and a `target="_blank"` click does nothing. A plain link to another site loads that site inside the frame, replacing the game | blocked; the app shows "An external link was blocked." | a new tab, from a click | `openExternal(url)`: an absolute `https://` address of at most 2048 characters. On the Genex page Genex shows the player where the link goes and opens it on their yes; `dismissed` is their no. In the app it answers `unsupported`: show the address as text. |
|
|
104
|
+
| To save a file: a screenshot, a replay, an export | a download started inside the game is dropped, with no error | not available | a download link works | `saveFile(blob, name)`: PNG, JPEG, WebP, WebM, MP4, JSON or plain text, up to 50 MB. `name` is a file name of 1 to 128 characters, never a path (no `/` or `\`); the saved extension follows the Blob's type. On the Genex page Genex asks the player first. In the app it answers `unsupported`. |
|
|
105
|
+
| Fullscreen | the game's own `requestFullscreen()` covers the frame only, without the page's controls, and does nothing on an iPhone, whose browser has no fullscreen for a page element | nothing to toggle: the game already has the whole screen | the browser's fullscreen | `setFullscreen(true)` / `setFullscreen(false)`. On the Genex page it asks for the PAGE's fullscreen, the one the page's own button gives. Entering needs the player's click, tap or key press inside the game, and a click on the page around the game in the five seconds before does not count; leaving does not. Nothing stays fullscreen while the page shows the player a sheet (a link, a file, a purchase): asked in the same tick as one, fullscreen is left again and answers `busy`. In the app it answers `unsupported`. |
|
|
106
|
+
| Its own URL parameters, and where the visitor came from | the page passes its own link on: `location.search` holds Genex's keys and then your pairs, `location.hash` is your fragment. `document.referrer` is Genex | the link the app was opened with; no referrer | your query survives the sign-in round trip. Until `initEmbed()` runs, `location.hash` holds Genex's sign-in fragment, and yours is put back with no `hashchange`. `document.referrer` is Genex | `getEntry()` → `{ search, params, hash, referrer }`: the same answer in every shell, before and after `initEmbed()`. Details under "The link a game was opened with". |
|
|
107
|
+
| A last save as the game closes | `pagehide` fires when the tab closes or reloads. A "Leave site?" prompt is never shown | the app tells the game before it closes it | `pagehide` fires | `onShellClosing(cb)`. The last chance to save, not the only one: save as the game goes, and never rely on `beforeunload`. |
|
|
108
|
+
| To sell an item | Genex draws the confirmation above the game and leaves fullscreen first; the SDK lets go of the pointer lock before it asks | no purchase surface yet: `buy()` resolves `failed` | a Genex popup, so `buy()` must start in the click | `buy()`, in `$genex-monetization`. Hide the buy buttons when `getShell() === "app"`. |
|
|
109
|
+
| A model call during play | Genex draws the approval above the game | no approval surface yet: the call resolves `failed` with `native_unsupported` | a Genex popup, so the call must start in the click | `generate()` and its family, in `$genex-llm-in-games`. Hide the entry point when `getShell() === "app"`. |
|
|
110
|
+
|
|
111
|
+
## Never
|
|
112
|
+
|
|
113
|
+
Nothing here has a call that makes it work. Each row names what to build
|
|
114
|
+
instead.
|
|
115
|
+
|
|
116
|
+
| Do not use | On its Genex page | In the Genex app | On its own link | Instead |
|
|
117
|
+
| --- | --- | --- | --- | --- |
|
|
118
|
+
| `alert()`, `confirm()`, `prompt()`, `print()` | nothing is shown: `alert()` returns at once, `confirm()` answers `false`, `prompt()` answers `null`, with no error | a blocking system dialog | a blocking system dialog | Draw the dialog in the game's own interface (`$genex-threejs-game-ui`). |
|
|
119
|
+
| Navigating away: `top.location`, `target="_top"`, a link or form that leaves the game | the page around the game cannot be navigated; a link inside the frame replaces the game | the app refuses to leave the game's address | the player has simply left the game | Keep every screen inside the game. Links out are `openExternal()`. |
|
|
120
|
+
| Reading the clipboard | not passed to any game | do not rely on it | a browser permission prompt | An `<input>` and the `paste` event. |
|
|
121
|
+
| Keyboard Lock: `navigator.keyboard.lock()` | not available: browsers give it to the top-level page only, so Escape leaves fullscreen and frees the cursor | no keyboard to lock | Chromium browsers, in fullscreen | Pause when pointer lock is lost. The lock is an extra for the game's own link (`$genex-threejs-game-ui`). |
|
|
122
|
+
| Notifications, web push | not available to a frame | not available | would prompt under the game's own address | Nothing. Do not ask. |
|
|
123
|
+
| A service worker, an installable web app | it registers, and one that caches then keeps serving its copy: an older build after an update, a game that has since been taken down | do not rely on it | the same as the page | Never register one. Genex serves the game and updates it. |
|
|
124
|
+
| `SharedArrayBuffer`, threaded WebAssembly | not available | not available | not available | Single-threaded builds. |
|
|
125
|
+
| MIDI, Bluetooth, USB, serial, HID, screen capture, browser payments, passkeys, sign-in popups for other services | not passed to any game | not available | behind the browser's own prompts | Do not build on them. Sign-in and money are Genex's: `waitForPlayer()` and `buy()`. |
|
|
126
|
+
| `history.pushState()` for in-game screens | its entries join the tab's history, so Back walks the game's screens before it leaves the page | Back belongs to the app | Back walks the game's screens first | Keep screens in memory, or use `replaceState`. |
|
|
127
|
+
| `document.title`, a favicon | never shown: the tab is the Genex page's | never shown | shown | Nothing to do. |
|
|
128
|
+
|
|
129
|
+
Also not for games: reaching `localhost` or devices on the player's network,
|
|
130
|
+
and the browser's built-in AI models. And a hidden game gets no frames: pause
|
|
131
|
+
on `visibilitychange`, never assume `requestAnimationFrame` keeps running.
|
|
132
|
+
|
|
133
|
+
## What a call answers
|
|
134
|
+
|
|
135
|
+
`{ ok: false, error }` from `copyText()`, `keepAwake()`, `openExternal()`,
|
|
136
|
+
`saveFile()` and `setFullscreen()`:
|
|
137
|
+
|
|
138
|
+
| `error` | It means | What to do |
|
|
139
|
+
| --- | --- | --- |
|
|
140
|
+
| `unsupported` | this shell or this browser cannot do it | hide the button, or show the link or the data another way |
|
|
141
|
+
| `needs_gesture` | the browser wants a click or a tap behind the call. On the Genex page `setFullscreen(true)` also answers it when no click, tap or key press of the player's INSIDE the game stands behind it, which includes the five seconds after they clicked or typed on the page around the game | call it straight from the handler, before any `await`, and keep the button up: the player's next click in the game works |
|
|
142
|
+
| `dismissed` | the player said no | nothing; it is not an error |
|
|
143
|
+
| `busy` | another request is still waiting for the player's answer, or a `setFullscreen(true)` the browser has not answered yet | ignore the extra click |
|
|
144
|
+
| `rate_limited` | the game asked too often | never call these on a timer |
|
|
145
|
+
| `invalid_request` | the arguments are outside the bounds in the call's row | fix the call |
|
|
146
|
+
| `too_large` | the file is over the size limit | save something smaller |
|
|
147
|
+
| `timeout` | Genex did not answer in time | treat it like `unsupported` for this click |
|
|
148
|
+
|
|
149
|
+
`unsupported` and `timeout` are ordinary answers, not bugs. Genex is updated
|
|
150
|
+
separately from a game, so a Genex page that does not take these requests yet
|
|
151
|
+
answers `unsupported` to `openExternal()` and `saveFile()` without asking
|
|
152
|
+
anyone, and `setFullscreen()` fullscreens the frame's own document there.
|
|
153
|
+
|
|
154
|
+
## The link a game was opened with
|
|
155
|
+
|
|
156
|
+
A link to a game can carry the game's own parameters: an invite
|
|
157
|
+
(`?lobby=abc`), a seed, a route (`#level-3`). Read them with `getEntry()`,
|
|
158
|
+
never from `location` or `document.referrer`:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { getEntry } from "@genex-ai/embed-sdk";
|
|
162
|
+
|
|
163
|
+
const entry = getEntry(); // safe before initEmbed()
|
|
164
|
+
const lobby = entry.params.get("lobby"); // ?lobby=abc -> "abc"
|
|
165
|
+
const route = entry.hash; // #level-3 -> "#level-3"
|
|
166
|
+
const from = entry.referrer; // "https://example.com", or null
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
- **The query** reaches every published game, in every shell, with no
|
|
170
|
+
republish: it is in `location.search`, beside Genex's own keys. `getEntry()`
|
|
171
|
+
returns just your pairs, in order and byte for byte (`?film` stays `?film`,
|
|
172
|
+
`%20` stays `%20`). Up to 1024 characters, not counting the `?`; a longer
|
|
173
|
+
query is dropped whole, never cut.
|
|
174
|
+
- **The fragment and the referrer** need the build shipped on
|
|
175
|
+
`@genex-ai/embed-sdk` 0.32.0+. On the game's own link Genex hands them back
|
|
176
|
+
from the sign-in round trip only to a build that recorded that: it reads
|
|
177
|
+
the range in the game's own `package.json` as `npx genex preview` /
|
|
178
|
+
`npx genex publish` stored it for the build. `^0.32.0` counts; `^0.31.0`
|
|
179
|
+
does not, even when 0.32 is what got installed; `workspace:*` and `latest`
|
|
180
|
+
do not. The fragment carries up to 4096 characters. The referrer is the
|
|
181
|
+
ORIGIN of the site the visitor came from, never a path, and `null` when
|
|
182
|
+
there is none or when they came from Genex itself. It is kept for the tab,
|
|
183
|
+
so a reload answers the same; where the browser keeps no storage for the
|
|
184
|
+
game, a reload no longer names that site. Any link can claim any origin:
|
|
185
|
+
use it for attribution, never to grant a reward.
|
|
186
|
+
- **Reserved names.** `embed` and every key starting `genex_` belong to Genex,
|
|
187
|
+
in the query and in the fragment; a fragment that contains `genex_` is not
|
|
188
|
+
carried at all. On a link to the game's Genex page and in the Genex app
|
|
189
|
+
`ref`, `s`, `from`, `remix` and `slug` are Genex's too, and on the Genex
|
|
190
|
+
page so is the fragment `#credits`. Pick other names. `share({ params })`
|
|
191
|
+
leaves the reserved ones out and warns once in the console.
|
|
192
|
+
- **In the app** a query or a fragment holding a raw `@`, or a character that
|
|
193
|
+
is not plain URL text, is dropped, and the two together are capped at 1024
|
|
194
|
+
characters. Keep invite values to letters, digits, `-` and `_`.
|
|
195
|
+
- **A value a shell did not pass is `""` or `null`, never an error.** Keep the
|
|
196
|
+
second way in: an invite link next to a code the player can type.
|
|
197
|
+
- **A value from a link is text anyone can write.** Check its shape before you
|
|
198
|
+
use it.
|
|
199
|
+
|
|
200
|
+
## The frame, exactly
|
|
201
|
+
|
|
202
|
+
What the Genex page gives the frame a game runs in, by its attribute names:
|
|
203
|
+
|
|
204
|
+
- Sandbox: `allow-scripts` `allow-same-origin` `allow-pointer-lock` `allow-orientation-lock` `allow-forms`
|
|
205
|
+
- Every game, for its own address only: `fullscreen` `gamepad` `xr-spatial-tracking` `accelerometer` `gyroscope` `magnetometer` `web-share` `clipboard-write` `screen-wake-lock` `keyboard-map` `autoplay`
|
|
206
|
+
- Only a build that declared it: `microphone` `camera` `geolocation`
|
|
207
|
+
- No game: `clipboard-read` `display-capture` `midi` `payment` `publickey-credentials-get` `usb` `hid` `serial` `bluetooth` `allow-popups` `allow-top-navigation` `allow-modals` `allow-downloads`
|
|
@@ -214,21 +214,25 @@ pointer bucket in `$genex-threejs-camera-direction`), pressing Escape does two
|
|
|
214
214
|
**browser-reserved** things you CANNOT stop with `preventDefault`: it exits
|
|
215
215
|
fullscreen and releases pointer lock, and the player sees the window "shrink".
|
|
216
216
|
The real fix is the **Keyboard Lock API**, which routes Escape to your code
|
|
217
|
-
instead — Chromium only,
|
|
218
|
-
(the Deploy/Resume click), capture Escape
|
|
217
|
+
instead — Chromium only, only in fullscreen, and only on the game's own link.
|
|
218
|
+
Enter fullscreen on a gesture (the Deploy/Resume click), capture Escape where
|
|
219
|
+
the browser lets you, and own the pause:
|
|
219
220
|
|
|
220
221
|
```ts
|
|
222
|
+
import { setFullscreen } from "@genex-ai/embed-sdk"; // 0.32.0+
|
|
223
|
+
|
|
221
224
|
// call ONLY from a user gesture (the Deploy click, the Resume click) — never at boot
|
|
222
225
|
async function enterImmersive(): Promise<void> {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// Chromium + fullscreen only: deliver Escape to us
|
|
226
|
+
// FIRST, before any await: the click is what lets fullscreen happen. It
|
|
227
|
+
// resolves { ok: false, error } instead of throwing — no gesture, a shell
|
|
228
|
+
// with no fullscreen — and the pointer-lock path below still pauses.
|
|
229
|
+
await setFullscreen(true);
|
|
230
|
+
// Chromium + fullscreen + the game's own link only: deliver Escape to us
|
|
231
|
+
// instead of exiting fullscreen. Everywhere else this rejects or is absent.
|
|
228
232
|
try {
|
|
229
233
|
await (navigator as unknown as { keyboard?: { lock?: (k: string[]) => Promise<void> } })
|
|
230
234
|
.keyboard?.lock?.(["Escape"]);
|
|
231
|
-
} catch { /* Keyboard Lock
|
|
235
|
+
} catch { /* no Keyboard Lock here — fine */ }
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
document.addEventListener("keydown", (e) => {
|
|
@@ -263,10 +267,13 @@ document.addEventListener("keydown", (e) => {
|
|
|
263
267
|
it, Esc frees the cursor while the game keeps running. Keyboard-only games may
|
|
264
268
|
still skip Keyboard Lock + fullscreen; the lock + pause/resume path is the
|
|
265
269
|
non-negotiable part.
|
|
266
|
-
- **
|
|
267
|
-
|
|
268
|
-
the
|
|
269
|
-
|
|
270
|
+
- **On the game's Genex page there is no Keyboard Lock.** Browsers give it to
|
|
271
|
+
the top-level page only, and there the game is in a frame — so Escape leaves
|
|
272
|
+
fullscreen and frees the cursor, and the `pointerlockchange` → pause path
|
|
273
|
+
above is what pauses. That is the path most players are on; the lock is an
|
|
274
|
+
extra for the game's own link. `setFullscreen(true)` is the call for every
|
|
275
|
+
shell: what it does in each is in `$genex-threejs-embed-auth`
|
|
276
|
+
(`references/shells.md`).
|
|
270
277
|
|
|
271
278
|
A mouse-aim game earns its keep with a **look-sensitivity slider** in the pause
|
|
272
279
|
menu — trackpads feel slower than mice, so let the player tune it. On the bundled
|
|
@@ -296,8 +303,9 @@ canvas.requestPointerLock?.(); // hides the cursor, focuses the game
|
|
|
296
303
|
// more — no reticle, no aim code; the lock just parks the cursor.
|
|
297
304
|
```
|
|
298
305
|
|
|
299
|
-
If the lock is genuinely unavailable (a
|
|
300
|
-
`allow
|
|
306
|
+
If the lock is genuinely unavailable (a sandboxed embed without the
|
|
307
|
+
`allow-pointer-lock` sandbox token), fall back to hiding the idle cursor over
|
|
308
|
+
the canvas:
|
|
301
309
|
`canvas.style.cursor = "none"` after ~2s without `pointermove`, restored
|
|
302
310
|
instantly on move. Menus and pause screens always keep their cursor.
|
|
303
311
|
|
|
@@ -401,13 +401,39 @@ and friends join it; the lobby is persistent (rounds replay, nobody is evicted):
|
|
|
401
401
|
|
|
402
402
|
```ts
|
|
403
403
|
import { createPrivate, joinPrivate } from "@genex-ai/multiplayer";
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
404
|
+
import { share, getEntry } from "@genex-ai/embed-sdk"; // 0.32.0+
|
|
405
|
+
const opts = { urls, room: slug, auth: () => getColyseusAuth() };
|
|
406
|
+
|
|
407
|
+
// host
|
|
408
|
+
const lobby = await createPrivate<MyState>(opts); // live NOW
|
|
409
|
+
showCode(lobby.code); // always show the code too: the way in when a link will not do
|
|
410
|
+
inviteBtn.onclick = async () => { // share() starts inside the click, before any await
|
|
411
|
+
const { status, url } = await share({ text: "Join my lobby", params: { lobby: lobby.code } });
|
|
412
|
+
if (status === "copied") toast("Invite link copied");
|
|
413
|
+
if (status === "unavailable") showLink(url); // no share sheet, no clipboard: show the link
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
// friend: ONE way in, for the code from a link and for a code typed by hand
|
|
417
|
+
async function enter(code: string) {
|
|
418
|
+
if (!/^[\w-]{1,32}$/.test(code)) return null; // a link is text anyone can write: take a code, nothing else
|
|
419
|
+
try { return await joinPrivate<MyState>(code, opts); }
|
|
420
|
+
catch { showNotice("That lobby is no longer open"); return null; }
|
|
421
|
+
}
|
|
422
|
+
// at boot, after waitForPlayer(): did they arrive on an invite link?
|
|
423
|
+
const invited = getEntry().params.get("lobby");
|
|
424
|
+
const joined = invited ? await enter(invited) : null; // null → the menu, with its "Enter code" field
|
|
408
425
|
// same handle API as matchmake(): lobby.session, lobby.matchmaking, eliminated()/score()/finish(), cancel()
|
|
409
426
|
```
|
|
410
427
|
|
|
428
|
+
`share()` builds the link itself — the game's page on Genex plus `?lobby=<code>`, or the
|
|
429
|
+
address the game is on where that is a preview address or a creator's own domain — and
|
|
430
|
+
`getEntry()` hands the parameter back to whoever opens it. **Never build the invite from
|
|
431
|
+
`location.href`**: on the game's page that is the frame's bare address, not the page a friend
|
|
432
|
+
should land on. `lobby` is this example's name; any name works except the ones Genex keeps
|
|
433
|
+
(`$genex-threejs-embed-auth`, `references/shells.md`). `joinPrivate` REJECTS when the code leads
|
|
434
|
+
to no lobby this player can reach — it closed, or its host is on another region's relay — so
|
|
435
|
+
catch it and show the menu, and keep the typed code as the second way in.
|
|
436
|
+
|
|
411
437
|
## Connect
|
|
412
438
|
|
|
413
439
|
Pick your own per-player state shape (any JSON). `room` is the **project slug**
|
|
@@ -674,10 +700,22 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
674
700
|
mesh — everyone uploads to everyone, and reliability, not bandwidth, breaks first). The
|
|
675
701
|
room still holds 64 players; the 7th `join()` is refused, so handle `voice:rejected`
|
|
676
702
|
instead of retrying. `join()` MUST run inside a click/tap — browsers gate both the mic
|
|
677
|
-
prompt and audio playback on a user gesture — and it REJECTS when the
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
703
|
+
prompt and audio playback on a user gesture — and it REJECTS when the microphone is
|
|
704
|
+
refused, which is an ordinary outcome to show, not an error to swallow. **Two refusals are
|
|
705
|
+
not the player's.** On its Genex page a game gets the microphone only when its BUILD
|
|
706
|
+
declared it: `npx genex preview` / `npx genex publish` declare it when the game's own
|
|
707
|
+
source calls `.voice.join()` with nothing or with options written at the call, and print
|
|
708
|
+
what the build is given — read that line. `voice.join(opts)` with the options in a
|
|
709
|
+
variable is not seen: declare the microphone in `package.json` then. When the
|
|
710
|
+
page withheld it, `join()` rejects with an error whose `name` is `VoiceNotDelegatedError`
|
|
711
|
+
(SDK 0.16.2+, Chromium browsers; elsewhere it arrives as the browser's own error): show
|
|
712
|
+
"Voice chat is not available here", never "allow your microphone" — the player was never
|
|
713
|
+
asked. And the Genex app refuses the microphone for every game: hide the voice button when
|
|
714
|
+
`getShell()` from `@genex-ai/embed-sdk` is `"app"`. The declaration rule, and the
|
|
715
|
+
`package.json` line for a call the CLI cannot see, are in `$genex-threejs-embed-auth`
|
|
716
|
+
(`references/shells.md`).
|
|
717
|
+
For a 3D game use `npx genex controller voice` (proximity audio + push-to-talk); it joins
|
|
718
|
+
with `{ spatial: true }` and drives a `THREE.PositionalAudio` per speaker. **TURN is automatic
|
|
681
719
|
— do not pass `iceServers`**: `join()` fetches short-lived relay credentials from the
|
|
682
720
|
platform before opening any peer connection, which is what lets the ~15% of players behind
|
|
683
721
|
symmetric NAT or carrier CGNAT be heard at all (in a mesh they fail every leg, so they
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: genex-threejs-touch-controls
|
|
3
|
-
description: Make a Genex Three.js game playable on phones — vendored touch primitives via `npx genex controller touch` (floating/static joystick, virtual buttons, drag zone, rotate-device overlay) plus per-genre wiring recipes (movement, camera look, taps, paddles, action buttons, touch pause). Use when wiring default touch controls into a new game, whenever someone asks to make a game playable on mobile/phone/tablet, or before shipping a game whose input is keyboard/mouse-only.
|
|
3
|
+
description: Make a Genex Three.js game playable on phones — vendored touch primitives via `npx genex controller touch` (floating/static joystick, virtual buttons, drag zone, rotate-device overlay) plus per-genre wiring recipes (movement, camera look, taps, paddles, action buttons, touch pause, tilt). Use when wiring default touch controls into a new game, whenever someone asks to make a game playable on mobile/phone/tablet, or before shipping a game whose input is keyboard/mouse-only.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Genex Three.js Touch Controls
|
|
@@ -116,6 +116,68 @@ If the game is merely suboptimal sideways, skip the overlay and just resize.
|
|
|
116
116
|
The web cannot force an orientation (fullscreen-only lock, none on iOS Safari)
|
|
117
117
|
— the overlay asks; it never traps.
|
|
118
118
|
|
|
119
|
+
## Tilt controls
|
|
120
|
+
|
|
121
|
+
Steering by tilting the phone is an extra on top of the stick, never a
|
|
122
|
+
replacement for it: the sensor is the one input a phone can silently not have.
|
|
123
|
+
Four rules, all in the block below.
|
|
124
|
+
|
|
125
|
+
1. **Ask inside the Start tap.** An iPhone hands a page no motion events until
|
|
126
|
+
the player allows them, and it asks only when `requestPermission()` is
|
|
127
|
+
called from a tap inside the game. Called at boot it is refused without a
|
|
128
|
+
prompt.
|
|
129
|
+
2. **A refusal, or silence, is "no sensor".** A browser can answer "granted"
|
|
130
|
+
and then send nothing, and a desktop can send events full of `null`. Trust
|
|
131
|
+
tilt only after a real reading has arrived.
|
|
132
|
+
3. **Keep the joystick.** It stays on screen and wins whenever it is touched.
|
|
133
|
+
4. **Hold the screen awake.** Tilt play has no touches, so the phone dims and
|
|
134
|
+
locks mid-game unless the game asks it not to.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { keepAwake } from "@genex-ai/embed-sdk"; // 0.32.0+
|
|
138
|
+
|
|
139
|
+
const tilt = { x: 0, y: 0, live: false }; // x/y in [-1, 1], same sense as joy.x / joy.y
|
|
140
|
+
const TILT_FULL = 25; // degrees of lean for full deflection
|
|
141
|
+
let rest: { x: number; y: number } | null = null; // how the phone was held at the first reading
|
|
142
|
+
|
|
143
|
+
type MotionGate = { requestPermission?: () => Promise<"granted" | "denied"> };
|
|
144
|
+
|
|
145
|
+
// Call from the Start/Play tap handler, before any await in that handler.
|
|
146
|
+
async function enableTilt(): Promise<void> {
|
|
147
|
+
void keepAwake(true); // released with keepAwake(false) on the menu / pause
|
|
148
|
+
const gate = (window as unknown as { DeviceOrientationEvent?: MotionGate }).DeviceOrientationEvent;
|
|
149
|
+
if (!gate) return; // no sensor API: the joystick is the control
|
|
150
|
+
try {
|
|
151
|
+
if (typeof gate.requestPermission === "function" && (await gate.requestPermission()) !== "granted") return;
|
|
152
|
+
} catch { return; } // refused, or not inside a tap
|
|
153
|
+
window.addEventListener("deviceorientation", (e) => {
|
|
154
|
+
if (e.beta == null || e.gamma == null) return; // a desktop fires events with nulls
|
|
155
|
+
// The reading is in the DEVICE's axes; turn it into the screen's.
|
|
156
|
+
const a = screen.orientation?.angle ?? 0;
|
|
157
|
+
const x = a === 90 ? e.beta : a === 270 ? -e.beta : a === 180 ? -e.gamma : e.gamma;
|
|
158
|
+
const y = a === 90 ? -e.gamma : a === 270 ? e.gamma : a === 180 ? -e.beta : e.beta;
|
|
159
|
+
rest ??= { x, y }; // "level" is however the player was holding it
|
|
160
|
+
const unit = (v: number) => Math.max(-1, Math.min(1, v / TILT_FULL));
|
|
161
|
+
tilt.x = unit(x - rest.x);
|
|
162
|
+
tilt.y = -unit(y - rest.y); // leaning the top edge away from you = up, like the stick
|
|
163
|
+
tilt.live = true;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// each frame, where the stick is read:
|
|
168
|
+
const steer = joy.x !== 0 ? joy.x : tilt.live ? tilt.x : 0;
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Clear `rest` when the device rotates or the player taps a "recentre" button,
|
|
172
|
+
so level is re-taken from the next reading. **Check the directions on a real
|
|
173
|
+
phone, in the orientation the game is played in** — a desktop browser has no
|
|
174
|
+
sensor, so nothing in a local test exercises this. Lean-right must steer
|
|
175
|
+
screen-right (the screen-direction contract in
|
|
176
|
+
`$genex-threejs-camera-direction`); a flipped feel is a sign to fix in the
|
|
177
|
+
mapping above, never a device quirk. What each Genex shell does with the
|
|
178
|
+
sensors and the wake lock is in `$genex-threejs-embed-auth`
|
|
179
|
+
(`references/shells.md`).
|
|
180
|
+
|
|
119
181
|
## Match the game's style
|
|
120
182
|
|
|
121
183
|
The touch controls are UI like any other — restyle them from the same style
|