@genex-ai/cli-demo 0.50.0-dev.95 → 0.51.0-dev.101
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 +82 -3
- package/package.json +1 -1
- package/templates/skills/genex-ai-hud/SKILL.md +18 -0
- package/templates/skills/genex-ai-image/SKILL.md +12 -0
- package/templates/skills/genex-ai-texture/SKILL.md +22 -2
- package/templates/skills/genex-threejs-game-ui/SKILL.md +42 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +52 -6
- package/templates/skills/genex-threejs-multiplayer/references/genre-recipes.md +51 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +7 -5
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +13 -7
package/dist/index.js
CHANGED
|
@@ -3183,7 +3183,8 @@ var UI_NUMBER_FLAGS = {
|
|
|
3183
3183
|
"--edge-flush-max": "edgeFlushMax",
|
|
3184
3184
|
"--quantize": "quantize",
|
|
3185
3185
|
"--ink-dist": "inkDist",
|
|
3186
|
-
"--min-frac": "minFrac"
|
|
3186
|
+
"--min-frac": "minFrac",
|
|
3187
|
+
"--seam-tolerance": "seamTolerance"
|
|
3187
3188
|
};
|
|
3188
3189
|
var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
|
|
3189
3190
|
ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
|
|
@@ -3199,7 +3200,10 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
|
|
|
3199
3200
|
ink candidates as JSON. [--quantize 12] [--ink-dist 60]
|
|
3200
3201
|
[--min-frac 0.004] [--out <json>] [--crop <png>]
|
|
3201
3202
|
ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
|
|
3202
|
-
--in), print real dims + refresh the .bbox.json sidecar
|
|
3203
|
+
--in), print real dims + refresh the .bbox.json sidecar.
|
|
3204
|
+
ui seams measure a tiling texture's wrap seam (opposite edges vs
|
|
3205
|
+
interior detail) \u2192 ratio + verdict as JSON; nonzero exit on
|
|
3206
|
+
a visible seam. [--seam-tolerance 3]`;
|
|
3203
3207
|
async function runUi(opts) {
|
|
3204
3208
|
const log = createLogger({ quiet: opts.quiet });
|
|
3205
3209
|
try {
|
|
@@ -3216,9 +3220,12 @@ async function runUi(opts) {
|
|
|
3216
3220
|
case "trim":
|
|
3217
3221
|
await uiTrim(opts, log);
|
|
3218
3222
|
return;
|
|
3223
|
+
case "seams":
|
|
3224
|
+
await uiSeams(opts, log);
|
|
3225
|
+
return;
|
|
3219
3226
|
default:
|
|
3220
3227
|
log.error(
|
|
3221
|
-
opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim.` : "Missing ui tool. Tools: extract, masks, text-color, trim."
|
|
3228
|
+
opts.sub ? `Unknown ui tool "${opts.sub}". Tools: extract, masks, text-color, trim, seams.` : "Missing ui tool. Tools: extract, masks, text-color, trim, seams."
|
|
3222
3229
|
);
|
|
3223
3230
|
log.plain(UI_USAGE);
|
|
3224
3231
|
process.exitCode = 1;
|
|
@@ -3925,6 +3932,78 @@ async function uiTrim(opts, log) {
|
|
|
3925
3932
|
`
|
|
3926
3933
|
);
|
|
3927
3934
|
}
|
|
3935
|
+
async function uiSeams(opts, log) {
|
|
3936
|
+
const input = requireOpt(opts.input, "--in", "seams");
|
|
3937
|
+
const tol = opts.seamTolerance ?? 3;
|
|
3938
|
+
const png = await loadPng(input);
|
|
3939
|
+
const { width: w, height: h, data } = png;
|
|
3940
|
+
if (w < 4 || h < 4) fail("Image is too small to test for tiling.", { width: w, height: h });
|
|
3941
|
+
const chan = (x, y, ch) => data[(y * w + x) * 4 + ch] ?? 0;
|
|
3942
|
+
const colDiff = (a, b) => {
|
|
3943
|
+
let s = 0;
|
|
3944
|
+
for (let y = 0; y < h; y++) {
|
|
3945
|
+
s += Math.abs(chan(a, y, 0) - chan(b, y, 0));
|
|
3946
|
+
s += Math.abs(chan(a, y, 1) - chan(b, y, 1));
|
|
3947
|
+
s += Math.abs(chan(a, y, 2) - chan(b, y, 2));
|
|
3948
|
+
}
|
|
3949
|
+
return s / (h * 3);
|
|
3950
|
+
};
|
|
3951
|
+
const rowDiff = (a, b) => {
|
|
3952
|
+
let s = 0;
|
|
3953
|
+
for (let x = 0; x < w; x++) {
|
|
3954
|
+
s += Math.abs(chan(x, a, 0) - chan(x, b, 0));
|
|
3955
|
+
s += Math.abs(chan(x, a, 1) - chan(x, b, 1));
|
|
3956
|
+
s += Math.abs(chan(x, a, 2) - chan(x, b, 2));
|
|
3957
|
+
}
|
|
3958
|
+
return s / (w * 3);
|
|
3959
|
+
};
|
|
3960
|
+
const seamH = colDiff(0, w - 1);
|
|
3961
|
+
const seamV = rowDiff(0, h - 1);
|
|
3962
|
+
const stepX = Math.max(1, Math.floor(w / 64));
|
|
3963
|
+
const stepY = Math.max(1, Math.floor(h / 64));
|
|
3964
|
+
let baseH = 0;
|
|
3965
|
+
let nH = 0;
|
|
3966
|
+
for (let x = 1; x < w - 1; x += stepX) {
|
|
3967
|
+
baseH += colDiff(x, x + 1);
|
|
3968
|
+
nH++;
|
|
3969
|
+
}
|
|
3970
|
+
let baseV = 0;
|
|
3971
|
+
let nV = 0;
|
|
3972
|
+
for (let y = 1; y < h - 1; y += stepY) {
|
|
3973
|
+
baseV += rowDiff(y, y + 1);
|
|
3974
|
+
nV++;
|
|
3975
|
+
}
|
|
3976
|
+
baseH = Math.max(baseH / Math.max(1, nH), 1e-6);
|
|
3977
|
+
baseV = Math.max(baseV / Math.max(1, nV), 1e-6);
|
|
3978
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
3979
|
+
const ratioH = round(seamH / baseH);
|
|
3980
|
+
const ratioV = round(seamV / baseV);
|
|
3981
|
+
const worst = Math.max(ratioH, ratioV);
|
|
3982
|
+
const axis = ratioH >= ratioV ? "horizontal" : "vertical";
|
|
3983
|
+
const seamless = worst <= tol;
|
|
3984
|
+
const name = input.split("/").pop() ?? input;
|
|
3985
|
+
process.stdout.write(
|
|
3986
|
+
`${JSON.stringify({
|
|
3987
|
+
input,
|
|
3988
|
+
width: w,
|
|
3989
|
+
height: h,
|
|
3990
|
+
ratioHorizontal: ratioH,
|
|
3991
|
+
ratioVertical: ratioV,
|
|
3992
|
+
worstRatio: worst,
|
|
3993
|
+
worstAxis: axis,
|
|
3994
|
+
tolerance: tol,
|
|
3995
|
+
seamless
|
|
3996
|
+
})}
|
|
3997
|
+
`
|
|
3998
|
+
);
|
|
3999
|
+
if (seamless) {
|
|
4000
|
+
log.success(`${name}: tiles cleanly \u2014 worst seam ${worst}\xD7 the texture's own detail (\u2264 ${tol}\xD7).`);
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
fail(
|
|
4004
|
+
`Visible ${axis} tiling seam in ${name}: the tile boundary jumps ${worst}\xD7 the texture's own detail (want \u2264 ${tol}\xD7). Regenerate it seamless/tileable, or lower the mesh's UV repeat so the seam falls off-camera.`
|
|
4005
|
+
);
|
|
4006
|
+
}
|
|
3928
4007
|
|
|
3929
4008
|
// src/index.ts
|
|
3930
4009
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
|
package/package.json
CHANGED
|
@@ -114,6 +114,24 @@ The test: "is this fixed identity artwork, or should runtime code own it?"
|
|
|
114
114
|
Baking a runtime value into a sprite is a failure; stripping a carved identity
|
|
115
115
|
label flattens the art.
|
|
116
116
|
|
|
117
|
+
## Icons & mechanics must match the game — diff before Stage 2
|
|
118
|
+
|
|
119
|
+
Words are only half of it. Every ICON, slot, gauge, and pictogram in the mockup
|
|
120
|
+
implies a MECHANIC: a grenade icon promises grenades, three ability slots
|
|
121
|
+
promise three abilities, an armor bar promises armor. Before Stage 2, walk each
|
|
122
|
+
one against the game contract — the mechanics the code actually has (or that you
|
|
123
|
+
are committing to build):
|
|
124
|
+
|
|
125
|
+
- **Backed by a real mechanic** → keep it and wire it to that state.
|
|
126
|
+
- **No backing mechanic** → it is a promise the player will notice is empty.
|
|
127
|
+
CUT it (prompt Stage 2, or a region re-edit, to omit that icon/slot) OR add
|
|
128
|
+
the mechanic to the game. Shipping a depicted control the game can't do — a
|
|
129
|
+
grenade slot on a rifle-only HUD, an armor bar with no armor, a minimap over
|
|
130
|
+
no map — is a failure, and "the model drew it so I kept it" is how it happens.
|
|
131
|
+
|
|
132
|
+
The mockup (and any concept frame) anchors STYLE; the game contract owns WHAT
|
|
133
|
+
EXISTS. When the picture and the mechanics disagree, the mechanics win.
|
|
134
|
+
|
|
117
135
|
## The pipeline
|
|
118
136
|
|
|
119
137
|
Each step is one command; every `<...-url>` is the R2 URL the previous command
|
|
@@ -129,6 +129,18 @@ Gotchas (all bite in practice):
|
|
|
129
129
|
moving or skinned target, use a small `PlaneGeometry` quad offset along the normal
|
|
130
130
|
(`hit.point + n * 0.01`, `quad.lookAt(hit.point.clone().add(n))`) instead.
|
|
131
131
|
|
|
132
|
+
### Impact marks (bullet holes, scorch, blood) — spawn on the hit, not a keypress
|
|
133
|
+
|
|
134
|
+
A game that shoots, throws, or explodes and leaves NO mark on what it hits reads
|
|
135
|
+
as unfinished — and it's the same recipe, driven by the weapon's raycast you
|
|
136
|
+
already have. Generate ONE small `--transparent` bullet-hole / scorch / impact
|
|
137
|
+
image, then on each confirmed **world** hit place a decal at `hit.point` with
|
|
138
|
+
`hit.face.normal` (exactly the `spray()` body above), capped in a FIFO ring
|
|
139
|
+
(~30–50) reusing one shared material. On enemy/skinned hits skip the decal and
|
|
140
|
+
use the quad fallback or a hit-spark VFX instead. Impact marks are a first-class
|
|
141
|
+
generated surface for any weapon/collision game — inventory them up front with
|
|
142
|
+
the rest of your art, don't discover the bare walls at the end.
|
|
143
|
+
|
|
132
144
|
## Multiplayer
|
|
133
145
|
|
|
134
146
|
The asset URL is public, permanent, and CORS-open, so it is safe to broadcast the
|
|
@@ -67,6 +67,24 @@ map.repeat.set(64, 64);
|
|
|
67
67
|
scene.add(ground);
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
## Verify it actually tiles — don't eyeball it
|
|
71
|
+
|
|
72
|
+
A texture that isn't truly seamless draws a repeating seam grid the moment
|
|
73
|
+
`repeat` is raised — the single most common "asset-flip" tell, and `--terrain`
|
|
74
|
+
asks for seamless but does not guarantee it. So measure the seam, don't guess:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
npx genex ui seams --in <texture-url> # a local .png works too
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
It compares the texture's opposite edges against its own interior detail and
|
|
81
|
+
prints a ratio + verdict (`seamless: false` and a nonzero exit on a visible
|
|
82
|
+
seam). A fail is a real defect — fix it in this order: **regenerate** (add
|
|
83
|
+
"seamless tiling, no visible edges" to the prompt, or `--terrain`), then **lower
|
|
84
|
+
`repeat`** so any residual seam falls off-camera. Run this on every tiled
|
|
85
|
+
floor/wall texture before you call the scene done — a large flat surface with a
|
|
86
|
+
high `repeat` and an unchecked texture is exactly how seams reach the player.
|
|
87
|
+
|
|
70
88
|
## Publish checklist
|
|
71
89
|
|
|
72
90
|
- Load it from the **URL** the command printed — absolute and permanent, so it resolves
|
|
@@ -92,6 +110,8 @@ scene.add(ground);
|
|
|
92
110
|
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
93
111
|
unlock after the account's email is verified. Give the user the verify link the
|
|
94
112
|
CLI printed, wait for them to confirm, then re-run the command.
|
|
95
|
-
- **Visible tiling seams** —
|
|
96
|
-
|
|
113
|
+
- **Visible tiling seams** — measure first (`npx genex ui seams --in <url>`): a
|
|
114
|
+
high ratio is a defect to fix, not a limitation to accept. Regenerate with
|
|
115
|
+
`--terrain` / a "seamless tiling" prompt, lower the `repeat`, or blend two
|
|
116
|
+
textures until the check passes.
|
|
97
117
|
- **Colors look washed/dark** — ensure `map.colorSpace = THREE.SRGBColorSpace`.
|
|
@@ -195,6 +195,48 @@ edits scattered through the code:
|
|
|
195
195
|
HUD state come from `$genex-threejs-multiplayer`; render what the SDK
|
|
196
196
|
reports, don't guess at it.
|
|
197
197
|
|
|
198
|
+
## Escape must pause the game — not shrink the window
|
|
199
|
+
|
|
200
|
+
For a game that captures the mouse (first-person or pointer-lock aim — see the
|
|
201
|
+
pointer bucket in `$genex-threejs-camera-direction`), pressing Escape does two
|
|
202
|
+
**browser-reserved** things you CANNOT stop with `preventDefault`: it exits
|
|
203
|
+
fullscreen and releases pointer lock, and the player sees the window "shrink".
|
|
204
|
+
The real fix is the **Keyboard Lock API**, which routes Escape to your code
|
|
205
|
+
instead — Chromium only, and only in fullscreen. Enter fullscreen on a gesture
|
|
206
|
+
(the Deploy/Resume click), capture Escape, and own the pause:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
// call ONLY from a user gesture (the Deploy click, the Resume click) — never at boot
|
|
210
|
+
async function enterImmersive(): Promise<void> {
|
|
211
|
+
try {
|
|
212
|
+
if (!document.fullscreenElement)
|
|
213
|
+
await document.documentElement.requestFullscreen({ navigationUI: "hide" });
|
|
214
|
+
} catch { /* sandboxed iframe or no gesture — the pointer-lock path below still pauses */ }
|
|
215
|
+
// Chromium + fullscreen only: deliver Escape to us instead of exiting fullscreen.
|
|
216
|
+
try {
|
|
217
|
+
await (navigator as unknown as { keyboard?: { lock?: (k: string[]) => Promise<void> } })
|
|
218
|
+
.keyboard?.lock?.(["Escape"]);
|
|
219
|
+
} catch { /* Keyboard Lock unsupported — fine */ }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
document.addEventListener("keydown", (e) => {
|
|
223
|
+
if (e.code !== "Escape") return;
|
|
224
|
+
e.preventDefault(); // stops any *other* default; the reserved ones the lock handles
|
|
225
|
+
if (phase === "playing") { document.exitPointerLock?.(); setPhase("paused"); } // free cursor for the menu
|
|
226
|
+
else if (phase === "paused") { void enterImmersive(); canvas.requestPointerLock?.(); } // Esc resumes
|
|
227
|
+
});
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
- **Degradation is built in.** Where Keyboard Lock is absent (Safari, Firefox) or
|
|
231
|
+
the game isn't fullscreen, the browser still releases pointer lock on Escape —
|
|
232
|
+
so ALSO keep the pointer-lock-loss → pause path (`pointerlockchange`: if
|
|
233
|
+
unlocked while `playing`, `setPhase("paused")`). Escape then always pauses; it
|
|
234
|
+
only *also* drops fullscreen on browsers without the lock — unavoidable there.
|
|
235
|
+
- **Only for immersive/pointer-lock games.** A top-down or menu-driven game never
|
|
236
|
+
captures the mouse and has no window to shrink — skip all of this.
|
|
237
|
+
- **Dashboard embed:** full coverage needs the game iframe to permit keyboard
|
|
238
|
+
lock; standalone play (`<slug>.genex.technology`) works today regardless.
|
|
239
|
+
|
|
198
240
|
## The loader
|
|
199
241
|
|
|
200
242
|
The loader is the first thing every player sees — a bare "Loading… 3/5" over
|
|
@@ -86,6 +86,46 @@ Everything is **server-owned** — set once in `package.json` under `genex.match
|
|
|
86
86
|
next preview/publish); the client declares nothing. You never run matchmaking logic: the server
|
|
87
87
|
owns the queue, roles, winner-stays, forfeit, timeout, and the win condition.
|
|
88
88
|
|
|
89
|
+
### WHEN to call `matchmake()` — it IS the "Play Online" button, never a boot call (MANDATORY)
|
|
90
|
+
|
|
91
|
+
`matchmake()` is the ONE action that puts a player on the server: calling it enters the queue and
|
|
92
|
+
the server seats them into a room. So call it **only when the player commits to online play** —
|
|
93
|
+
the click on "Play Online" / "Find Match" — and NEVER on page load, in your boot code, or next to
|
|
94
|
+
`waitForPlayer()`. Getting this wrong is the seat-contamination bug: a player who loaded the page,
|
|
95
|
+
saw the menu, and started a **bots/local** game is STILL sitting in an online room counting toward
|
|
96
|
+
`minPlayers` — three real players wait forever for a fourth who is off fighting bots.
|
|
97
|
+
|
|
98
|
+
**The menu is pre-multiplayer — the player is in NO room and the server does not know they exist.**
|
|
99
|
+
Concretely, for the near-universal "Play Online / Local / Bots" title menu:
|
|
100
|
+
|
|
101
|
+
- **On page load:** boot the menu over an **offline world** generated locally. Do NOT `connect()`,
|
|
102
|
+
do NOT `matchmake()`. `await waitForPlayer()` MAY run here — that only mints an identity token, it
|
|
103
|
+
seats nobody — but nothing touches the relay yet. No room, no queue, no roster entry, no network.
|
|
104
|
+
- **"Bots" / "Local" / "Single-player":** run entirely offline. Never call `matchmake()` or
|
|
105
|
+
`connect()`. The player is a party of one against local AI; the server is never involved.
|
|
106
|
+
- **"Play Online":** NOW call `matchmake()`. This is the first and only relay contact. The player
|
|
107
|
+
enters the queue (`status: 'searching'`), the server seats them into a room, and only from this
|
|
108
|
+
moment are they a counted participant.
|
|
109
|
+
|
|
110
|
+
**The waiting screen belongs to online play, not to the menu.** Show it only AFTER the player
|
|
111
|
+
pressed "Play Online" (so `matchmake()` was called and they hold a seat) AND the room hasn't reached
|
|
112
|
+
`minPlayers` yet (`mm.matchmaking.status === 'waiting'`). A player still on the menu sees no waiting
|
|
113
|
+
screen and no player count — they are in no room, so there is genuinely nothing to show, and there
|
|
114
|
+
is no way for them to know how many others are waiting. The count becomes visible the instant they
|
|
115
|
+
commit and get seated, never before.
|
|
116
|
+
|
|
117
|
+
**Symmetric rule — leaving online play calls `mm.cancel()`.** Entering online is `matchmake()`;
|
|
118
|
+
LEAVING it (back to menu, quit, switching to a bots/local game after being seated) MUST call
|
|
119
|
+
`mm.cancel()`. Otherwise the player keeps their seat and keeps counting toward `minPlayers` for
|
|
120
|
+
everyone else — the same contamination from the other side. One rule, both directions: **commit to
|
|
121
|
+
online → `matchmake()`; leave online → `cancel()`.**
|
|
122
|
+
|
|
123
|
+
This costs the server nothing and adds NO cheat surface: the client only ever decided *whether/when*
|
|
124
|
+
to search (a player can always just not play). Seating, the roster, capacity, and adjudication stay
|
|
125
|
+
server-authoritative — a modified client still cannot fake participation, inflate the roster, or
|
|
126
|
+
force `minPlayers`. Full menu wiring is Recipe 5 in
|
|
127
|
+
[references/genre-recipes.md](references/genre-recipes.md).
|
|
128
|
+
|
|
89
129
|
```jsonc
|
|
90
130
|
"genex": {
|
|
91
131
|
"matchmaking": {
|
|
@@ -197,9 +237,11 @@ team score) is Recipe 4 in [references/genre-recipes.md](references/genre-recipe
|
|
|
197
237
|
|
|
198
238
|
#### Waiting room / lobby — two patterns
|
|
199
239
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
240
|
+
The waiting room exists only AFTER the player pressed "Play Online" and got seated (see "WHEN to call
|
|
241
|
+
`matchmake()`" above — the menu is pre-multiplayer and shows no waiting screen). Once seated, `open`
|
|
242
|
+
puts you in a LIVE shared room the moment you're matched (`session` goes live, players sync) but
|
|
243
|
+
doesn't "start" anything — so the pre-game lobby is simply **your room before it's grown to the size
|
|
244
|
+
you want**. Set `minPlayers` to your target: the SERVER flips `mm.matchmaking.status` from
|
|
203
245
|
`'waiting'` to `'playing'` the instant the roster reaches it. The lobby and the game are ONE `open`
|
|
204
246
|
room — never spin up a second room for it.
|
|
205
247
|
|
|
@@ -616,9 +658,13 @@ host-driven saving works as long as ANY account is in the room.
|
|
|
616
658
|
- [ ] Hit-tests and discrete values read from `stateRaw`, not `state`.
|
|
617
659
|
- [ ] A ball / shared NPC is on `objects` (claim on contact), never on `shared`.
|
|
618
660
|
- [ ] `shared` scores/rounds and host-simulated enemies are written only by `room.isHost`.
|
|
619
|
-
- [ ]
|
|
620
|
-
|
|
621
|
-
`
|
|
661
|
+
- [ ] `matchmake()` fires ONLY on the "Play Online" commit, never on page load / in boot code — the
|
|
662
|
+
menu runs an offline world with NO relay contact; "Bots"/"Local" never call it; leaving online
|
|
663
|
+
calls `mm.cancel()`.
|
|
664
|
+
- [ ] Waiting room (if any): shown only AFTER "Play Online" (seated) and while under `minPlayers`;
|
|
665
|
+
overlay driven by `mm.matchmaking.status` read every frame, gone the moment it flips to
|
|
666
|
+
`'playing'` — and you WATCHED it close in two browser windows at `minPlayers` (never gated on
|
|
667
|
+
`matchStart` or a host `shared` signal alone).
|
|
622
668
|
- [ ] Team game: the HOST reconciles the balanced `id → team` map into `shared` (leavers dropped,
|
|
623
669
|
newcomers to the smallest team); every client READS its team from `shared` — never computed
|
|
624
670
|
per-client, never `mm.matchmaking.teams`, never a default for the unassigned — and you
|
|
@@ -174,6 +174,57 @@ the short-handed side.
|
|
|
174
174
|
|
|
175
175
|
---
|
|
176
176
|
|
|
177
|
+
## Recipe 5 — Title menu with Online / Local / Bots (the seat-contamination fix)
|
|
178
|
+
|
|
179
|
+
Almost every game opens on a menu: **Play Online**, **Local / Single-player**, **Bots**. The rule
|
|
180
|
+
that keeps online matches clean: **the menu is pre-multiplayer — there is no room, no queue, no
|
|
181
|
+
server contact until the player commits to online.** `matchmake()` IS the "Play Online" button. Call
|
|
182
|
+
it anywhere earlier (page load, boot code, beside `waitForPlayer()`) and a player who picks Bots is
|
|
183
|
+
still parked in an online room, counting toward `minPlayers` while three real players wait for a
|
|
184
|
+
fourth who never comes.
|
|
185
|
+
|
|
186
|
+
| Moment | What runs | Relay contact |
|
|
187
|
+
| --- | --- | --- |
|
|
188
|
+
| Page load → menu | An **offline world** generated locally, menu overlay on top | NONE. Not `connect()`, not `matchmake()`. `waitForPlayer()` may run (mints identity only, seats nobody). |
|
|
189
|
+
| "Bots" / "Local" | The same offline world + local AI / single-player | NONE, ever. |
|
|
190
|
+
| "Play Online" | `matchmake()` → queue → the server seats you | FIRST contact. Only now are you a counted participant. |
|
|
191
|
+
| Leaving online (back to menu / quit / switch to Bots after being seated) | Tear down the online view, return to the offline menu | `mm.cancel()` — frees the seat so you stop counting toward `minPlayers`. |
|
|
192
|
+
|
|
193
|
+
**Decisions:**
|
|
194
|
+
- **`matchmake()` is created lazily, on the click — not held from boot.** Keep the handle in a
|
|
195
|
+
variable so you can `cancel()` it; create it inside the "Play Online" handler, not at module load.
|
|
196
|
+
- **Bots/Local touch nothing networked.** They run the exact offline world the menu already booted.
|
|
197
|
+
A player can sit in Bots forever and the online queue never knows they exist — which is the point.
|
|
198
|
+
- **The waiting screen is an online-only, post-commit overlay.** Show it only when
|
|
199
|
+
`mm.matchmaking.status === 'waiting'` (seated, under `minPlayers`). On the menu there is no room,
|
|
200
|
+
so there is nothing to show and no count to know — never render a "0 players waiting" teaser there.
|
|
201
|
+
- **Leaving online is `mm.cancel()`, always.** Back-to-menu button, browser-quit
|
|
202
|
+
(`visibilitychange`/`pagehide` if you want promptness), or choosing Bots after having been seated —
|
|
203
|
+
each calls `cancel()`. Skipping it re-creates the contamination from the other side: a squatted
|
|
204
|
+
seat that never frees.
|
|
205
|
+
- **No new cheat surface.** The client only ever chose *when* to search — a player can always just
|
|
206
|
+
not play. Seating, the roster, capacity, and adjudication remain server-authoritative, so a
|
|
207
|
+
modified client still cannot fake participation or force the `minPlayers` gate.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
let mm = null; // no relay contact yet — we're on the menu
|
|
211
|
+
bootOfflineWorld(); // local world under the menu overlay
|
|
212
|
+
await waitForPlayer(); // identity token only; seats nobody
|
|
213
|
+
|
|
214
|
+
onClick("play-online", async () => {
|
|
215
|
+
mm = await matchmake({ urls, room: slug, auth: () => getColyseusAuth() });
|
|
216
|
+
showWaitingOverlay(); // driven by mm.matchmaking.status, per SKILL.md
|
|
217
|
+
});
|
|
218
|
+
onClick("play-bots", () => startBots()); // offline; mm stays null
|
|
219
|
+
onClick("leave-online", () => { mm?.cancel(); mm = null; returnToMenu(); });
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Acceptance feel:** a player who picks Bots never appears in anyone's online room; the online
|
|
223
|
+
waiting count reflects exactly the people who pressed Play Online; a player who backs out of online
|
|
224
|
+
frees their seat immediately, so a 3/4 lobby doesn't hang on a ghost.
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
|
|
177
228
|
## Not sure which? Start from the table
|
|
178
229
|
|
|
179
230
|
Whatever the genre, ask per thing: *is it one player's own state* (`me.set`) *· a moving thing
|
|
@@ -67,11 +67,13 @@ direction in the same plan block — the camera rig + pointer bucket
|
|
|
67
67
|
(`$genex-threejs-camera-direction`), the renderer baseline (tone mapping,
|
|
68
68
|
exposure, output color space — set deliberately at boot; stock three.js
|
|
69
69
|
defaults are not a look, `$genex-threejs-exposure-color-grading`), the post
|
|
70
|
-
stack this game ships
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
stack this game ships — read off the concept frame's OWN look (the grade, bloom
|
|
71
|
+
level, haze, grain it already shows) and which post the 2–3 AAA references lean
|
|
72
|
+
on, not a default single bloom; ONE built render-pass effect is the FLOOR
|
|
73
|
+
against no-post for EVERY game, never the target — ship the richness the concept
|
|
74
|
+
implies (the UI vignette div or a CSS canvas filter does not count;
|
|
75
|
+
`$genex-threejs-image-pipeline` owns ordering when 2+ compose), one named
|
|
76
|
+
**ambient-motion loop** that keeps
|
|
75
77
|
the scene alive at rest (emissive pulse, shimmer, drifting dust — shader
|
|
76
78
|
work, zero generations), and the lighting/atmosphere mood from that
|
|
77
79
|
same brief. Planning is not building — effects still land last in the
|
|
@@ -41,13 +41,19 @@ Three.js release or branch, and do not blindly copy demo architecture.
|
|
|
41
41
|
- **renderer baseline**: tone mapping, exposure, and output color space set
|
|
42
42
|
deliberately at boot (`$genex-threejs-exposure-color-grading` owns the
|
|
43
43
|
staging — stock three.js defaults are not a look);
|
|
44
|
-
- **the post stack**:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
- **the post stack**: read the look off your evidence — don't default to a
|
|
45
|
+
single bloom. TWO sources drive it: (a) the CONCEPT FRAME — a rendered
|
|
46
|
+
image that already carries a grade, a bloom level, haze/DoF, maybe grain or
|
|
47
|
+
aberration; name what it actually shows and reproduce THAT; (b) the 2–3 AAA
|
|
48
|
+
references — name which post each one leans on (racing: motion blur + heat
|
|
49
|
+
haze; grounded shooter: restrained bloom + film grain + faint aberration;
|
|
50
|
+
clean sci-fi: crisp bloom + strong grade). Ship the stack that evidence
|
|
51
|
+
calls for, each effect named and justified. ONE scene-serving render-pass
|
|
52
|
+
effect (bloom, AO, or a LUT/shader grade — a UI vignette div or a CSS
|
|
53
|
+
canvas filter does NOT count) is the FLOOR against "no post", never the
|
|
54
|
+
target: match the concept's richness, don't stop at one token pass. "No
|
|
55
|
+
post at all" is the stock default, not a plan, and "it's only a draft" is
|
|
56
|
+
not a lower floor. When 2+ effects compose, `$genex-threejs-image-pipeline`
|
|
51
57
|
owns the pass ordering;
|
|
52
58
|
- **references**: name 2–3 AAA games whose look this game borrows
|
|
53
59
|
(conventions, lighting mood, palette, post — never trade dress); the
|