@homie-rocks/studio 0.2.0 → 0.3.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/README.md +14 -2
- package/bin/homie-studio.mjs +34 -3
- package/lib/build.mjs +83 -21
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +6 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +11 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +19 -0
- package/worker/pages.mjs +30 -10
- package/worker/qr.mjs +501 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Most people never run this by hand: the Homie plugin for Claude Code and Codex d
|
|
|
10
10
|
and the person approves Cloudflare once in their browser.
|
|
11
11
|
|
|
12
12
|
```sh
|
|
13
|
-
npx -y --package=https://homie.rocks/npm/homie-studio-0.
|
|
13
|
+
npx -y --package=https://homie.rocks/npm/homie-studio-0.3.0.tgz homie-studio new ./night-owls --name "Night Owls"
|
|
14
14
|
cd night-owls && npm install
|
|
15
15
|
npx homie-studio game new crown-thief --from gem-rush --name "Crown Thief"
|
|
16
16
|
npx homie-studio dev # the whole site locally
|
|
@@ -19,6 +19,15 @@ npx homie-studio deploy # the studio's own Cloudf
|
|
|
19
19
|
npx homie-studio publish # the homie.rocks directory
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
An existing single-player web game becomes a multiplayer one with the port toolkit
|
|
23
|
+
(the plugin's `port` skill drives it):
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
npx homie-studio port plan ../my-game # grade it: easy, medium, hard or not a fit, and why
|
|
27
|
+
npx homie-studio port import ../my-game --id my-game # bring it into games/ (static, bundle or its own build)
|
|
28
|
+
npx homie-studio port check my-game --url http://127.0.0.1:8787 # the owner tests, real touch, host kill, late joiner, a round
|
|
29
|
+
```
|
|
30
|
+
|
|
22
31
|
| Path | What it is |
|
|
23
32
|
| --- | --- |
|
|
24
33
|
| `bin/homie-studio.mjs` | The CLI. |
|
|
@@ -26,7 +35,10 @@ npx homie-studio publish # the homie.rocks directo
|
|
|
26
35
|
| `lib/build.mjs` | `build`: games bundled with esbuild into `site/dist`, `games.json`, each game's shared `source.json`. |
|
|
27
36
|
| `lib/cloudflare.mjs` | `deploy`: Wrangler, D1, R2, migrations, the directory claim; refuses resources it did not create. |
|
|
28
37
|
| `lib/check.mjs` | `check`: two fresh Chrome processes (computer + phone) must share a room and finish a round. |
|
|
29
|
-
| `
|
|
38
|
+
| `lib/port.mjs` | `port plan` (reads a game and grades the port) and `port import`. |
|
|
39
|
+
| `lib/port-check.mjs` | `port check`: held and alternating directions on keys, Android Chrome and iPhone WebKit touch, UI cover, two browsers finishing a round, a killed host, a late joiner, the big screen. |
|
|
40
|
+
| `port/` | The port toolkit (`@homie-rocks/studio/port`, or `window.HomiePort` from `homie-port.js` in a static game): `createRoom`, the touch kit, keys, camera rules, bots, a HUD, sandbox shims, first-touch audio, `exposePort`. |
|
|
41
|
+
| `worker/index.mjs` | The site Worker and the `Table` (netplay relay, `room.mjs`) and `Lobby` Durable Objects; `/<game>/tv` is the big screen with a join QR (`qr.mjs`). |
|
|
30
42
|
| `netplay/` | The netplay contract (`NETPLAY.md`) and its game helper (`@homie-rocks/studio/netplay`). |
|
|
31
43
|
| `starters/gem-rush/` | The reference multiplayer starter. |
|
|
32
44
|
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
* homie-studio build [<id>]
|
|
12
12
|
* homie-studio dev [--port 8787]
|
|
13
13
|
* homie-studio check <id> [--url <site>] [--shots <dir>]
|
|
14
|
+
* homie-studio port plan <game folder>
|
|
15
|
+
* homie-studio port import <game folder> --id <id> [--name "<Name>"] [--mode static|bundle|command]
|
|
16
|
+
* homie-studio port check <id> [--url <site>] [--only owner-desk,owner-phone,owner-iphone,round,life,tv] [--shots <dir>]
|
|
14
17
|
* homie-studio deploy
|
|
15
18
|
* homie-studio publish
|
|
16
19
|
* homie-studio media put <file> [--as <key>]
|
|
@@ -25,19 +28,22 @@ import { build } from '../lib/build.mjs';
|
|
|
25
28
|
import { check } from '../lib/check.mjs';
|
|
26
29
|
import { deploy, whoami, wranglerBin } from '../lib/cloudflare.mjs';
|
|
27
30
|
import { publish } from '../lib/directory.mjs';
|
|
31
|
+
import { importPort, planPort } from '../lib/port.mjs';
|
|
32
|
+
import { portCheck } from '../lib/port-check.mjs';
|
|
28
33
|
import { newStudio } from '../lib/scaffold.mjs';
|
|
29
34
|
import { listGames, newGame, readStudio, remixGame, requireStudio, starters, writeStudio } from '../lib/studio.mjs';
|
|
30
35
|
import { STUDIO_VERSION } from '../lib/version.mjs';
|
|
31
36
|
|
|
32
37
|
const argv = process.argv.slice(2);
|
|
33
38
|
const flags = new Map();
|
|
39
|
+
const BOOL_FLAGS = ['json', 'yes', 'detach', 'no-install'];
|
|
34
40
|
const positional = [];
|
|
35
41
|
for (let i = 0; i < argv.length; i++) {
|
|
36
42
|
const a = argv[i];
|
|
37
43
|
if (a.startsWith('--')) {
|
|
38
44
|
const [k, v] = a.slice(2).split(/=(.*)/s, 2);
|
|
39
45
|
if (v !== undefined) flags.set(k, v);
|
|
40
|
-
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !
|
|
46
|
+
else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') && !BOOL_FLAGS.includes(k)) flags.set(k, argv[++i]);
|
|
41
47
|
else flags.set(k, true);
|
|
42
48
|
} else positional.push(a);
|
|
43
49
|
}
|
|
@@ -46,7 +52,8 @@ const log = asJson ? () => {} : (line) => process.stderr.write(`${line}\n`);
|
|
|
46
52
|
|
|
47
53
|
function print(result) {
|
|
48
54
|
if (asJson) { process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; }
|
|
49
|
-
if (result.ok === false) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
55
|
+
if (result.ok === false && result.command !== 'port check') { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
56
|
+
if (result.ok === false && result.command === 'port check' && !result.rows) { process.stdout.write(`homie-studio: ${result.why ?? 'failed'}\n`); return; }
|
|
50
57
|
const lines = [];
|
|
51
58
|
switch (result.command) {
|
|
52
59
|
case 'new':
|
|
@@ -68,6 +75,22 @@ function print(result) {
|
|
|
68
75
|
case 'publish':
|
|
69
76
|
lines.push(`Listed in the directory: ${result.studioPage ?? result.directory}`, ...(result.games ?? []).map((g) => ` ${g.name}: ${g.play}`));
|
|
70
77
|
break;
|
|
78
|
+
case 'port plan': {
|
|
79
|
+
const f = result.facts;
|
|
80
|
+
lines.push(`Port plan for ${result.folder}`, '', `Difficulty: ${result.grade.toUpperCase()}`, ...result.reasons.map((r) => ` - ${r}`), '',
|
|
81
|
+
`What it is: ${f.engine.join(', ')}; ${f.loc} lines of game code in ${f.files} files (${Math.round(f.bytes / 1024)} KB); ${f.turnBased ? 'turn-based / moves on input' : 'real time'}${f.physics.length ? `; physics: ${f.physics.join(', ')}` : ''}.`,
|
|
82
|
+
`Input: keys ${f.input.keys}, mouse ${f.input.mouse}, touch ${f.input.touch}, pointer ${f.input.pointer}${f.input.pointerLock ? ', pointer lock' : ''}.`,
|
|
83
|
+
`Recommended: movement "${result.recommend.movement}", check view "${result.recommend.view}", build "${result.recommend.build}".`,
|
|
84
|
+
`Licence: ${result.licence.kind}${result.licence.file ? ` (${result.licence.file})` : ''}.`, '', 'Risks:', ...(result.risks.length ? result.risks.map((r) => ` - ${r}`) : [' - none found by reading; the checks will say']));
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
case 'port import':
|
|
88
|
+
lines.push(`games/${result.id} is a port of ${result.from} (${result.files} files, build "${result.mode}", draft grade ${result.plan.grade}).`, ...result.edits.map((e) => ` ${e}`), '', 'Next:', ...result.next.map((n) => ` - ${n}`));
|
|
89
|
+
break;
|
|
90
|
+
case 'port check':
|
|
91
|
+
lines.push(`${result.ok ? 'PASS' : 'NOT YET'}: ${result.game} at ${result.url} (${Math.round(result.totalMs / 1000)} s)`,
|
|
92
|
+
...result.passed.map((p) => ` ok ${p}`), ...result.failed.map((f) => ` FAIL ${f}`), ...result.skipped.map((f) => ` skip ${f}`), '', `Receipt and screenshots: ${result.out}`);
|
|
93
|
+
break;
|
|
71
94
|
case 'check':
|
|
72
95
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
73
96
|
...result.seats.map((s) => ` ${s.browser}: seat ${s.seat} (${s.role}), seated in ${(s.seatedMs / 1000).toFixed(1)} s`));
|
|
@@ -89,7 +112,15 @@ async function main() {
|
|
|
89
112
|
if (cmd === 'new') return newStudio(positional[1], { name: flags.get('name'), homie: flags.get('homie'), slug: flags.get('slug'), install: !flags.has('no-install') });
|
|
90
113
|
if (cmd === 'starters') return { ok: true, command: 'starters', starters: starters() };
|
|
91
114
|
|
|
115
|
+
if (cmd === 'port' && sub === 'plan') return planPort(positional[2] ?? '.');
|
|
92
116
|
const root = requireStudio();
|
|
117
|
+
if (cmd === 'port' && sub === 'import') return importPort(root, positional[2], flags.get('id'), { name: flags.get('name'), mode: flags.get('mode') });
|
|
118
|
+
if (cmd === 'port' && sub === 'check') {
|
|
119
|
+
const game = positional[2] ?? listGames(root)[0]?.id;
|
|
120
|
+
const url = flags.get('url') ?? readStudio(root).cloudflare?.url;
|
|
121
|
+
if (!url) return { ok: false, command: 'port check', why: 'give --url (the local dev address or the live site)' };
|
|
122
|
+
return portCheck({ url, game, root, only: flags.get('only') ?? null, shots: flags.get('shots') ? resolve(flags.get('shots')) : null, log });
|
|
123
|
+
}
|
|
93
124
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
94
125
|
if (cmd === 'game' && sub === 'remix') return remixGame(root, positional[2], flags.get('id'), { name: flags.get('name') });
|
|
95
126
|
if (cmd === 'games') return { ok: true, command: 'games', games: listGames(root).map(({ dir, ...g }) => ({ ...g, dir: relative(root, dir) })) };
|
|
@@ -159,7 +190,7 @@ try {
|
|
|
159
190
|
if (result && result.command !== 'help') print(result);
|
|
160
191
|
if (result?.ok === false) process.exitCode = 1;
|
|
161
192
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
162
|
-
if (result?.command === 'check') process.exit(process.exitCode ?? 0);
|
|
193
|
+
if (result?.command === 'check' || result?.command === 'port check') process.exit(process.exitCode ?? 0);
|
|
163
194
|
} catch (error) {
|
|
164
195
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
165
196
|
process.exitCode = 1;
|
package/lib/build.mjs
CHANGED
|
@@ -6,14 +6,51 @@
|
|
|
6
6
|
* site/dist/games/<id>/assets/main.js its bundle (esbuild; @homie-rocks/studio/netplay inlined)
|
|
7
7
|
* site/dist/games/<id>/... everything in games/<id>/public/
|
|
8
8
|
* site/dist/games.json { studio, games[] } from studio.json and game.json files
|
|
9
|
+
*
|
|
10
|
+
* game.json "build" picks how a game becomes files (a ported game keeps its own shape):
|
|
11
|
+
* (absent) / { "mode": "bundle" } src/main.ts (or "entry") bundled by esbuild — new games and ES-module ports
|
|
12
|
+
* { "mode": "static" } the folder copied as it is (plain <script> games), plus homie-port.js,
|
|
13
|
+
* the port toolkit as one classic script (window.HomiePort); an "entry"
|
|
14
|
+
* is bundled to assets/main.js as well
|
|
15
|
+
* { "mode": "command", "command": "npm run build", "out": "dist" }
|
|
16
|
+
* the game's own build (Vite, webpack…), then its output copied; base must be './'
|
|
9
17
|
*/
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
10
19
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
11
20
|
import { createRequire } from 'node:module';
|
|
12
|
-
import { join } from 'node:path';
|
|
13
|
-
import { listGames, readStudio } from './studio.mjs';
|
|
21
|
+
import { join, relative } from 'node:path';
|
|
22
|
+
import { PACKAGE_ROOT, listGames, readStudio } from './studio.mjs';
|
|
14
23
|
import { STUDIO_VERSION } from './version.mjs';
|
|
15
24
|
|
|
16
|
-
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler']);
|
|
25
|
+
const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
|
|
26
|
+
/** Never copied into a static game's served folder. */
|
|
27
|
+
const STATIC_SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', 'game.json', 'PORT.md']);
|
|
28
|
+
const LOADERS = { '.png': 'file', '.jpg': 'file', '.jpeg': 'file', '.gif': 'file', '.webp': 'file', '.mp3': 'file', '.ogg': 'file', '.wav': 'file', '.m4a': 'file', '.glb': 'file', '.gltf': 'file', '.bin': 'file', '.hdr': 'file', '.svg': 'file', '.json': 'json', '.woff2': 'file', '.ttf': 'file' };
|
|
29
|
+
|
|
30
|
+
/** The port toolkit as one classic script (window.HomiePort), for static games. Built once per build. */
|
|
31
|
+
async function portScript(esbuild, root, cache) {
|
|
32
|
+
if (cache.text) return cache.text;
|
|
33
|
+
const entry = join(PACKAGE_ROOT, 'port', 'global.ts');
|
|
34
|
+
const res = await esbuild.build({ entryPoints: [entry], bundle: true, format: 'iife', target: 'es2020', minify: true, write: false, absWorkingDir: root, logLevel: 'silent' });
|
|
35
|
+
cache.text = `/* homie-port.js — @homie-rocks/studio port toolkit (window.HomiePort). Load it first in <head>. */\n${res.outputFiles[0].text}`;
|
|
36
|
+
return cache.text;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function copyStatic(from, to) {
|
|
40
|
+
for (const entry of readdirSync(from, { withFileTypes: true })) {
|
|
41
|
+
if (STATIC_SKIP.has(entry.name)) continue;
|
|
42
|
+
const src = join(from, entry.name);
|
|
43
|
+
const dest = join(to, entry.name);
|
|
44
|
+
if (entry.isDirectory()) { mkdirSync(dest, { recursive: true }); copyStatic(src, dest); }
|
|
45
|
+
else if (entry.isFile()) cpSync(src, dest);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dirBytes(dir) {
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) n += e.isDirectory() ? dirBytes(join(dir, e.name)) : statSync(join(dir, e.name)).size;
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
17
54
|
const TEXT = /\.(ts|tsx|js|mjs|jsx|json|html|css|md|txt|svg|glsl|wgsl|frag|vert)$/i;
|
|
18
55
|
/** Text files of a game folder, capped (2 MB total, 512 KB each): what `homie-studio game remix` takes. */
|
|
19
56
|
export function sourceOf(dir, id) {
|
|
@@ -47,31 +84,55 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
47
84
|
if (!only) rmSync(dist, { recursive: true, force: true });
|
|
48
85
|
mkdirSync(dist, { recursive: true });
|
|
49
86
|
const built = [];
|
|
87
|
+
const cache = {};
|
|
50
88
|
for (const g of games) {
|
|
51
89
|
const out = join(dist, 'games', g.id);
|
|
52
90
|
rmSync(out, { recursive: true, force: true });
|
|
53
91
|
mkdirSync(join(out, 'assets'), { recursive: true });
|
|
54
|
-
const
|
|
55
|
-
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${g.entry ?? 'src/main.ts'} not found`);
|
|
92
|
+
const mode = g.build?.mode ?? 'bundle';
|
|
56
93
|
const started = Date.now();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
let warnings = 0;
|
|
95
|
+
const bundle = async (entryRel) => {
|
|
96
|
+
const entry = join(g.dir, entryRel);
|
|
97
|
+
if (!existsSync(entry)) throw new Error(`games/${g.id}: entry ${entryRel} not found`);
|
|
98
|
+
const result = await esbuild.build({
|
|
99
|
+
entryPoints: [entry], bundle: true, format: 'esm', target: 'es2022', minify: true, sourcemap: false,
|
|
100
|
+
outfile: join(out, 'assets', 'main.js'), absWorkingDir: root, logLevel: 'silent', metafile: true,
|
|
101
|
+
loader: LOADERS, assetNames: '[name]-[hash]',
|
|
102
|
+
}).catch((error) => {
|
|
103
|
+
const first = error.errors?.[0];
|
|
104
|
+
throw new Error(`games/${g.id} did not build: ${first ? `${first.text}${first.location ? ` (${first.location.file}:${first.location.line})` : ''}` : error.message}`);
|
|
105
|
+
});
|
|
106
|
+
warnings += result.warnings.length;
|
|
107
|
+
};
|
|
108
|
+
if (mode === 'static') {
|
|
109
|
+
copyStatic(g.dir, out);
|
|
110
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
111
|
+
if (g.entry) await bundle(g.entry);
|
|
112
|
+
const html = readFileSync(join(out, 'index.html'), 'utf8');
|
|
113
|
+
if (!/homie-port\.js/.test(html)) log(`warning: games/${g.id}/index.html does not load ./homie-port.js (the port toolkit); add <script src="./homie-port.js"></script> first in <head>`);
|
|
114
|
+
} else if (mode === 'command') {
|
|
115
|
+
const command = String(g.build.command ?? 'npm run build');
|
|
116
|
+
const res = spawnSync(command, { cwd: g.dir, shell: true, encoding: 'utf8', timeout: 10 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
117
|
+
if (res.status !== 0) throw new Error(`games/${g.id}: \`${command}\` failed: ${`${res.stdout ?? ''}${res.stderr ?? ''}`.trim().split('\n').slice(-6).join(' ')}`);
|
|
118
|
+
const built = join(g.dir, String(g.build.out ?? 'dist'));
|
|
119
|
+
if (!existsSync(join(built, 'index.html'))) throw new Error(`games/${g.id}: \`${command}\` left no index.html in ${relative(g.dir, built) || '.'}`);
|
|
120
|
+
cpSync(built, out, { recursive: true });
|
|
121
|
+
writeFileSync(join(out, 'homie-port.js'), await portScript(esbuild, root, cache));
|
|
122
|
+
} else {
|
|
123
|
+
await bundle(g.entry ?? 'src/main.ts');
|
|
124
|
+
const html = join(g.dir, 'index.html');
|
|
125
|
+
if (!existsSync(html)) throw new Error(`games/${g.id}/index.html is missing`);
|
|
126
|
+
writeFileSync(join(out, 'index.html'), readFileSync(html, 'utf8'));
|
|
127
|
+
}
|
|
128
|
+
if (mode !== 'static' && existsSync(join(g.dir, 'public'))) cpSync(join(g.dir, 'public'), out, { recursive: true });
|
|
129
|
+
if (!existsSync(join(out, 'index.html'))) throw new Error(`games/${g.id}/index.html is missing`);
|
|
70
130
|
// The game's own source, for other studios to remix (game.json "share": { "source": false } keeps it private).
|
|
71
131
|
if (g.share?.source !== false) writeFileSync(join(out, 'source.json'), `${JSON.stringify(sourceOf(g.dir, g.id))}\n`);
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
132
|
+
const main = join(out, 'assets', 'main.js');
|
|
133
|
+
const bytes = existsSync(main) ? statSync(main).size : dirBytes(out);
|
|
134
|
+
built.push({ id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings });
|
|
135
|
+
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB)`);
|
|
75
136
|
}
|
|
76
137
|
const all = listGames(root);
|
|
77
138
|
const catalogue = {
|
|
@@ -79,6 +140,7 @@ export async function build(root, { only = null, log = () => {} } = {}) {
|
|
|
79
140
|
games: all.filter((g) => existsSync(join(dist, 'games', g.id, 'index.html'))).map((g) => ({
|
|
80
141
|
id: g.id, name: g.name ?? g.id, blurb: g.blurb ?? '', players: g.players ?? { min: 1, max: 8 },
|
|
81
142
|
roundSeconds: g.roundSeconds ?? null, movement: g.netplay?.movement ?? null, cover: g.cover ?? null,
|
|
143
|
+
...(g.screen ? { screen: g.screen } : {}),
|
|
82
144
|
})),
|
|
83
145
|
};
|
|
84
146
|
writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
|