@genex-ai/cli-demo 0.71.0 → 0.74.0-dev.190
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 +354 -8
- package/package.json +2 -1
- package/templates/controllers/character/follow-camera.ts +15 -4
- package/templates/controllers/character/vrm/vrm-loader.ts +74 -11
- package/templates/controllers/quality/governor.ts +147 -0
- package/templates/controllers/quality/pick-asset.ts +57 -0
- package/templates/controllers/quality/tier.ts +170 -0
- package/templates/skills/genex-ai-hud/SKILL.md +11 -2
- package/templates/skills/genex-ai-menu/SKILL.md +18 -2
- package/templates/skills/genex-ai-skybox/SKILL.md +15 -4
- package/templates/skills/genex-ai-texture/SKILL.md +1 -1
- package/templates/skills/genex-ai-video/SKILL.md +1 -1
- package/templates/skills/genex-explore/SKILL.md +1 -1
- package/templates/skills/genex-getting-started/SKILL.md +2 -2
- package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +141 -0
- package/templates/skills/genex-threejs-adaptive-quality/references/adaptive-quality.md +105 -0
- package/templates/skills/genex-threejs-bloom/SKILL.md +4 -1
- package/templates/skills/genex-threejs-bloom/references/bloom.md +1 -1
- package/templates/skills/genex-threejs-camera-direction/SKILL.md +62 -12
- package/templates/skills/genex-threejs-camera-direction/references/camera-rigs.md +62 -0
- package/templates/skills/genex-threejs-character-controller/references/wiring.md +9 -3
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -1
- package/templates/skills/genex-threejs-game-feel/SKILL.md +4 -1
- package/templates/skills/genex-threejs-game-ui/SKILL.md +113 -30
- package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +4 -1
- package/templates/skills/genex-threejs-image-pipeline/SKILL.md +5 -0
- package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +1 -1
- package/templates/skills/genex-threejs-lighting-design/SKILL.md +5 -1
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +7 -1
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +6 -3
- package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +1 -0
- package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +1 -1
- package/templates/skills/genex-threejs-shadow-systems/SKILL.md +6 -0
- package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +1 -1
- package/templates/skills/genex-threejs-skill-router/SKILL.md +26 -5
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +25 -9
- package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +1 -1
- package/templates/skills/genex-threejs-touch-controls/SKILL.md +11 -0
- package/templates/skills/genex-threejs-vehicle-controllers/references/enter-exit.md +7 -0
- package/templates/skills/genex-threejs-visual-validation/SKILL.md +36 -12
- package/templates/skills/genex-threejs-water-optics/references/water-optics.md +1 -1
- package/templates/skills/genex-updates/SKILL.md +1 -1
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import fs from "fs";
|
|
|
8
8
|
import os from "os";
|
|
9
9
|
import path from "path";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
-
var RAW_CHANNEL = "
|
|
11
|
+
var RAW_CHANNEL = "dev";
|
|
12
12
|
var CLI_CHANNEL = RAW_CHANNEL === "dev" ? "dev" : "latest";
|
|
13
13
|
var STANDS = {
|
|
14
14
|
prod: { api: "https://api.genex.games", dashboard: "https://genex.games" },
|
|
@@ -1497,6 +1497,261 @@ import crypto3 from "crypto";
|
|
|
1497
1497
|
import fs9 from "fs/promises";
|
|
1498
1498
|
import os4 from "os";
|
|
1499
1499
|
import path10 from "path";
|
|
1500
|
+
|
|
1501
|
+
// ../../packages/mobile-scan/src/image-dims.ts
|
|
1502
|
+
function u32be(b, o) {
|
|
1503
|
+
return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
|
|
1504
|
+
}
|
|
1505
|
+
function u32le(b, o) {
|
|
1506
|
+
return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
|
|
1507
|
+
}
|
|
1508
|
+
function u16be(b, o) {
|
|
1509
|
+
return b[o] << 8 | b[o + 1];
|
|
1510
|
+
}
|
|
1511
|
+
function u16le(b, o) {
|
|
1512
|
+
return b[o] | b[o + 1] << 8;
|
|
1513
|
+
}
|
|
1514
|
+
function isPng(b) {
|
|
1515
|
+
return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
|
|
1516
|
+
}
|
|
1517
|
+
var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
|
|
1518
|
+
function parseImageDims(bytes) {
|
|
1519
|
+
if (bytes.length < 30) return null;
|
|
1520
|
+
if (isPng(bytes)) {
|
|
1521
|
+
return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
|
|
1522
|
+
}
|
|
1523
|
+
if (bytes[0] === 255 && bytes[1] === 216) {
|
|
1524
|
+
let i = 2;
|
|
1525
|
+
while (i + 9 < bytes.length) {
|
|
1526
|
+
if (bytes[i] !== 255) {
|
|
1527
|
+
i++;
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
const marker = bytes[i + 1];
|
|
1531
|
+
if (marker === 255) {
|
|
1532
|
+
i++;
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (marker === 216 || marker >= 208 && marker <= 217) {
|
|
1536
|
+
i += 2;
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
|
|
1540
|
+
if (isSof) {
|
|
1541
|
+
return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
|
|
1542
|
+
}
|
|
1543
|
+
i += 2 + u16be(bytes, i + 2);
|
|
1544
|
+
}
|
|
1545
|
+
return null;
|
|
1546
|
+
}
|
|
1547
|
+
if (bytes[0] === 82 && // RIFF….WEBP
|
|
1548
|
+
bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
|
|
1549
|
+
const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
|
|
1550
|
+
if (fourcc === "VP8X" && bytes.length >= 30) {
|
|
1551
|
+
const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
|
|
1552
|
+
const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
|
|
1553
|
+
return { width: w, height: h };
|
|
1554
|
+
}
|
|
1555
|
+
if (fourcc === "VP8 " && bytes.length >= 30) {
|
|
1556
|
+
return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
|
|
1557
|
+
}
|
|
1558
|
+
if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
|
|
1559
|
+
const b0 = bytes[21];
|
|
1560
|
+
const b1 = bytes[22];
|
|
1561
|
+
const b2 = bytes[23];
|
|
1562
|
+
const b3 = bytes[24];
|
|
1563
|
+
const width = 1 + ((b1 & 63) << 8 | b0);
|
|
1564
|
+
const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
|
|
1565
|
+
return { width, height };
|
|
1566
|
+
}
|
|
1567
|
+
return null;
|
|
1568
|
+
}
|
|
1569
|
+
if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
|
|
1570
|
+
return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
|
|
1571
|
+
}
|
|
1572
|
+
return null;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// ../../packages/mobile-scan/src/glb.ts
|
|
1576
|
+
function u32le2(b, o) {
|
|
1577
|
+
return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
|
|
1578
|
+
}
|
|
1579
|
+
function scanGlb(bytes) {
|
|
1580
|
+
if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
|
|
1581
|
+
try {
|
|
1582
|
+
const jsonLen = u32le2(bytes, 12);
|
|
1583
|
+
if (u32le2(bytes, 16) !== 1313821514) return null;
|
|
1584
|
+
const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
|
|
1585
|
+
let bin = null;
|
|
1586
|
+
const binHeader = 20 + jsonLen;
|
|
1587
|
+
if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
|
|
1588
|
+
bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
|
|
1589
|
+
}
|
|
1590
|
+
const images = [];
|
|
1591
|
+
for (const img of json.images ?? []) {
|
|
1592
|
+
if (img.bufferView == null || !bin) continue;
|
|
1593
|
+
const view = json.bufferViews?.[img.bufferView];
|
|
1594
|
+
if (!view?.byteLength) continue;
|
|
1595
|
+
const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
|
|
1596
|
+
const dims = parseImageDims(slice);
|
|
1597
|
+
if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
|
|
1598
|
+
}
|
|
1599
|
+
let triangles = 0;
|
|
1600
|
+
for (const mesh of json.meshes ?? []) {
|
|
1601
|
+
for (const prim of mesh.primitives ?? []) {
|
|
1602
|
+
const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
|
|
1603
|
+
if (accessor?.count) triangles += Math.floor(accessor.count / 3);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
return {
|
|
1607
|
+
triangles,
|
|
1608
|
+
binBytes: bin?.byteLength ?? 0,
|
|
1609
|
+
images,
|
|
1610
|
+
extensions: json.extensionsUsed ?? []
|
|
1611
|
+
};
|
|
1612
|
+
} catch {
|
|
1613
|
+
return null;
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
// ../../packages/mobile-scan/src/scan.ts
|
|
1618
|
+
var GENEX_GENERATION_URL_RE = /https:\/\/assets\.genex\.technology\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
|
|
1619
|
+
var SKYBOX_ROLE = "skybox-equirect";
|
|
1620
|
+
var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
|
|
1621
|
+
var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
|
|
1622
|
+
var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
|
|
1623
|
+
var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
|
|
1624
|
+
var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
|
|
1625
|
+
var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
|
|
1626
|
+
var GLB_EXT = /\.(glb|gltf)$/i;
|
|
1627
|
+
function scanBundle(files) {
|
|
1628
|
+
const images = [];
|
|
1629
|
+
const glbs = [];
|
|
1630
|
+
const videos = [];
|
|
1631
|
+
const audio = [];
|
|
1632
|
+
const urls = /* @__PURE__ */ new Set();
|
|
1633
|
+
let text = "";
|
|
1634
|
+
let totalBytes = 0;
|
|
1635
|
+
for (const f of files) {
|
|
1636
|
+
totalBytes += f.bytes.byteLength;
|
|
1637
|
+
if (IMAGE_EXT.test(f.relPath)) {
|
|
1638
|
+
const dims = parseImageDims(f.bytes);
|
|
1639
|
+
if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
if (GLB_EXT.test(f.relPath)) {
|
|
1643
|
+
glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
if (VIDEO_EXT.test(f.relPath)) {
|
|
1647
|
+
videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
if (AUDIO_EXT.test(f.relPath)) {
|
|
1651
|
+
audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
if (TEXT_EXT.test(f.relPath)) {
|
|
1655
|
+
text += new TextDecoder().decode(f.bytes.subarray(0, 8 * 1024 * 1024));
|
|
1656
|
+
text += "\n";
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
|
|
1660
|
+
const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
|
|
1661
|
+
const markers = {
|
|
1662
|
+
rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
|
|
1663
|
+
postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
|
|
1664
|
+
dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
|
|
1665
|
+
contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
|
|
1666
|
+
ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
|
|
1667
|
+
touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
|
|
1668
|
+
adaptiveQuality: text.includes("__GENEX_QUALITY__")
|
|
1669
|
+
};
|
|
1670
|
+
return {
|
|
1671
|
+
images,
|
|
1672
|
+
glbs,
|
|
1673
|
+
videos,
|
|
1674
|
+
audio,
|
|
1675
|
+
genexAssetUrls: [...urls],
|
|
1676
|
+
markers,
|
|
1677
|
+
totalBytes,
|
|
1678
|
+
fileCount: files.length
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
// ../../packages/mobile-scan/src/estimate.ts
|
|
1683
|
+
var MB = 1024 * 1024;
|
|
1684
|
+
var CSS_W = 430;
|
|
1685
|
+
var CSS_H = 932;
|
|
1686
|
+
var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
|
|
1687
|
+
var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
|
|
1688
|
+
var KTX2_DISCOUNT = 6;
|
|
1689
|
+
function textureBytes(w, h, ktx2) {
|
|
1690
|
+
const raw = w * h * 4 * 1.33;
|
|
1691
|
+
return ktx2 ? raw / KTX2_DISCOUNT : raw;
|
|
1692
|
+
}
|
|
1693
|
+
function estimateVram(scan, externalDims) {
|
|
1694
|
+
let bundled = 0;
|
|
1695
|
+
let maxDim = 0;
|
|
1696
|
+
for (const img of scan.images) {
|
|
1697
|
+
bundled += textureBytes(img.width, img.height, img.ktx2);
|
|
1698
|
+
maxDim = Math.max(maxDim, img.width, img.height);
|
|
1699
|
+
}
|
|
1700
|
+
let glbTex = 0;
|
|
1701
|
+
let geometry = 0;
|
|
1702
|
+
for (const glb of scan.glbs) {
|
|
1703
|
+
if (!glb.scan) {
|
|
1704
|
+
geometry += glb.bytes;
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
|
|
1708
|
+
for (const img of glb.scan.images) {
|
|
1709
|
+
glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
|
|
1710
|
+
maxDim = Math.max(maxDim, img.width, img.height);
|
|
1711
|
+
}
|
|
1712
|
+
geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
|
|
1713
|
+
}
|
|
1714
|
+
let external = 0;
|
|
1715
|
+
const unresolved = [];
|
|
1716
|
+
for (const url of scan.genexAssetUrls) {
|
|
1717
|
+
const role = url.split("/").pop() ?? "";
|
|
1718
|
+
if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
|
|
1719
|
+
const dims = externalDims.get(url);
|
|
1720
|
+
if (dims) {
|
|
1721
|
+
external += textureBytes(dims.width, dims.height, dims.ktx2);
|
|
1722
|
+
maxDim = Math.max(maxDim, dims.width, dims.height);
|
|
1723
|
+
} else {
|
|
1724
|
+
unresolved.push(url);
|
|
1725
|
+
external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
|
|
1726
|
+
maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
|
|
1730
|
+
const surface = CSS_W * CSS_H * dpr * dpr * 4;
|
|
1731
|
+
const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
|
|
1732
|
+
const shadow = 2048 * 2048 * 4;
|
|
1733
|
+
const total = bundled + glbTex + external + framebuffers + shadow + geometry;
|
|
1734
|
+
return {
|
|
1735
|
+
estVramMb: Math.round(total / MB),
|
|
1736
|
+
breakdown: {
|
|
1737
|
+
bundledTexturesMb: Math.round(bundled / MB),
|
|
1738
|
+
glbTexturesMb: Math.round(glbTex / MB),
|
|
1739
|
+
externalTexturesMb: Math.round(external / MB),
|
|
1740
|
+
framebuffersMb: Math.round(framebuffers / MB),
|
|
1741
|
+
shadowDefaultMb: Math.round(shadow / MB),
|
|
1742
|
+
geometryMb: Math.round(geometry / MB)
|
|
1743
|
+
},
|
|
1744
|
+
unresolvedExternals: unresolved,
|
|
1745
|
+
textureMaxDim: maxDim
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
function tierFor(estVramMb) {
|
|
1749
|
+
if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
|
|
1750
|
+
if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
|
|
1751
|
+
return "over-budget";
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// src/lib/deploy.ts
|
|
1500
1755
|
function run(cmd, args, env) {
|
|
1501
1756
|
return new Promise((resolve) => {
|
|
1502
1757
|
let child;
|
|
@@ -1514,6 +1769,48 @@ function run(cmd, args, env) {
|
|
|
1514
1769
|
child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
|
|
1515
1770
|
});
|
|
1516
1771
|
}
|
|
1772
|
+
function printMobilePreflight(files, log) {
|
|
1773
|
+
try {
|
|
1774
|
+
const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
|
|
1775
|
+
const externalDims = /* @__PURE__ */ new Map();
|
|
1776
|
+
for (const url of scan.genexAssetUrls) {
|
|
1777
|
+
const role = url.split("/").pop() ?? "";
|
|
1778
|
+
if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
|
|
1779
|
+
externalDims.set(url, { width: 2048, height: 1024 });
|
|
1780
|
+
} else {
|
|
1781
|
+
externalDims.set(url, null);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
const est = estimateVram(scan, externalDims);
|
|
1785
|
+
const tier = tierFor(est.estVramMb);
|
|
1786
|
+
const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
|
|
1787
|
+
if (tier === "mobile-broad") {
|
|
1788
|
+
log.dim(` ${line}`);
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
log.warn(line);
|
|
1792
|
+
for (const url of est.unresolvedExternals) {
|
|
1793
|
+
const role = url.split("/").pop() ?? "";
|
|
1794
|
+
if (role === SKYBOX_ROLE) {
|
|
1795
|
+
log.warn(
|
|
1796
|
+
` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
if (est.textureMaxDim > 2048) {
|
|
1801
|
+
log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
|
|
1802
|
+
}
|
|
1803
|
+
if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
|
|
1804
|
+
log.warn(
|
|
1805
|
+
" Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
|
|
1806
|
+
);
|
|
1807
|
+
}
|
|
1808
|
+
if (!scan.markers.contextLossHandler) {
|
|
1809
|
+
log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
|
|
1810
|
+
}
|
|
1811
|
+
} catch {
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1517
1814
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
|
|
1518
1815
|
function isSecretEnvFile(name) {
|
|
1519
1816
|
return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
|
|
@@ -1544,6 +1841,7 @@ async function deployGame(ctx, opts, log) {
|
|
|
1544
1841
|
log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
|
|
1545
1842
|
return false;
|
|
1546
1843
|
}
|
|
1844
|
+
printMobilePreflight(files, log);
|
|
1547
1845
|
const commit = contentCommit(files);
|
|
1548
1846
|
log.step("Requesting an upload token\u2026");
|
|
1549
1847
|
const grant = await getUploadToken(ctx, commit, log);
|
|
@@ -1687,7 +1985,8 @@ async function callPublish(ctx, commit, opts, log) {
|
|
|
1687
1985
|
commit,
|
|
1688
1986
|
matchmaking: opts.matchmaking ?? null,
|
|
1689
1987
|
...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
|
|
1690
|
-
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
|
|
1988
|
+
...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
|
|
1989
|
+
...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
|
|
1691
1990
|
})
|
|
1692
1991
|
});
|
|
1693
1992
|
} catch (err) {
|
|
@@ -2033,6 +2332,32 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
|
|
|
2033
2332
|
}
|
|
2034
2333
|
return null;
|
|
2035
2334
|
}
|
|
2335
|
+
var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
|
|
2336
|
+
async function detectMobileControls(cwd = process.cwd()) {
|
|
2337
|
+
try {
|
|
2338
|
+
const raw = await fs11.readFile(path12.join(cwd, "package.json"), "utf8");
|
|
2339
|
+
const pkg = JSON.parse(raw);
|
|
2340
|
+
if (pkg.genex?.mobileControls === true) return true;
|
|
2341
|
+
} catch {
|
|
2342
|
+
}
|
|
2343
|
+
const srcDir = path12.join(cwd, "src");
|
|
2344
|
+
let entries;
|
|
2345
|
+
try {
|
|
2346
|
+
entries = await fs11.readdir(srcDir, { recursive: true });
|
|
2347
|
+
} catch {
|
|
2348
|
+
return false;
|
|
2349
|
+
}
|
|
2350
|
+
for (const rel of entries) {
|
|
2351
|
+
if (rel.includes("node_modules")) continue;
|
|
2352
|
+
if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
|
|
2353
|
+
try {
|
|
2354
|
+
const content = await fs11.readFile(path12.join(srcDir, rel), "utf8");
|
|
2355
|
+
if (TOUCH_KIT_MARKERS.test(content)) return true;
|
|
2356
|
+
} catch {
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
return false;
|
|
2360
|
+
}
|
|
2036
2361
|
var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
|
|
2037
2362
|
async function detectGameStateUsage(cwd = process.cwd()) {
|
|
2038
2363
|
const srcDir = path12.join(cwd, "src");
|
|
@@ -2217,6 +2542,7 @@ async function detectFeatures(log, cwd = process.cwd()) {
|
|
|
2217
2542
|
multiplayer: await detectMultiplayer(cwd),
|
|
2218
2543
|
matchmaking: await detectMatchmaking(log, cwd),
|
|
2219
2544
|
gameStateUsed: await detectGameStateUsage(cwd),
|
|
2545
|
+
mobileControls: await detectMobileControls(cwd),
|
|
2220
2546
|
uiPhases: await detectUiPhases(cwd),
|
|
2221
2547
|
surfaces: await detectSurfaceScan(cwd),
|
|
2222
2548
|
generations: await detectGenerationAudit(cwd)
|
|
@@ -2360,7 +2686,8 @@ async function runPublish(opts) {
|
|
|
2360
2686
|
noBuild: opts.noBuild,
|
|
2361
2687
|
matchmaking: detections.matchmaking,
|
|
2362
2688
|
embedSdkVersion: detections.embedSdkVersion,
|
|
2363
|
-
multiplayer: detections.multiplayer
|
|
2689
|
+
multiplayer: detections.multiplayer,
|
|
2690
|
+
mobileControls: detections.mobileControls
|
|
2364
2691
|
},
|
|
2365
2692
|
log
|
|
2366
2693
|
);
|
|
@@ -2392,6 +2719,7 @@ async function runPublish(opts) {
|
|
|
2392
2719
|
body.multiplayer = detections.multiplayer;
|
|
2393
2720
|
body.matchmaking = detections.matchmaking ?? null;
|
|
2394
2721
|
body.gameStateUsed = detections.gameStateUsed;
|
|
2722
|
+
body.mobileControls = detections.mobileControls;
|
|
2395
2723
|
res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
|
|
2396
2724
|
method: "POST",
|
|
2397
2725
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
@@ -2445,7 +2773,8 @@ async function runPreview(opts) {
|
|
|
2445
2773
|
noBuild: opts.noBuild,
|
|
2446
2774
|
matchmaking: detections.matchmaking,
|
|
2447
2775
|
embedSdkVersion: detections.embedSdkVersion,
|
|
2448
|
-
multiplayer: detections.multiplayer
|
|
2776
|
+
multiplayer: detections.multiplayer,
|
|
2777
|
+
mobileControls: detections.mobileControls
|
|
2449
2778
|
},
|
|
2450
2779
|
log
|
|
2451
2780
|
);
|
|
@@ -12694,7 +13023,8 @@ var CONTROLLER_KINDS = [
|
|
|
12694
13023
|
"car",
|
|
12695
13024
|
"drone",
|
|
12696
13025
|
"touch",
|
|
12697
|
-
"networked-physics"
|
|
13026
|
+
"networked-physics",
|
|
13027
|
+
"quality"
|
|
12698
13028
|
];
|
|
12699
13029
|
var SHARED = [
|
|
12700
13030
|
"shared/math.ts",
|
|
@@ -12787,6 +13117,17 @@ var CONTROLLER_FILE_SETS = {
|
|
|
12787
13117
|
`physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
|
|
12788
13118
|
]
|
|
12789
13119
|
},
|
|
13120
|
+
quality: {
|
|
13121
|
+
code: ["quality/tier.ts", "quality/governor.ts", "quality/pick-asset.ts"],
|
|
13122
|
+
assets: [],
|
|
13123
|
+
skill: "genex-threejs-adaptive-quality",
|
|
13124
|
+
sketch: [
|
|
13125
|
+
`const tier = detectTier(); // phone-low | phone | desktop \u2014 manual Quality setting wins`,
|
|
13126
|
+
`renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap)); // antialias: tier.antialias at creation`,
|
|
13127
|
+
`const gov = new QualityGovernor(tier, { setDprScale: (m) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap * m)) }, renderer);`,
|
|
13128
|
+
`// per frame: gov.frame(deltaMs); skybox: loadTextureWithFallback(SKYBOX_URL, tier, (u) => loader.loadAsync(u))`
|
|
13129
|
+
]
|
|
13130
|
+
},
|
|
12790
13131
|
touch: {
|
|
12791
13132
|
code: [...TOUCH_KIT, NOTICE],
|
|
12792
13133
|
assets: [],
|
|
@@ -13678,7 +14019,10 @@ async function runExplore(opts) {
|
|
|
13678
14019
|
const meta = [
|
|
13679
14020
|
p.categories.join(", "),
|
|
13680
14021
|
p.license ?? "",
|
|
13681
|
-
p.sourceAuthor ? `originally by ${p.sourceAuthor}` : ""
|
|
14022
|
+
p.sourceAuthor ? `originally by ${p.sourceAuthor}` : "",
|
|
14023
|
+
// Reuse signal for mobile-targeted games: cloning a desktop-only/heavy
|
|
14024
|
+
// system into a phone game imports its memory profile too.
|
|
14025
|
+
p.mobileReadiness && p.mobileReadiness.state !== "unknown" ? `[mobile: ${p.mobileReadiness.state}]` : ""
|
|
13682
14026
|
].filter(Boolean).join(" \xB7 ");
|
|
13683
14027
|
log.plain(`${c.bold(`${i + 1}. ${p.title}`)} \u2014 ${meta}`);
|
|
13684
14028
|
if (p.description) log.plain(` ${p.description.slice(0, 160)}`);
|
|
@@ -14671,11 +15015,13 @@ ${c.bold("Usage")}
|
|
|
14671
15015
|
enqueued (done/running/queued/failed) \u2014 a single
|
|
14672
15016
|
API refresh, never blocks, never bills.
|
|
14673
15017
|
genex controller <type> [--force] Install a bundled controller
|
|
14674
|
-
(character|car|drone|touch|networked-physics)
|
|
15018
|
+
(character|car|drone|touch|networked-physics|quality)
|
|
14675
15019
|
into src/controllers (+ assets into public/assets).
|
|
14676
15020
|
"touch" = the standalone mobile touch kit (joystick,
|
|
14677
15021
|
buttons, drag zone, rotate overlay) for games
|
|
14678
|
-
without a physics controller.
|
|
15022
|
+
without a physics controller. "quality" = the
|
|
15023
|
+
adaptive-quality kit (device tier, runtime governor,
|
|
15024
|
+
per-tier asset rungs) every game wires at boot.
|
|
14679
15025
|
Character defaults to the player's VRM + UAL pack.
|
|
14680
15026
|
genex controller character --character <id>
|
|
14681
15027
|
Install the Meshy-native controller variant.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.74.0-dev.190",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@dimforge/rapier3d-compat": "^0.19.3",
|
|
44
44
|
"@genex/meshy-animation-catalog": "workspace:*",
|
|
45
|
+
"@genex/mobile-scan": "workspace:*",
|
|
45
46
|
"@genex-ai/multiplayer": "workspace:*",
|
|
46
47
|
"@pixiv/three-vrm": "^3.5.4",
|
|
47
48
|
"@types/pngjs": "^6.0.5",
|
|
@@ -674,13 +674,24 @@ export class FollowCamera {
|
|
|
674
674
|
|
|
675
675
|
/**
|
|
676
676
|
* Suspend or resume aim without disabling the camera. Call `setPaused(true)`
|
|
677
|
-
* when a menu opens or the player starts driving; call
|
|
678
|
-
* the closing click/keypress handler (browsers only
|
|
679
|
-
* gesture). While paused, canvas clicks do NOT
|
|
680
|
-
* or "unavailable".
|
|
677
|
+
* when a menu opens (the BOOT menu counts) or the player starts driving; call
|
|
678
|
+
* `setPaused(false)` INSIDE the closing click/keypress handler (browsers only
|
|
679
|
+
* grant re-lock from a user gesture). While paused, canvas clicks do NOT
|
|
680
|
+
* re-lock. No-op when aim is "off" or "unavailable".
|
|
681
|
+
*
|
|
682
|
+
* IDEMPOTENT: a same-value call is a complete no-op — safe to drive from a
|
|
683
|
+
* phase binding (`setPaused(phase !== "playing")`) that may fire repeatedly.
|
|
684
|
+
* This is load-bearing, not a nicety: an unguarded `setPaused(false)` per
|
|
685
|
+
* render frame re-requested the lock every frame, and because ANY DOM click
|
|
686
|
+
* (a Settings button) grants ~5s of transient activation, the next frame
|
|
687
|
+
* grabbed the pointer over the open menu. The resume re-lock therefore only
|
|
688
|
+
* fires on a real paused→unpaused transition; a game that never called
|
|
689
|
+
* `setPaused(true)` gets its re-lock from the canvas click (+ the cue), by
|
|
690
|
+
* design.
|
|
681
691
|
*/
|
|
682
692
|
setPaused(paused: boolean): void {
|
|
683
693
|
if (this._aimState === "off" || this._aimState === "unavailable") return;
|
|
694
|
+
if (paused === this._pausedByGame) return; // idempotent — same-value calls are no-ops
|
|
684
695
|
this._pausedByGame = paused;
|
|
685
696
|
if (paused) {
|
|
686
697
|
this._retryArmed = false; // a menu/vehicle owns input now — no gesture retry
|
|
@@ -4,9 +4,17 @@
|
|
|
4
4
|
// orientation, and runs the standard perf cleanup — so the rest of the
|
|
5
5
|
// controller treats every avatar (VRM 0.x or 1.0) identically.
|
|
6
6
|
//
|
|
7
|
+
// Mobile-readiness: everyone in a Genex room renders the SAME avatar file, so
|
|
8
|
+
// this module caches per URL — the file downloads once (loadVrm), and remote
|
|
9
|
+
// players should use loadVrmClone, which shares one set of GPU geometry +
|
|
10
|
+
// textures across N remotes (~1 avatar of VRAM instead of N). Before this,
|
|
11
|
+
// each remote re-downloaded AND re-parsed the VRM into duplicate GPU memory —
|
|
12
|
+
// a real jetsam contributor at high player counts.
|
|
13
|
+
//
|
|
7
14
|
// Needs `npm i @pixiv/three-vrm` (peer of three, which the scaffold already has).
|
|
8
15
|
import * as THREE from "three";
|
|
9
16
|
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
17
|
+
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
|
|
10
18
|
import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
|
|
11
19
|
import type { VRM } from "@pixiv/three-vrm";
|
|
12
20
|
|
|
@@ -17,28 +25,83 @@ export interface LoadedVrm {
|
|
|
17
25
|
vrm: VRM;
|
|
18
26
|
}
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
// One network fetch + decode per URL, however many avatars spawn.
|
|
29
|
+
const bufferCache = new Map<string, Promise<ArrayBuffer>>();
|
|
30
|
+
// One PARSED base VRM per URL — the clone source for remote players.
|
|
31
|
+
const baseCache = new Map<string, Promise<LoadedVrm>>();
|
|
32
|
+
|
|
33
|
+
function fetchVrmBuffer(url: string): Promise<ArrayBuffer> {
|
|
34
|
+
let cached = bufferCache.get(url);
|
|
35
|
+
if (!cached) {
|
|
36
|
+
cached = fetch(url).then((r) => {
|
|
37
|
+
if (!r.ok) throw new Error(`VRM fetch failed: ${r.status} ${url}`);
|
|
38
|
+
return r.arrayBuffer();
|
|
39
|
+
});
|
|
40
|
+
cached.catch(() => bufferCache.delete(url)); // failed fetches retry next call
|
|
41
|
+
bufferCache.set(url, cached);
|
|
42
|
+
}
|
|
43
|
+
return cached;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function parseVrm(url: string): Promise<LoadedVrm> {
|
|
28
47
|
const loader = new GLTFLoader();
|
|
29
48
|
loader.register((parser) => new VRMLoaderPlugin(parser));
|
|
30
49
|
|
|
31
|
-
const
|
|
50
|
+
const buffer = await fetchVrmBuffer(url);
|
|
51
|
+
const gltf = await loader.parseAsync(buffer.slice(0), url);
|
|
32
52
|
const vrm = gltf.userData.vrm as VRM;
|
|
33
53
|
|
|
34
54
|
VRMUtils.rotateVRM0(vrm);
|
|
35
55
|
VRMUtils.removeUnnecessaryVertices(vrm.scene);
|
|
36
56
|
VRMUtils.combineSkeletons(vrm.scene);
|
|
37
57
|
|
|
38
|
-
// Skinned
|
|
58
|
+
// Skinned meshes can pop out at glancing camera angles (animated bounds) —
|
|
59
|
+
// but ONLY skinned meshes need the exemption. Blanket-disabling culling on
|
|
60
|
+
// every node made ALL loaded avatars draw every frame regardless of
|
|
61
|
+
// visibility, a per-remote GPU-time tax on phones.
|
|
39
62
|
vrm.scene.traverse((obj) => {
|
|
40
|
-
obj.frustumCulled = false;
|
|
63
|
+
if ((obj as THREE.SkinnedMesh).isSkinnedMesh) obj.frustumCulled = false;
|
|
41
64
|
});
|
|
42
65
|
|
|
43
66
|
return { scene: vrm.scene, vrm };
|
|
44
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load a `.vrm` and return its scene + VRM instance, ready to animate.
|
|
71
|
+
* Full-fidelity (spring bones, humanoid) — the LOCAL player's avatar, or any
|
|
72
|
+
* avatar you retarget clips onto. The file itself is fetched once per URL.
|
|
73
|
+
*
|
|
74
|
+
* `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
|
|
75
|
+
* humanoid rig, so the avatar faces -Z (three's forward, same as every other
|
|
76
|
+
* model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
|
|
77
|
+
*/
|
|
78
|
+
export async function loadVrm(url: string): Promise<LoadedVrm> {
|
|
79
|
+
return parseVrm(url);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Visual-only clone for REMOTE players: parses the URL once (cached), then
|
|
84
|
+
* returns `SkeletonUtils.clone` of the base — its own bones and mixer target,
|
|
85
|
+
* but SHARED geometry, materials, and textures (N remotes ≈ 1 avatar of GPU
|
|
86
|
+
* memory). Trade-off, on purpose: clones carry no VRM instance, so spring
|
|
87
|
+
* bones (hair/cloth sway) don't simulate on remotes — retarget animation clips
|
|
88
|
+
* ONCE on the base (`(await loadVrmClone.base(url)).vrm`, or your local
|
|
89
|
+
* loadVrm result when it's the same file) and play them on each clone's own
|
|
90
|
+
* `THREE.AnimationMixer`; track names match by construction.
|
|
91
|
+
*/
|
|
92
|
+
export async function loadVrmClone(url: string): Promise<{ scene: THREE.Group }> {
|
|
93
|
+
const base = await loadVrmCloneBase(url);
|
|
94
|
+
return { scene: SkeletonUtils.clone(base.scene) as THREE.Group };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The shared parsed base behind loadVrmClone — retarget clips against ITS vrm. */
|
|
98
|
+
export function loadVrmCloneBase(url: string): Promise<LoadedVrm> {
|
|
99
|
+
let cached = baseCache.get(url);
|
|
100
|
+
if (!cached) {
|
|
101
|
+
cached = parseVrm(url);
|
|
102
|
+
cached.catch(() => baseCache.delete(url));
|
|
103
|
+
baseCache.set(url, cached);
|
|
104
|
+
}
|
|
105
|
+
return cached;
|
|
106
|
+
}
|
|
107
|
+
loadVrmClone.base = loadVrmCloneBase;
|