jgengine 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +278 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +119 -0
- package/dist/create.d.ts +4 -0
- package/dist/create.js +111 -0
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +161 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +4 -0
- package/dist/pkg.d.ts +18 -0
- package/dist/pkg.js +45 -0
- package/dist/templates.d.ts +15 -0
- package/dist/templates.js +341 -0
- package/llms.txt +1439 -0
- package/package.json +45 -0
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { findWorkspaceRoot, readPackageJson } from "./pkg.js";
|
|
4
|
+
import { IN_REPO_TSCONFIG_PATHS } from "./templates.js";
|
|
5
|
+
const SKELETON_FILES = new Set(["game.config.ts", "index.tsx", "main.tsx", "loop.ts", "world.ts", "index.css"]);
|
|
6
|
+
const SKELETON_DIRS = new Set(["game"]);
|
|
7
|
+
function allEngineDeps(pkg) {
|
|
8
|
+
return Object.fromEntries(Object.entries({ ...pkg.dependencies, ...pkg.devDependencies }).filter(([name]) => name.startsWith("@jgengine/")));
|
|
9
|
+
}
|
|
10
|
+
export function diagnose(dir) {
|
|
11
|
+
const findings = [];
|
|
12
|
+
const pkg = readPackageJson(join(dir, "package.json"));
|
|
13
|
+
if (pkg === null) {
|
|
14
|
+
return [{ ok: false, label: "package.json readable", fix: `no package.json found in ${dir}` }];
|
|
15
|
+
}
|
|
16
|
+
const engineDeps = allEngineDeps(pkg);
|
|
17
|
+
findings.push({
|
|
18
|
+
ok: Object.keys(engineDeps).length > 0,
|
|
19
|
+
label: "@jgengine/* dependencies declared",
|
|
20
|
+
fix: "add the engine: bun add @jgengine/core @jgengine/react @jgengine/shell (or npx jgengine create for a fresh game)",
|
|
21
|
+
});
|
|
22
|
+
findings.push({
|
|
23
|
+
ok: pkg.type === "module",
|
|
24
|
+
label: 'package.json "type": "module"',
|
|
25
|
+
fix: 'the engine is ESM-only; set "type": "module"',
|
|
26
|
+
});
|
|
27
|
+
findings.push({
|
|
28
|
+
ok: pkg.scripts?.dev !== undefined,
|
|
29
|
+
label: '"dev" script present',
|
|
30
|
+
fix: 'add "dev": "vite" so the standalone harness can launch',
|
|
31
|
+
});
|
|
32
|
+
const ranges = new Set(Object.values(engineDeps).filter((range) => range !== "workspace:*"));
|
|
33
|
+
findings.push({
|
|
34
|
+
ok: ranges.size <= 1,
|
|
35
|
+
label: "@jgengine/* versions aligned",
|
|
36
|
+
fix: `mixed ranges ${[...ranges].join(", ")} — pin every @jgengine/* package to one version, or run npx jgengine versions`,
|
|
37
|
+
});
|
|
38
|
+
const workspaceRoot = findWorkspaceRoot(dir);
|
|
39
|
+
const usesWorkspaceProtocol = Object.values(engineDeps).includes("workspace:*");
|
|
40
|
+
findings.push({
|
|
41
|
+
ok: !usesWorkspaceProtocol || workspaceRoot !== null,
|
|
42
|
+
label: "workspace:* deps only inside a workspace",
|
|
43
|
+
fix: "this game was copied out of the engine monorepo — replace workspace:* with published versions (e.g. ^0.8.0)",
|
|
44
|
+
});
|
|
45
|
+
const tsconfigPath = join(dir, "tsconfig.json");
|
|
46
|
+
const tsconfig = existsSync(tsconfigPath)
|
|
47
|
+
? JSON.parse(readFileSync(tsconfigPath, "utf8"))
|
|
48
|
+
: null;
|
|
49
|
+
findings.push({ ok: tsconfig !== null, label: "tsconfig.json present", fix: "scaffold one with npx jgengine create" });
|
|
50
|
+
const paths = tsconfig?.compilerOptions?.paths ?? {};
|
|
51
|
+
const monorepoPaths = Object.values(paths)
|
|
52
|
+
.flat()
|
|
53
|
+
.filter((target) => target.includes("../../packages/"));
|
|
54
|
+
const danglingPaths = monorepoPaths.filter((target) => !existsSync(resolve(dir, target.replace(/\/\*$/, ""))));
|
|
55
|
+
findings.push({
|
|
56
|
+
ok: danglingPaths.length === 0,
|
|
57
|
+
label: "tsconfig paths resolve",
|
|
58
|
+
fix: `paths point into a missing engine checkout (${danglingPaths[0] ?? ""}) — outside the monorepo, drop compilerOptions.paths and rely on node_modules`,
|
|
59
|
+
});
|
|
60
|
+
if (workspaceRoot !== null && usesWorkspaceProtocol) {
|
|
61
|
+
const missing = Object.entries(IN_REPO_TSCONFIG_PATHS).filter(([alias, target]) => paths[alias]?.[0] !== target[0]);
|
|
62
|
+
findings.push({
|
|
63
|
+
ok: missing.length === 0,
|
|
64
|
+
label: "in-repo tsconfig paths match the canonical game shape",
|
|
65
|
+
fix: `add compilerOptions.paths ${missing.map(([alias]) => `"${alias}"`).join(", ")} per Games/* (see check-game-shape)`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
|
|
69
|
+
const usesReactPkg = engineDeps["@jgengine/react"] !== undefined || engineDeps["@jgengine/shell"] !== undefined;
|
|
70
|
+
if (usesReactPkg) {
|
|
71
|
+
findings.push({
|
|
72
|
+
ok: deps.react !== undefined && deps["react-dom"] !== undefined,
|
|
73
|
+
label: "react + react-dom present for @jgengine/react|shell",
|
|
74
|
+
fix: "bun add react react-dom (@jgengine/react peers on react ^19)",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (engineDeps["@jgengine/shell"] !== undefined) {
|
|
78
|
+
findings.push({
|
|
79
|
+
ok: deps.three !== undefined && deps["@react-three/fiber"] !== undefined,
|
|
80
|
+
label: "three + @react-three/fiber present for @jgengine/shell",
|
|
81
|
+
fix: "bun add three @react-three/fiber (the shell renders through react-three-fiber)",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
const cssPath = join(dir, "src", "index.css");
|
|
85
|
+
if (existsSync(cssPath)) {
|
|
86
|
+
const css = readFileSync(cssPath, "utf8");
|
|
87
|
+
const usesTailwind = css.includes("tailwindcss");
|
|
88
|
+
const coversReact = /@source\s+"[^"]*(@jgengine\/react|packages\/react)/.test(css);
|
|
89
|
+
const coversShell = /@source\s+"[^"]*(@jgengine\/shell|packages\/shell)/.test(css);
|
|
90
|
+
findings.push({
|
|
91
|
+
ok: !usesTailwind || (coversReact && coversShell),
|
|
92
|
+
label: "Tailwind @source covers @jgengine/react and @jgengine/shell",
|
|
93
|
+
fix: 'without @source entries for the engine UI packages, HUD classes are silently not generated and the UI renders unstyled — add @source "../node_modules/@jgengine/react/dist" and "../node_modules/@jgengine/shell/dist" to src/index.css',
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
findings.push({ ok: false, label: "src/index.css present", fix: "the standalone harness needs src/index.css importing tailwindcss" });
|
|
98
|
+
}
|
|
99
|
+
findings.push({
|
|
100
|
+
ok: existsSync(join(dir, "index.html")),
|
|
101
|
+
label: "index.html present (standalone harness)",
|
|
102
|
+
fix: "scaffold shape: index.html at the game root mounts /src/main.tsx",
|
|
103
|
+
});
|
|
104
|
+
findings.push({
|
|
105
|
+
ok: existsSync(join(dir, "vite.config.ts")),
|
|
106
|
+
label: "vite.config.ts present",
|
|
107
|
+
fix: "the standalone harness runs on vite with @vitejs/plugin-react + @tailwindcss/vite",
|
|
108
|
+
});
|
|
109
|
+
const configPath = join(dir, "src", "game.config.ts");
|
|
110
|
+
const configOk = existsSync(configPath) && /from\s+["']@jgengine\/shell\/defineGame["']/.test(readFileSync(configPath, "utf8"));
|
|
111
|
+
findings.push({
|
|
112
|
+
ok: configOk,
|
|
113
|
+
label: 'src/game.config.ts defines the game via defineGame from "@jgengine/shell/defineGame"',
|
|
114
|
+
fix: "game.config.ts is the single entry — export const game = defineGame({...})",
|
|
115
|
+
});
|
|
116
|
+
const barrelPath = join(dir, "src", "index.tsx");
|
|
117
|
+
const barrelOk = existsSync(barrelPath) &&
|
|
118
|
+
/\bgame\b/.test(readFileSync(barrelPath, "utf8").match(/export\s*\{[^}]*\}/g)?.join(" ") ?? "");
|
|
119
|
+
findings.push({
|
|
120
|
+
ok: barrelOk,
|
|
121
|
+
label: "src/index.tsx re-exports { game }",
|
|
122
|
+
fix: 'add: export { game } from "./game.config";',
|
|
123
|
+
});
|
|
124
|
+
const srcDir = join(dir, "src");
|
|
125
|
+
if (existsSync(srcDir)) {
|
|
126
|
+
const strays = readdirSync(srcDir).filter((entry) => {
|
|
127
|
+
const isDir = statSync(join(srcDir, entry)).isDirectory();
|
|
128
|
+
return isDir ? !SKELETON_DIRS.has(entry) : !SKELETON_FILES.has(entry);
|
|
129
|
+
});
|
|
130
|
+
findings.push({
|
|
131
|
+
ok: strays.length === 0,
|
|
132
|
+
label: "src/ holds only the skeleton (everything else under src/game/)",
|
|
133
|
+
fix: `move ${strays.join(", ")} under src/game/ — src/ is only game.config.ts, index.tsx, main.tsx, loop.ts, world.ts, index.css`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!usesWorkspaceProtocol) {
|
|
137
|
+
findings.push({
|
|
138
|
+
ok: existsSync(join(dir, "node_modules", "@jgengine", "core")),
|
|
139
|
+
label: "engine installed in node_modules",
|
|
140
|
+
fix: "run bun install (or npm install)",
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return findings;
|
|
144
|
+
}
|
|
145
|
+
export function runDoctor(argv) {
|
|
146
|
+
const dir = resolve(argv.find((arg) => !arg.startsWith("--")) ?? ".");
|
|
147
|
+
const findings = diagnose(dir);
|
|
148
|
+
for (const finding of findings) {
|
|
149
|
+
if (finding.ok) {
|
|
150
|
+
console.log(` ✓ ${finding.label}`);
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
console.log(` ✗ ${finding.label}`);
|
|
154
|
+
if (finding.fix !== undefined)
|
|
155
|
+
console.log(` fix: ${finding.fix}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const failed = findings.filter((finding) => !finding.ok).length;
|
|
159
|
+
console.log(failed === 0 ? "\ndoctor: all clear" : `\ndoctor: ${failed} problem(s) found`);
|
|
160
|
+
return failed === 0 ? 0 : 1;
|
|
161
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { gameTemplate, displayNameFromId, GAME_ID_PATTERN, IN_REPO_TSCONFIG_PATHS } from "./templates.js";
|
|
2
|
+
export type { TemplateFile, TemplateOptions, TemplateVariant } from "./templates.js";
|
|
3
|
+
export { runCreate, writeGame, registerRootGameScript } from "./create.js";
|
|
4
|
+
export { diagnose, runDoctor } from "./doctor.js";
|
|
5
|
+
export type { Finding } from "./doctor.js";
|
|
6
|
+
export { cliVersion } from "./pkg.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { gameTemplate, displayNameFromId, GAME_ID_PATTERN, IN_REPO_TSCONFIG_PATHS } from "./templates.js";
|
|
2
|
+
export { runCreate, writeGame, registerRootGameScript } from "./create.js";
|
|
3
|
+
export { diagnose, runDoctor } from "./doctor.js";
|
|
4
|
+
export { cliVersion } from "./pkg.js";
|
package/dist/pkg.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
interface PackageJson {
|
|
2
|
+
name?: string;
|
|
3
|
+
version?: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
workspaces?: string[];
|
|
6
|
+
scripts?: Record<string, string>;
|
|
7
|
+
dependencies?: Record<string, string>;
|
|
8
|
+
devDependencies?: Record<string, string>;
|
|
9
|
+
peerDependencies?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
export type { PackageJson };
|
|
12
|
+
export declare function readPackageJson(path: string): PackageJson | null;
|
|
13
|
+
export declare function cliVersion(): string;
|
|
14
|
+
export declare function findUp(startDir: string, predicate: (dir: string) => boolean): string | null;
|
|
15
|
+
export declare function findWorkspaceRoot(startDir: string): string | null;
|
|
16
|
+
export declare function isEngineMonorepo(rootDir: string): boolean;
|
|
17
|
+
export declare function flag(argv: string[], name: string): string | undefined;
|
|
18
|
+
export declare function hasFlag(argv: string[], name: string): boolean;
|
package/dist/pkg.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
export function readPackageJson(path) {
|
|
5
|
+
if (!existsSync(path))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function cliVersion() {
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const own = readPackageJson(join(here, "..", "package.json")) ?? readPackageJson(join(here, "..", "..", "package.json"));
|
|
17
|
+
return own?.version ?? "0.0.0";
|
|
18
|
+
}
|
|
19
|
+
export function findUp(startDir, predicate) {
|
|
20
|
+
let dir = resolve(startDir);
|
|
21
|
+
for (;;) {
|
|
22
|
+
if (predicate(dir))
|
|
23
|
+
return dir;
|
|
24
|
+
const parent = dirname(dir);
|
|
25
|
+
if (parent === dir)
|
|
26
|
+
return null;
|
|
27
|
+
dir = parent;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function findWorkspaceRoot(startDir) {
|
|
31
|
+
return findUp(startDir, (dir) => {
|
|
32
|
+
const pkg = readPackageJson(join(dir, "package.json"));
|
|
33
|
+
return Array.isArray(pkg?.workspaces);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export function isEngineMonorepo(rootDir) {
|
|
37
|
+
return existsSync(join(rootDir, "packages", "core", "src")) && existsSync(join(rootDir, "Games"));
|
|
38
|
+
}
|
|
39
|
+
export function flag(argv, name) {
|
|
40
|
+
const index = argv.indexOf(`--${name}`);
|
|
41
|
+
return index >= 0 ? argv[index + 1] : undefined;
|
|
42
|
+
}
|
|
43
|
+
export function hasFlag(argv, name) {
|
|
44
|
+
return argv.includes(`--${name}`);
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TemplateVariant = "standalone" | "in-repo";
|
|
2
|
+
export interface TemplateOptions {
|
|
3
|
+
id: string;
|
|
4
|
+
name: string;
|
|
5
|
+
variant: TemplateVariant;
|
|
6
|
+
engineVersion: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TemplateFile {
|
|
9
|
+
path: string;
|
|
10
|
+
contents: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const GAME_ID_PATTERN: RegExp;
|
|
13
|
+
export declare function displayNameFromId(id: string): string;
|
|
14
|
+
export declare const IN_REPO_TSCONFIG_PATHS: Record<string, string[]>;
|
|
15
|
+
export declare function gameTemplate(options: TemplateOptions): TemplateFile[];
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
export const GAME_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
2
|
+
export function displayNameFromId(id) {
|
|
3
|
+
return id
|
|
4
|
+
.split("-")
|
|
5
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
|
6
|
+
.join(" ");
|
|
7
|
+
}
|
|
8
|
+
const indexHtml = (name) => `<!doctype html>
|
|
9
|
+
<html lang="en">
|
|
10
|
+
<head>
|
|
11
|
+
<meta charset="UTF-8" />
|
|
12
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
|
13
|
+
<title>${name}</title>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="root"></div>
|
|
17
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
20
|
+
`;
|
|
21
|
+
const viteConfig = `import { existsSync } from "node:fs";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
24
|
+
import react from "@vitejs/plugin-react";
|
|
25
|
+
import { defineConfig } from "vite";
|
|
26
|
+
|
|
27
|
+
const engineSrc = (pkg: string) => fileURLToPath(new URL(\`../../packages/\${pkg}/src\`, import.meta.url));
|
|
28
|
+
|
|
29
|
+
export default defineConfig({
|
|
30
|
+
plugins: [react(), tailwindcss()],
|
|
31
|
+
clearScreen: false,
|
|
32
|
+
build: { target: "es2022" },
|
|
33
|
+
resolve: {
|
|
34
|
+
extensions: [".ts", ".tsx", ".mjs", ".js", ".jsx", ".json"],
|
|
35
|
+
alias: existsSync(engineSrc("core"))
|
|
36
|
+
? [
|
|
37
|
+
{ find: /^@jgengine\\/core\\/(.*)$/, replacement: \`\${engineSrc("core")}/$1\` },
|
|
38
|
+
{ find: /^@jgengine\\/react\\/(.*)$/, replacement: \`\${engineSrc("react")}/$1\` },
|
|
39
|
+
{ find: /^@jgengine\\/ws\\/(.*)$/, replacement: \`\${engineSrc("ws")}/$1\` },
|
|
40
|
+
{ find: /^@jgengine\\/shell\\/(.*)$/, replacement: \`\${engineSrc("shell")}/$1\` },
|
|
41
|
+
{ find: /^@jgengine\\/assets$/, replacement: \`\${engineSrc("assets")}/index.ts\` },
|
|
42
|
+
{ find: /^@jgengine\\/assets\\/(.*)$/, replacement: \`\${engineSrc("assets")}/$1\` },
|
|
43
|
+
]
|
|
44
|
+
: [],
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
`;
|
|
48
|
+
const standalonePackageJson = (id, engineVersion) => `${JSON.stringify({
|
|
49
|
+
name: id,
|
|
50
|
+
version: "0.1.0",
|
|
51
|
+
private: true,
|
|
52
|
+
type: "module",
|
|
53
|
+
scripts: {
|
|
54
|
+
dev: "vite",
|
|
55
|
+
build: "vite build",
|
|
56
|
+
preview: "vite preview",
|
|
57
|
+
"check-types": "tsc --noEmit -p tsconfig.json",
|
|
58
|
+
test: "bun test src",
|
|
59
|
+
},
|
|
60
|
+
dependencies: {
|
|
61
|
+
"@jgengine/core": `^${engineVersion}`,
|
|
62
|
+
"@jgengine/react": `^${engineVersion}`,
|
|
63
|
+
"@jgengine/shell": `^${engineVersion}`,
|
|
64
|
+
"@react-three/fiber": "^9.5.0",
|
|
65
|
+
react: "19.2.3",
|
|
66
|
+
"react-dom": "19.2.3",
|
|
67
|
+
three: "^0.182.0",
|
|
68
|
+
},
|
|
69
|
+
devDependencies: {
|
|
70
|
+
"@tailwindcss/vite": "^4.0.15",
|
|
71
|
+
"@types/react": "^19",
|
|
72
|
+
"@types/react-dom": "^19",
|
|
73
|
+
"@types/three": "^0.182.0",
|
|
74
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
75
|
+
tailwindcss: "^4.0.15",
|
|
76
|
+
typescript: "^5",
|
|
77
|
+
vite: "^6.2.2",
|
|
78
|
+
},
|
|
79
|
+
}, null, 2)}
|
|
80
|
+
`;
|
|
81
|
+
const inRepoPackageJson = (id) => `${JSON.stringify({
|
|
82
|
+
name: `@games/${id}`,
|
|
83
|
+
version: "0.1.0",
|
|
84
|
+
private: true,
|
|
85
|
+
type: "module",
|
|
86
|
+
scripts: {
|
|
87
|
+
dev: "vite",
|
|
88
|
+
"check-types": "tsgo --noEmit -p tsconfig.json",
|
|
89
|
+
test: "bun test src",
|
|
90
|
+
},
|
|
91
|
+
dependencies: {
|
|
92
|
+
"@jgengine/core": "workspace:*",
|
|
93
|
+
"@jgengine/react": "workspace:*",
|
|
94
|
+
"@jgengine/shell": "workspace:*",
|
|
95
|
+
"@react-three/fiber": "^9.5.0",
|
|
96
|
+
react: "19.2.3",
|
|
97
|
+
"react-dom": "19.2.3",
|
|
98
|
+
three: "^0.182.0",
|
|
99
|
+
},
|
|
100
|
+
devDependencies: {
|
|
101
|
+
"@tailwindcss/vite": "^4.0.15",
|
|
102
|
+
"@types/react": "^19",
|
|
103
|
+
"@types/react-dom": "^19",
|
|
104
|
+
"@types/three": "^0.182.0",
|
|
105
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
106
|
+
tailwindcss: "^4.0.15",
|
|
107
|
+
typescript: "^5",
|
|
108
|
+
vite: "^6.2.2",
|
|
109
|
+
},
|
|
110
|
+
exports: {
|
|
111
|
+
".": "./src/index.tsx",
|
|
112
|
+
"./*": "./src/*",
|
|
113
|
+
},
|
|
114
|
+
}, null, 2)}
|
|
115
|
+
`;
|
|
116
|
+
const sharedCompilerOptions = {
|
|
117
|
+
target: "ES2022",
|
|
118
|
+
lib: ["ES2022", "DOM"],
|
|
119
|
+
module: "ESNext",
|
|
120
|
+
moduleResolution: "bundler",
|
|
121
|
+
jsx: "react-jsx",
|
|
122
|
+
strict: true,
|
|
123
|
+
skipLibCheck: true,
|
|
124
|
+
isolatedModules: true,
|
|
125
|
+
verbatimModuleSyntax: true,
|
|
126
|
+
types: ["vite/client"],
|
|
127
|
+
};
|
|
128
|
+
export const IN_REPO_TSCONFIG_PATHS = {
|
|
129
|
+
"@jgengine/core/*": ["../../packages/core/src/*"],
|
|
130
|
+
"@jgengine/react/*": ["../../packages/react/src/*"],
|
|
131
|
+
"@jgengine/ws/*": ["../../packages/ws/src/*"],
|
|
132
|
+
"@jgengine/shell/*": ["../../packages/shell/src/*"],
|
|
133
|
+
"@jgengine/assets": ["../../packages/assets/src/index.ts"],
|
|
134
|
+
"@jgengine/assets/*": ["../../packages/assets/src/*"],
|
|
135
|
+
};
|
|
136
|
+
const tsconfigJson = (variant) => `${JSON.stringify({
|
|
137
|
+
compilerOptions: variant === "in-repo" ? { ...sharedCompilerOptions, paths: IN_REPO_TSCONFIG_PATHS } : sharedCompilerOptions,
|
|
138
|
+
include: ["src"],
|
|
139
|
+
exclude: ["src/**/*.test.ts"],
|
|
140
|
+
}, null, 2)}
|
|
141
|
+
`;
|
|
142
|
+
const indexCss = (variant) => `@import "tailwindcss";
|
|
143
|
+
${variant === "in-repo"
|
|
144
|
+
? `@source "../../../packages/react/src";
|
|
145
|
+
@source "../../../packages/shell/src";`
|
|
146
|
+
: `@source "../node_modules/@jgengine/react/dist";
|
|
147
|
+
@source "../node_modules/@jgengine/shell/dist";`}
|
|
148
|
+
|
|
149
|
+
html,
|
|
150
|
+
body,
|
|
151
|
+
#root {
|
|
152
|
+
height: 100%;
|
|
153
|
+
margin: 0;
|
|
154
|
+
background: #0a0a0a;
|
|
155
|
+
}
|
|
156
|
+
`;
|
|
157
|
+
const mainTsx = `import "./index.css";
|
|
158
|
+
|
|
159
|
+
import { createRoot } from "react-dom/client";
|
|
160
|
+
|
|
161
|
+
import { GameHost } from "@jgengine/shell/GameHost";
|
|
162
|
+
|
|
163
|
+
import { game } from "./game.config";
|
|
164
|
+
|
|
165
|
+
const root = document.getElementById("root");
|
|
166
|
+
if (root === null) throw new Error("main: missing #root mount element");
|
|
167
|
+
createRoot(root).render(<GameHost playable={game} />);
|
|
168
|
+
`;
|
|
169
|
+
const indexTsx = `export { game } from "./game.config";
|
|
170
|
+
`;
|
|
171
|
+
const gameConfigTs = (name) => `import { createAssetCatalog } from "@jgengine/core/scene/assetCatalog";
|
|
172
|
+
import { defineGame } from "@jgengine/shell/defineGame";
|
|
173
|
+
|
|
174
|
+
import { entityById } from "./game/content";
|
|
175
|
+
import { keybinds } from "./game/keybinds";
|
|
176
|
+
import { GameUI } from "./game/ui/GameUI";
|
|
177
|
+
import { onInit, onNewPlayer, onTick } from "./loop";
|
|
178
|
+
import { physics, world } from "./world";
|
|
179
|
+
|
|
180
|
+
export const game = defineGame({
|
|
181
|
+
name: "${name}",
|
|
182
|
+
assets: createAssetCatalog(),
|
|
183
|
+
world,
|
|
184
|
+
physics,
|
|
185
|
+
input: keybinds,
|
|
186
|
+
server: { mode: "single" },
|
|
187
|
+
save: "none",
|
|
188
|
+
content: { entityById },
|
|
189
|
+
loop: { onInit, onNewPlayer, onTick },
|
|
190
|
+
GameUI,
|
|
191
|
+
});
|
|
192
|
+
`;
|
|
193
|
+
const worldTs = (id) => `import type { PhysicsConfig } from "@jgengine/core/game/defineGame";
|
|
194
|
+
import { environment, grass, sky, terrain, type EnvironmentWorldFeature } from "@jgengine/core/world/features";
|
|
195
|
+
|
|
196
|
+
export const world: EnvironmentWorldFeature = environment({
|
|
197
|
+
terrain: terrain({ bounds: { w: 96, d: 96 }, height: 0, material: "grass" }),
|
|
198
|
+
sky: sky({ preset: "day" }),
|
|
199
|
+
vegetation: grass({ area: { w: 80, d: 80 }, density: 2, colors: ["#3f7d2d", "#6bbf4a"], seed: "${id}" }),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
export const physics: PhysicsConfig = { gravity: -24 };
|
|
203
|
+
`;
|
|
204
|
+
const loopTs = `import type { GameContext } from "@jgengine/core/runtime/gameContext";
|
|
205
|
+
|
|
206
|
+
import { PLAYER, SPAWN } from "./game/tuning";
|
|
207
|
+
|
|
208
|
+
export function onInit(_ctx: GameContext): void {}
|
|
209
|
+
|
|
210
|
+
export function onNewPlayer(ctx: GameContext): void {
|
|
211
|
+
ctx.scene.entity.spawn(PLAYER, { id: ctx.player.userId, position: SPAWN });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function onTick(_ctx: GameContext, _dt: number): void {}
|
|
215
|
+
`;
|
|
216
|
+
const tuningTs = `export const PLAYER = "player";
|
|
217
|
+
export const MAX_HEALTH = 5;
|
|
218
|
+
export const WALK_SPEED = 4;
|
|
219
|
+
export const SPAWN: [number, number, number] = [0, 0, 0];
|
|
220
|
+
`;
|
|
221
|
+
const contentTs = `import type { GameContextEntityEntry } from "@jgengine/core/runtime/gameContext";
|
|
222
|
+
|
|
223
|
+
import { MAX_HEALTH, PLAYER, WALK_SPEED } from "./tuning";
|
|
224
|
+
|
|
225
|
+
export function entityById(catalogId: string): GameContextEntityEntry | null {
|
|
226
|
+
if (catalogId === PLAYER) {
|
|
227
|
+
return {
|
|
228
|
+
role: "player",
|
|
229
|
+
stats: { health: { max: MAX_HEALTH, min: 0 } },
|
|
230
|
+
receive: { damage: { order: ["health"] } },
|
|
231
|
+
movement: { walkSpeed: WALK_SPEED },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
`;
|
|
237
|
+
const keybindsTs = `import type { ActionCodesMap } from "@jgengine/core/input/actionBindings";
|
|
238
|
+
|
|
239
|
+
export const keybinds: ActionCodesMap = {
|
|
240
|
+
interact: ["KeyE"],
|
|
241
|
+
};
|
|
242
|
+
`;
|
|
243
|
+
const gameUiTsx = (name) => `import { useEntityStat, usePlayer } from "@jgengine/react/hooks";
|
|
244
|
+
|
|
245
|
+
import { MAX_HEALTH } from "../tuning";
|
|
246
|
+
|
|
247
|
+
function Hearts({ userId }: { userId: string }) {
|
|
248
|
+
const health = useEntityStat(userId, "health");
|
|
249
|
+
const current = health === null ? MAX_HEALTH : Math.round(health.current);
|
|
250
|
+
const max = health === null ? MAX_HEALTH : Math.round(health.max);
|
|
251
|
+
return (
|
|
252
|
+
<div className="flex items-center gap-1" aria-label="health">
|
|
253
|
+
{Array.from({ length: max }, (_, index) => (
|
|
254
|
+
<span
|
|
255
|
+
key={index}
|
|
256
|
+
className={index < current ? "text-lg leading-none text-rose-400" : "text-lg leading-none text-white/20"}
|
|
257
|
+
>
|
|
258
|
+
{index < current ? "\\u2665" : "\\u2661"}
|
|
259
|
+
</span>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function GameUI() {
|
|
266
|
+
const { userId } = usePlayer();
|
|
267
|
+
return (
|
|
268
|
+
<div className="pointer-events-none absolute inset-0 p-4 text-white">
|
|
269
|
+
<div className="flex w-fit items-center gap-4 rounded-lg bg-black/40 px-3 py-2 backdrop-blur">
|
|
270
|
+
<span className="text-sm font-semibold tracking-wide">${name}</span>
|
|
271
|
+
<Hearts userId={userId} />
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
`;
|
|
277
|
+
const worldTest = (id) => `import { describe, expect, test } from "bun:test";
|
|
278
|
+
import { summarizeEnvironment } from "@jgengine/core/world/environmentSummary";
|
|
279
|
+
|
|
280
|
+
import { world } from "../world";
|
|
281
|
+
|
|
282
|
+
describe("${id} world", () => {
|
|
283
|
+
const summary = summarizeEnvironment(world);
|
|
284
|
+
|
|
285
|
+
test("renders a populated scene", () => {
|
|
286
|
+
expect(summary.isEmpty).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
test("has the expected backdrop content", () => {
|
|
290
|
+
expect(summary.counts.terrain).toBe(1);
|
|
291
|
+
expect(summary.counts.vegetationFields).toBe(1);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("terrain resolves to a finite ground plane", () => {
|
|
295
|
+
expect(summary.terrain?.height.finite).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
`;
|
|
299
|
+
const agentsMd = (name, variant) => `# ${name} — agent notes
|
|
300
|
+
|
|
301
|
+
This is a JGengine game (pure-TypeScript engine SDK, \`@jgengine/*\` on npm). Load context before writing code:
|
|
302
|
+
|
|
303
|
+
- Engine API surface: \`npx jgengine llms core\` (any package name works) prints the packaged API docs.
|
|
304
|
+
- Agent skills — API reference, phased game-build workflow, browserless verify gate: \`npx jgengine skills\` installs them into \`.claude/skills\`.
|
|
305
|
+
- Setup broken or UI unstyled: \`npx jgengine doctor\`.
|
|
306
|
+
|
|
307
|
+
Rules this project follows:
|
|
308
|
+
|
|
309
|
+
- Shape: \`src/\` holds only \`game.config.ts\`, \`index.tsx\`, \`main.tsx\`, \`loop.ts\`, \`world.ts\`, \`index.css\`; every game-specific module, UI component, and test lives under \`src/game/\`.
|
|
310
|
+
- \`game.config.ts\` is the single entry — \`defineGame({...})\` from \`@jgengine/shell/defineGame\`.
|
|
311
|
+
- Scene and world changes are proven with \`summarizeEnvironment\` assertions in \`bun test src\` (see \`src/game/world.world.test.ts\`), never by screenshots as the inner loop.
|
|
312
|
+
- HUD styling is Tailwind v4: the \`@source\` entries in \`src/index.css\` must cover \`@jgengine/react\` and \`@jgengine/shell\`${variant === "in-repo" ? " (engine source dirs in this monorepo)" : " (their dist dirs in node_modules)"}, or engine UI classes are silently not generated and the HUD renders unstyled.
|
|
313
|
+
- \`onNewPlayer\` spawns the player entity with \`id === ctx.player.userId\`; \`onTick\`'s \`dt\` is game time, never wall-clock.
|
|
314
|
+
`;
|
|
315
|
+
export function gameTemplate(options) {
|
|
316
|
+
const { id, name, variant, engineVersion } = options;
|
|
317
|
+
if (!GAME_ID_PATTERN.test(id)) {
|
|
318
|
+
throw new Error(`game id "${id}" must be kebab-case: lowercase letters, digits, dashes, starting with a letter`);
|
|
319
|
+
}
|
|
320
|
+
return [
|
|
321
|
+
{ path: "index.html", contents: indexHtml(name) },
|
|
322
|
+
{ path: "vite.config.ts", contents: viteConfig },
|
|
323
|
+
{
|
|
324
|
+
path: "package.json",
|
|
325
|
+
contents: variant === "in-repo" ? inRepoPackageJson(id) : standalonePackageJson(id, engineVersion),
|
|
326
|
+
},
|
|
327
|
+
{ path: "tsconfig.json", contents: tsconfigJson(variant) },
|
|
328
|
+
{ path: "AGENTS.md", contents: agentsMd(name, variant) },
|
|
329
|
+
{ path: "src/index.css", contents: indexCss(variant) },
|
|
330
|
+
{ path: "src/main.tsx", contents: mainTsx },
|
|
331
|
+
{ path: "src/index.tsx", contents: indexTsx },
|
|
332
|
+
{ path: "src/game.config.ts", contents: gameConfigTs(name) },
|
|
333
|
+
{ path: "src/loop.ts", contents: loopTs },
|
|
334
|
+
{ path: "src/world.ts", contents: worldTs(id) },
|
|
335
|
+
{ path: "src/game/tuning.ts", contents: tuningTs },
|
|
336
|
+
{ path: "src/game/content.ts", contents: contentTs },
|
|
337
|
+
{ path: "src/game/keybinds.ts", contents: keybindsTs },
|
|
338
|
+
{ path: "src/game/ui/GameUI.tsx", contents: gameUiTsx(name) },
|
|
339
|
+
{ path: "src/game/world.world.test.ts", contents: worldTest(id) },
|
|
340
|
+
];
|
|
341
|
+
}
|