@genex-ai/cli-demo 0.51.0-dev.98 → 0.52.0-dev.111
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 +186 -19
- package/package.json +2 -2
- 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 +37 -2
- package/templates/skills/genex-threejs-game-ui/SKILL.md +61 -7
- package/templates/skills/genex-threejs-skill-router/SKILL.md +15 -5
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +28 -7
package/dist/index.js
CHANGED
|
@@ -1652,7 +1652,13 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1652
1652
|
async function pushSource(cwd, ctx, log) {
|
|
1653
1653
|
const target = await fetchPushUrl(ctx, log);
|
|
1654
1654
|
if (!target) return false;
|
|
1655
|
-
|
|
1655
|
+
if (await pushWorktree(cwd, target.pushUrl, target.managed, log)) return true;
|
|
1656
|
+
if (!target.managed) return false;
|
|
1657
|
+
log.info("Retrying the source push\u2026");
|
|
1658
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1659
|
+
const fresh = await fetchPushUrl(ctx, log);
|
|
1660
|
+
if (!fresh) return false;
|
|
1661
|
+
return pushWorktree(cwd, fresh.pushUrl, fresh.managed, log);
|
|
1656
1662
|
}
|
|
1657
1663
|
async function pushWorktree(cwd, pushUrl, managed, log) {
|
|
1658
1664
|
const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
@@ -2119,6 +2125,104 @@ async function runPreview(opts) {
|
|
|
2119
2125
|
}
|
|
2120
2126
|
}
|
|
2121
2127
|
|
|
2128
|
+
// src/commands/generate.ts
|
|
2129
|
+
import { PNG } from "pngjs";
|
|
2130
|
+
|
|
2131
|
+
// src/lib/open.ts
|
|
2132
|
+
import { spawn as spawn4 } from "child_process";
|
|
2133
|
+
function tokenize(cmd) {
|
|
2134
|
+
return cmd.trim().split(/\s+/).filter(Boolean);
|
|
2135
|
+
}
|
|
2136
|
+
function openUrl(url) {
|
|
2137
|
+
let command;
|
|
2138
|
+
let args;
|
|
2139
|
+
const custom = (process.env.GENEX_BROWSER || process.env.BROWSER)?.trim();
|
|
2140
|
+
const customParts = custom ? tokenize(custom) : [];
|
|
2141
|
+
if (customParts.length > 0) {
|
|
2142
|
+
command = customParts[0];
|
|
2143
|
+
args = [...customParts.slice(1), url];
|
|
2144
|
+
} else {
|
|
2145
|
+
switch (process.platform) {
|
|
2146
|
+
case "darwin":
|
|
2147
|
+
command = "open";
|
|
2148
|
+
args = [url];
|
|
2149
|
+
break;
|
|
2150
|
+
case "win32":
|
|
2151
|
+
command = "cmd";
|
|
2152
|
+
args = ["/c", "start", "", url];
|
|
2153
|
+
break;
|
|
2154
|
+
default:
|
|
2155
|
+
command = "xdg-open";
|
|
2156
|
+
args = [url];
|
|
2157
|
+
break;
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
try {
|
|
2161
|
+
const child = spawn4(command, args, { stdio: "ignore", detached: true });
|
|
2162
|
+
child.on("error", () => {
|
|
2163
|
+
});
|
|
2164
|
+
child.unref();
|
|
2165
|
+
} catch {
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
// src/lib/seams.ts
|
|
2170
|
+
var DEFAULT_SEAM_TOLERANCE = 3;
|
|
2171
|
+
function measureSeam(png, tolerance = DEFAULT_SEAM_TOLERANCE) {
|
|
2172
|
+
const { width: w, height: h, data } = png;
|
|
2173
|
+
const chan = (x, y, ch) => data[(y * w + x) * 4 + ch] ?? 0;
|
|
2174
|
+
const colDiff = (a, b) => {
|
|
2175
|
+
let s = 0;
|
|
2176
|
+
for (let y = 0; y < h; y++) {
|
|
2177
|
+
s += Math.abs(chan(a, y, 0) - chan(b, y, 0));
|
|
2178
|
+
s += Math.abs(chan(a, y, 1) - chan(b, y, 1));
|
|
2179
|
+
s += Math.abs(chan(a, y, 2) - chan(b, y, 2));
|
|
2180
|
+
}
|
|
2181
|
+
return s / (h * 3);
|
|
2182
|
+
};
|
|
2183
|
+
const rowDiff = (a, b) => {
|
|
2184
|
+
let s = 0;
|
|
2185
|
+
for (let x = 0; x < w; x++) {
|
|
2186
|
+
s += Math.abs(chan(x, a, 0) - chan(x, b, 0));
|
|
2187
|
+
s += Math.abs(chan(x, a, 1) - chan(x, b, 1));
|
|
2188
|
+
s += Math.abs(chan(x, a, 2) - chan(x, b, 2));
|
|
2189
|
+
}
|
|
2190
|
+
return s / (w * 3);
|
|
2191
|
+
};
|
|
2192
|
+
const seamH = colDiff(0, w - 1);
|
|
2193
|
+
const seamV = rowDiff(0, h - 1);
|
|
2194
|
+
const stepX = Math.max(1, Math.floor(w / 64));
|
|
2195
|
+
const stepY = Math.max(1, Math.floor(h / 64));
|
|
2196
|
+
let baseH = 0;
|
|
2197
|
+
let nH = 0;
|
|
2198
|
+
for (let x = 1; x < w - 1; x += stepX) {
|
|
2199
|
+
baseH += colDiff(x, x + 1);
|
|
2200
|
+
nH++;
|
|
2201
|
+
}
|
|
2202
|
+
let baseV = 0;
|
|
2203
|
+
let nV = 0;
|
|
2204
|
+
for (let y = 1; y < h - 1; y += stepY) {
|
|
2205
|
+
baseV += rowDiff(y, y + 1);
|
|
2206
|
+
nV++;
|
|
2207
|
+
}
|
|
2208
|
+
baseH = Math.max(baseH / Math.max(1, nH), 1e-6);
|
|
2209
|
+
baseV = Math.max(baseV / Math.max(1, nV), 1e-6);
|
|
2210
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
2211
|
+
const ratioHorizontal = round(seamH / baseH);
|
|
2212
|
+
const ratioVertical = round(seamV / baseV);
|
|
2213
|
+
const worstRatio = Math.max(ratioHorizontal, ratioVertical);
|
|
2214
|
+
return {
|
|
2215
|
+
width: w,
|
|
2216
|
+
height: h,
|
|
2217
|
+
ratioHorizontal,
|
|
2218
|
+
ratioVertical,
|
|
2219
|
+
worstRatio,
|
|
2220
|
+
worstAxis: ratioHorizontal >= ratioVertical ? "horizontal" : "vertical",
|
|
2221
|
+
tolerance,
|
|
2222
|
+
seamless: worstRatio <= tolerance
|
|
2223
|
+
};
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2122
2226
|
// src/lib/sse.ts
|
|
2123
2227
|
async function* readSSE(body) {
|
|
2124
2228
|
const decoder = new TextDecoder();
|
|
@@ -2255,9 +2359,9 @@ async function runGenerate(kind, opts) {
|
|
|
2255
2359
|
log.dim(" (Re-running the generate command would start \u2014 and bill \u2014 a NEW generation.)");
|
|
2256
2360
|
return;
|
|
2257
2361
|
}
|
|
2258
|
-
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2362
|
+
await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
|
|
2259
2363
|
}
|
|
2260
|
-
async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
2364
|
+
async function awaitAndReport(apiUrl, token, id, kind, log, open = false) {
|
|
2261
2365
|
log.step("Generating\u2026 (this can take up to a minute)");
|
|
2262
2366
|
const onProgress = (p) => log.dim(` ${p}%`);
|
|
2263
2367
|
const timeoutMs = waitTimeoutFor(kind);
|
|
@@ -2269,9 +2373,9 @@ async function awaitAndReport(apiUrl, token, id, kind, log) {
|
|
|
2269
2373
|
process.exitCode = 1;
|
|
2270
2374
|
return;
|
|
2271
2375
|
}
|
|
2272
|
-
await reportTerminal(kind, view, log);
|
|
2376
|
+
await reportTerminal(kind, view, log, open);
|
|
2273
2377
|
}
|
|
2274
|
-
async function reportTerminal(kind, view, log) {
|
|
2378
|
+
async function reportTerminal(kind, view, log, open = false) {
|
|
2275
2379
|
if (view.status !== "completed") {
|
|
2276
2380
|
log.error(`Generation ${view.status}${view.error ? `: ${view.error}` : ""}.`);
|
|
2277
2381
|
process.exitCode = 1;
|
|
@@ -2290,6 +2394,15 @@ async function reportTerminal(kind, view, log) {
|
|
|
2290
2394
|
log.plain(` ${label}${f.url}`);
|
|
2291
2395
|
}
|
|
2292
2396
|
log.plain("");
|
|
2397
|
+
if (kind === "texture" && files[0]) await reportTextureSeam(files[0].url, log);
|
|
2398
|
+
if (open && files[0]) {
|
|
2399
|
+
openUrl(files[0].url);
|
|
2400
|
+
log.plain(
|
|
2401
|
+
` ${c.bold("\u{1F441} Show this to the user")} \u2014 it just opened in their browser; paste this link in chat too so they can open it themselves:`
|
|
2402
|
+
);
|
|
2403
|
+
log.plain(` ${files[0].url}`);
|
|
2404
|
+
log.plain("");
|
|
2405
|
+
}
|
|
2293
2406
|
printHint(kind, files, log);
|
|
2294
2407
|
await firstPreviewNudge(log);
|
|
2295
2408
|
}
|
|
@@ -2375,6 +2488,27 @@ async function poll(apiUrl, token, id, onProgress, timeoutMs) {
|
|
|
2375
2488
|
}
|
|
2376
2489
|
return null;
|
|
2377
2490
|
}
|
|
2491
|
+
async function reportTextureSeam(url, log) {
|
|
2492
|
+
try {
|
|
2493
|
+
const res = await fetch(url);
|
|
2494
|
+
if (!res.ok) return;
|
|
2495
|
+
const png = PNG.sync.read(Buffer.from(await res.arrayBuffer()));
|
|
2496
|
+
if (png.width < 4 || png.height < 4) return;
|
|
2497
|
+
const r = measureSeam(png);
|
|
2498
|
+
if (r.seamless) {
|
|
2499
|
+
log.dim(` Tiling check: seam ${r.worstRatio}\xD7 this texture's own detail \u2014 tiles cleanly.`);
|
|
2500
|
+
return;
|
|
2501
|
+
}
|
|
2502
|
+
log.error(
|
|
2503
|
+
`Visible ${r.worstAxis} tiling seam \u2014 the tile boundary jumps ${r.worstRatio}\xD7 this texture's own detail (want \u2264 ${r.tolerance}\xD7).`
|
|
2504
|
+
);
|
|
2505
|
+
log.plain(" It will read as a repeating grid on any large surface. Before wiring it in:");
|
|
2506
|
+
log.plain(' \u2022 regenerate with "seamless tiling, no visible edges" in the prompt (--terrain for ground), or');
|
|
2507
|
+
log.plain(" \u2022 lower the mesh's UV repeat so the seam falls off-camera.");
|
|
2508
|
+
log.plain(` Re-check any image any time: ${c.cyan("npx genex ui seams --in <url>")}`);
|
|
2509
|
+
} catch {
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2378
2512
|
function printHint(kind, files, log) {
|
|
2379
2513
|
const url = files[0]?.url ?? "";
|
|
2380
2514
|
const hint = {
|
|
@@ -2440,10 +2574,10 @@ async function runWait(opts) {
|
|
|
2440
2574
|
log.dim(` ${kind} ${id}`);
|
|
2441
2575
|
log.plain("");
|
|
2442
2576
|
if (TERMINAL2.has(view.status)) {
|
|
2443
|
-
await reportTerminal(kind, view, log);
|
|
2577
|
+
await reportTerminal(kind, view, log, opts.open);
|
|
2444
2578
|
return;
|
|
2445
2579
|
}
|
|
2446
|
-
await awaitAndReport(apiUrl, token, id, kind, log);
|
|
2580
|
+
await awaitAndReport(apiUrl, token, id, kind, log, opts.open);
|
|
2447
2581
|
}
|
|
2448
2582
|
|
|
2449
2583
|
// src/commands/controller.ts
|
|
@@ -3029,11 +3163,11 @@ function rank(items, query) {
|
|
|
3029
3163
|
// src/commands/ui.ts
|
|
3030
3164
|
import fs14 from "fs/promises";
|
|
3031
3165
|
import path14 from "path";
|
|
3032
|
-
import { PNG as
|
|
3166
|
+
import { PNG as PNG3 } from "pngjs";
|
|
3033
3167
|
|
|
3034
3168
|
// src/lib/png-tools.ts
|
|
3035
3169
|
import fs13 from "fs/promises";
|
|
3036
|
-
import { PNG } from "pngjs";
|
|
3170
|
+
import { PNG as PNG2 } from "pngjs";
|
|
3037
3171
|
var ALPHA_TRANSPARENT_MAX = 16;
|
|
3038
3172
|
var isHttpUrl = (s) => /^https?:\/\//i.test(s);
|
|
3039
3173
|
async function loadPng(input) {
|
|
@@ -3045,14 +3179,14 @@ async function loadPng(input) {
|
|
|
3045
3179
|
} else {
|
|
3046
3180
|
buf = await fs13.readFile(input);
|
|
3047
3181
|
}
|
|
3048
|
-
return
|
|
3182
|
+
return PNG2.sync.read(buf);
|
|
3049
3183
|
}
|
|
3050
3184
|
async function writePng(file, png) {
|
|
3051
|
-
await fs13.writeFile(file,
|
|
3185
|
+
await fs13.writeFile(file, PNG2.sync.write(png));
|
|
3052
3186
|
}
|
|
3053
3187
|
function cropPng(image, box) {
|
|
3054
|
-
const out = new
|
|
3055
|
-
|
|
3188
|
+
const out = new PNG2({ width: box.w, height: box.h });
|
|
3189
|
+
PNG2.bitblt(image, out, box.x, box.y, box.w, box.h, 0, 0);
|
|
3056
3190
|
return out;
|
|
3057
3191
|
}
|
|
3058
3192
|
function floodFill(width, height, visited, queue, startX, startY, inRegion, onPixel) {
|
|
@@ -3183,7 +3317,8 @@ var UI_NUMBER_FLAGS = {
|
|
|
3183
3317
|
"--edge-flush-max": "edgeFlushMax",
|
|
3184
3318
|
"--quantize": "quantize",
|
|
3185
3319
|
"--ink-dist": "inkDist",
|
|
3186
|
-
"--min-frac": "minFrac"
|
|
3320
|
+
"--min-frac": "minFrac",
|
|
3321
|
+
"--seam-tolerance": "seamTolerance"
|
|
3187
3322
|
};
|
|
3188
3323
|
var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or https URL>)")}
|
|
3189
3324
|
ui extract --out-dir <dir> --names a,b,c (reading order) \u2014 split an
|
|
@@ -3199,7 +3334,10 @@ var UI_USAGE = `${c.bold("Options for `ui` (all tools read --in <png path or htt
|
|
|
3199
3334
|
ink candidates as JSON. [--quantize 12] [--ink-dist 60]
|
|
3200
3335
|
[--min-frac 0.004] [--out <json>] [--crop <png>]
|
|
3201
3336
|
ui trim [--out <png>] \u2014 crop to the alpha bbox (default: overwrite
|
|
3202
|
-
--in), print real dims + refresh the .bbox.json sidecar
|
|
3337
|
+
--in), print real dims + refresh the .bbox.json sidecar.
|
|
3338
|
+
ui seams measure a tiling texture's wrap seam (opposite edges vs
|
|
3339
|
+
interior detail) \u2192 ratio + verdict as JSON; nonzero exit on
|
|
3340
|
+
a visible seam. [--seam-tolerance 3]`;
|
|
3203
3341
|
async function runUi(opts) {
|
|
3204
3342
|
const log = createLogger({ quiet: opts.quiet });
|
|
3205
3343
|
try {
|
|
@@ -3216,9 +3354,12 @@ async function runUi(opts) {
|
|
|
3216
3354
|
case "trim":
|
|
3217
3355
|
await uiTrim(opts, log);
|
|
3218
3356
|
return;
|
|
3357
|
+
case "seams":
|
|
3358
|
+
await uiSeams(opts, log);
|
|
3359
|
+
return;
|
|
3219
3360
|
default:
|
|
3220
3361
|
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."
|
|
3362
|
+
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
3363
|
);
|
|
3223
3364
|
log.plain(UI_USAGE);
|
|
3224
3365
|
process.exitCode = 1;
|
|
@@ -3384,7 +3525,7 @@ async function uiExtract(opts, log) {
|
|
|
3384
3525
|
log.warn(`Skipping "${name}" \u2014 crop too small (${cropW}x${cropH}).`);
|
|
3385
3526
|
continue;
|
|
3386
3527
|
}
|
|
3387
|
-
const out = new
|
|
3528
|
+
const out = new PNG3({ width: cropW, height: cropH });
|
|
3388
3529
|
for (let yo = 0; yo < cropH; yo++) {
|
|
3389
3530
|
const sy = padY0 + yo;
|
|
3390
3531
|
for (let xo = 0; xo < cropW; xo++) {
|
|
@@ -3545,7 +3686,7 @@ function greenAlpha(r, g, b, a) {
|
|
|
3545
3686
|
return 0;
|
|
3546
3687
|
}
|
|
3547
3688
|
function convertGreenMask(annotatedCrop) {
|
|
3548
|
-
const out = new
|
|
3689
|
+
const out = new PNG3({ width: annotatedCrop.width, height: annotatedCrop.height });
|
|
3549
3690
|
let filled = 0;
|
|
3550
3691
|
let minX = annotatedCrop.width;
|
|
3551
3692
|
let minY = annotatedCrop.height;
|
|
@@ -3647,7 +3788,7 @@ function blendPixel(image, x, y, rgba) {
|
|
|
3647
3788
|
image.data[idx + 3] = Math.max(image.data[idx + 3], rgba[3]);
|
|
3648
3789
|
}
|
|
3649
3790
|
function makeOverlay(clean2, mask) {
|
|
3650
|
-
const out = new
|
|
3791
|
+
const out = new PNG3({ width: clean2.width, height: clean2.height });
|
|
3651
3792
|
clean2.data.copy(out.data);
|
|
3652
3793
|
for (let y = 0; y < mask.height; y += 1) {
|
|
3653
3794
|
for (let x = 0; x < mask.width; x += 1) {
|
|
@@ -3925,6 +4066,27 @@ async function uiTrim(opts, log) {
|
|
|
3925
4066
|
`
|
|
3926
4067
|
);
|
|
3927
4068
|
}
|
|
4069
|
+
async function uiSeams(opts, log) {
|
|
4070
|
+
const input = requireOpt(opts.input, "--in", "seams");
|
|
4071
|
+
const tol = opts.seamTolerance ?? DEFAULT_SEAM_TOLERANCE;
|
|
4072
|
+
const png = await loadPng(input);
|
|
4073
|
+
if (png.width < 4 || png.height < 4) {
|
|
4074
|
+
fail("Image is too small to test for tiling.", { width: png.width, height: png.height });
|
|
4075
|
+
}
|
|
4076
|
+
const r = measureSeam(png, tol);
|
|
4077
|
+
const name = input.split("/").pop() ?? input;
|
|
4078
|
+
process.stdout.write(`${JSON.stringify({ input, ...r })}
|
|
4079
|
+
`);
|
|
4080
|
+
if (r.seamless) {
|
|
4081
|
+
log.success(
|
|
4082
|
+
`${name}: tiles cleanly \u2014 worst seam ${r.worstRatio}\xD7 the texture's own detail (\u2264 ${tol}\xD7).`
|
|
4083
|
+
);
|
|
4084
|
+
return;
|
|
4085
|
+
}
|
|
4086
|
+
fail(
|
|
4087
|
+
`Visible ${r.worstAxis} tiling seam in ${name}: the tile boundary jumps ${r.worstRatio}\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.`
|
|
4088
|
+
);
|
|
4089
|
+
}
|
|
3928
4090
|
|
|
3929
4091
|
// src/index.ts
|
|
3930
4092
|
var GEN_KINDS = /* @__PURE__ */ new Set(["model", "skybox", "sfx", "texture", "image", "video"]);
|
|
@@ -3969,6 +4131,8 @@ ${c.bold("Options for the generators (`model` `skybox` `sfx` `texture` `image` `
|
|
|
3969
4131
|
--terrain (texture) seamless tiling surface for terrain/ground.
|
|
3970
4132
|
--duration <sec> (sfx, video) target clip length in seconds.
|
|
3971
4133
|
--transparent (image) transparent background (alpha) \u2014 for decals/stickers.
|
|
4134
|
+
--open (image/video) open the finished asset in the user's browser +
|
|
4135
|
+
print a link \u2014 show them the concept frame / anything they approve.
|
|
3972
4136
|
--aspect <ratio> (image) aspect preset (e.g. square, landscape, portrait).
|
|
3973
4137
|
--quality <q> (image) quality preset: low | medium | high.
|
|
3974
4138
|
--candidates <n> (image) generate 2-4 variants in ONE call and print every
|
|
@@ -4153,6 +4317,9 @@ function parseArgs(argv) {
|
|
|
4153
4317
|
case "--no-wait":
|
|
4154
4318
|
parsed.options.noWait = true;
|
|
4155
4319
|
break;
|
|
4320
|
+
case "--open":
|
|
4321
|
+
parsed.options.open = true;
|
|
4322
|
+
break;
|
|
4156
4323
|
case "--terrain":
|
|
4157
4324
|
parsed.options.terrain = true;
|
|
4158
4325
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0-dev.111",
|
|
4
4
|
"description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"start": "node src/index.ts",
|
|
22
22
|
"dev": "node --watch src/index.ts",
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
-
"test": "node --test test/*.test.ts"
|
|
24
|
+
"test": "node --test --test-concurrency=1 test/*.test.ts"
|
|
25
25
|
},
|
|
26
26
|
"keywords": [
|
|
27
27
|
"cli",
|
|
@@ -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,39 @@ map.repeat.set(64, 64);
|
|
|
67
67
|
scene.add(ground);
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
+
## Tiling is checked FOR you — read the verdict
|
|
71
|
+
|
|
72
|
+
`npx genex texture` measures the wrap seam of every texture it generates and
|
|
73
|
+
prints the verdict right under the URL. A bad one looks like this:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
✗ Visible vertical tiling seam — the tile boundary jumps 6.6× this texture's own detail (want ≤ 3×).
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`--terrain` ASKS the model for seamless; it does **not** guarantee it — a shipped
|
|
80
|
+
game seamed at 6.6× with `--terrain` set. So when that line appears, fix it
|
|
81
|
+
BEFORE wiring the texture in: **regenerate** with "seamless tiling, no visible
|
|
82
|
+
edges" in the prompt, or **lower the `repeat`** so the seam falls off-camera. A
|
|
83
|
+
good texture prints `tiles cleanly` instead. To re-check any image (including one
|
|
84
|
+
you didn't just generate): `npx genex ui seams --in <png|url>`.
|
|
85
|
+
|
|
86
|
+
## Scale by texel density — never hand-pick `repeat`
|
|
87
|
+
|
|
88
|
+
The other half of "the floor looks wrong" is a `repeat` guessed out of the air.
|
|
89
|
+
Keep texels SQUARE: derive BOTH axes from the surface's real world size and ONE
|
|
90
|
+
chosen tile size.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
const TILE_M = 4; // one tile covers 4×4 metres — choose once
|
|
94
|
+
map.repeat.set(width / TILE_M, depth / TILE_M); // a 40×400 m lane → (10, 100)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
A non-uniform guess like `repeat.set(2, 40)` on a long lane stretches the texture
|
|
98
|
+
20:1 along it: the pattern smears one way and crowds the other. That reads as
|
|
99
|
+
"wrong scale / glued together" even when the texture itself is flawless — and it
|
|
100
|
+
is exactly what shipped in a real game. Same rule for walls and platforms: derive
|
|
101
|
+
from that surface's own width/height, not from the floor's numbers.
|
|
102
|
+
|
|
70
103
|
## Publish checklist
|
|
71
104
|
|
|
72
105
|
- Load it from the **URL** the command printed — absolute and permanent, so it resolves
|
|
@@ -92,6 +125,8 @@ scene.add(ground);
|
|
|
92
125
|
- **"Email not verified" (`email_verification_required`)** — generation credits
|
|
93
126
|
unlock after the account's email is verified. Give the user the verify link the
|
|
94
127
|
CLI printed, wait for them to confirm, then re-run the command.
|
|
95
|
-
- **Visible tiling seams** —
|
|
96
|
-
|
|
128
|
+
- **Visible tiling seams** — measure first (`npx genex ui seams --in <url>`): a
|
|
129
|
+
high ratio is a defect to fix, not a limitation to accept. Regenerate with
|
|
130
|
+
`--terrain` / a "seamless tiling" prompt, lower the `repeat`, or blend two
|
|
131
|
+
textures until the check passes.
|
|
97
132
|
- **Colors look washed/dark** — ensure `map.colorSpace = THREE.SRGBColorSpace`.
|
|
@@ -79,13 +79,25 @@ lap counter in a game without laps). `--aspect 16:9 --quality high
|
|
|
79
79
|
--no-wait`, enqueued FIRST of all art; the URL goes into the style-brief
|
|
80
80
|
comment.
|
|
81
81
|
|
|
82
|
-
**Share it before building on it
|
|
83
|
-
the
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
82
|
+
**Share it before building on it — actually SHOW the image, don't just ask.**
|
|
83
|
+
When the concept lands, pick it up with `genex wait <id> --open` (or generate it
|
|
84
|
+
with `--open`): it opens in the user's browser AND prints the link. Paste that
|
|
85
|
+
URL as a clickable link in your approval message — a URL is invisible in a
|
|
86
|
+
terminal, and "do you like it?" with no picture in front of the user is the #1
|
|
87
|
+
way this checkpoint fails (they end up digging logs for the file path). Then ask
|
|
88
|
+
ONE structured question — "this is roughly how the game will look: keep it, or
|
|
89
|
+
change something?" — with 2–3 concrete adjustment options. Keep building
|
|
90
|
+
gameplay-neutral work (scaffold, physics, netcode) while waiting, but do NOT
|
|
91
|
+
anchor further art to an unapproved frame. **Approval is a LOOP, not a
|
|
92
|
+
one-shot:** if the user dislikes ANYTHING, regenerate with their exact notes
|
|
93
|
+
(`--candidates 2–3` gives them options to choose from), open + link the new
|
|
94
|
+
frame, and ask again — repeat until they actually approve. Carry every note
|
|
95
|
+
forward so each round compounds; if two rounds don't converge, offer 2–3
|
|
96
|
+
distinct directions as a structured question instead of re-rolling blind. The
|
|
97
|
+
HUD mockup, the menu, and every downstream asset wait for a frame the user has
|
|
98
|
+
signed off — a single "meh" is never permission to move on. The same "open it +
|
|
99
|
+
paste the link" rule covers every image the user weighs in on — the menu still,
|
|
100
|
+
the HUD mockup candidates.
|
|
89
101
|
|
|
90
102
|
**The concept anchors STYLE, not truth.** Palette, materials, light, and
|
|
91
103
|
register come from the frame; CONTENT comes from the game contract. When
|
|
@@ -195,6 +207,48 @@ edits scattered through the code:
|
|
|
195
207
|
HUD state come from `$genex-threejs-multiplayer`; render what the SDK
|
|
196
208
|
reports, don't guess at it.
|
|
197
209
|
|
|
210
|
+
## Escape must pause the game — not shrink the window
|
|
211
|
+
|
|
212
|
+
For a game that captures the mouse (first-person or pointer-lock aim — see the
|
|
213
|
+
pointer bucket in `$genex-threejs-camera-direction`), pressing Escape does two
|
|
214
|
+
**browser-reserved** things you CANNOT stop with `preventDefault`: it exits
|
|
215
|
+
fullscreen and releases pointer lock, and the player sees the window "shrink".
|
|
216
|
+
The real fix is the **Keyboard Lock API**, which routes Escape to your code
|
|
217
|
+
instead — Chromium only, and only in fullscreen. Enter fullscreen on a gesture
|
|
218
|
+
(the Deploy/Resume click), capture Escape, and own the pause:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
// call ONLY from a user gesture (the Deploy click, the Resume click) — never at boot
|
|
222
|
+
async function enterImmersive(): Promise<void> {
|
|
223
|
+
try {
|
|
224
|
+
if (!document.fullscreenElement)
|
|
225
|
+
await document.documentElement.requestFullscreen({ navigationUI: "hide" });
|
|
226
|
+
} catch { /* sandboxed iframe or no gesture — the pointer-lock path below still pauses */ }
|
|
227
|
+
// Chromium + fullscreen only: deliver Escape to us instead of exiting fullscreen.
|
|
228
|
+
try {
|
|
229
|
+
await (navigator as unknown as { keyboard?: { lock?: (k: string[]) => Promise<void> } })
|
|
230
|
+
.keyboard?.lock?.(["Escape"]);
|
|
231
|
+
} catch { /* Keyboard Lock unsupported — fine */ }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
document.addEventListener("keydown", (e) => {
|
|
235
|
+
if (e.code !== "Escape") return;
|
|
236
|
+
e.preventDefault(); // stops any *other* default; the reserved ones the lock handles
|
|
237
|
+
if (phase === "playing") { document.exitPointerLock?.(); setPhase("paused"); } // free cursor for the menu
|
|
238
|
+
else if (phase === "paused") { void enterImmersive(); canvas.requestPointerLock?.(); } // Esc resumes
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
- **Degradation is built in.** Where Keyboard Lock is absent (Safari, Firefox) or
|
|
243
|
+
the game isn't fullscreen, the browser still releases pointer lock on Escape —
|
|
244
|
+
so ALSO keep the pointer-lock-loss → pause path (`pointerlockchange`: if
|
|
245
|
+
unlocked while `playing`, `setPhase("paused")`). Escape then always pauses; it
|
|
246
|
+
only *also* drops fullscreen on browsers without the lock — unavoidable there.
|
|
247
|
+
- **Only for immersive/pointer-lock games.** A top-down or menu-driven game never
|
|
248
|
+
captures the mouse and has no window to shrink — skip all of this.
|
|
249
|
+
- **Dashboard embed:** full coverage needs the game iframe to permit keyboard
|
|
250
|
+
lock; standalone play (`<slug>.genex.technology`) works today regardless.
|
|
251
|
+
|
|
198
252
|
## The loader
|
|
199
253
|
|
|
200
254
|
The loader is the first thing every player sees — a bare "Loading… 3/5" over
|
|
@@ -67,11 +67,21 @@ 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), a decision for
|
|
76
|
+
**every primitive surface** the game builds — walls, barriers, kerbs and
|
|
77
|
+
platforms each get a real texture or a **shader** where that surface wants motion
|
|
78
|
+
or energy (a pulsing force barrier, an electric fence, a scrolling hazard strip),
|
|
79
|
+
judged one by one: a shader that gives a barrier life earns its place, the same
|
|
80
|
+
effect everywhere disfigures, deliberate flat black is valid if you say so — but
|
|
81
|
+
a flat-colour box beside textured geometry is the "stopped halfway" tell
|
|
82
|
+
(`$genex-threejs-procedural-materials` / `$genex-threejs-procedural-vfx`, and SEE
|
|
83
|
+
it in the running game), one named
|
|
84
|
+
**ambient-motion loop** that keeps
|
|
75
85
|
the scene alive at rest (emissive pulse, shimmer, drifting dust — shader
|
|
76
86
|
work, zero generations), and the lighting/atmosphere mood from that
|
|
77
87
|
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
|
|
@@ -59,6 +65,21 @@ Three.js release or branch, and do not blindly copy demo architecture.
|
|
|
59
65
|
drifting dust, a slowly flowing texture. Shader/procedural, zero
|
|
60
66
|
generations, built with the scene — a world that is perfectly still
|
|
61
67
|
reads as a screenshot, not a place;
|
|
68
|
+
- **every primitive surface — texture it or shade it, decided one by one**:
|
|
69
|
+
the ground is never the only surface. Walk the walls, barriers, kerbs,
|
|
70
|
+
platforms and props the game BUILDS out of primitives, and give each a real
|
|
71
|
+
material: a generated texture, or a **shader** where that surface genuinely
|
|
72
|
+
wants motion or energy — a force barrier that pulses and refracts, an
|
|
73
|
+
electric fence, a scrolling hazard strip, an emissive seam that breathes.
|
|
74
|
+
Judge surface by surface: a shader that gives a blocking barrier life earns
|
|
75
|
+
its place; the same effect smeared over everything disfigures the scene.
|
|
76
|
+
Deliberate flat black IS a valid answer when the look calls for it — say so
|
|
77
|
+
in one line. What is never valid is not deciding: a flat-colour box standing
|
|
78
|
+
next to textured geometry is the "stopped halfway" tell, and it is what
|
|
79
|
+
ships when this bullet is skipped. `$genex-threejs-procedural-materials`
|
|
80
|
+
and `$genex-threejs-procedural-vfx` own the craft. Whatever you apply, SEE
|
|
81
|
+
it in the running game before calling it done — an unverified shader
|
|
82
|
+
disfigures as easily as it delights;
|
|
62
83
|
- **lighting/atmosphere mood** from the SAME shared style brief the UI gate
|
|
63
84
|
wrote — one art direction across scene and UI.
|
|
64
85
|
Planning is not building: effects still land LAST (steps 10–11); this step
|