@scenoco-three/unity 0.0.0-stage → 0.7.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/dist/export.js ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Reads a SceNoCo project's prefabs and scenes in its own page, and writes them into a Unity project.
3
+ *
4
+ * The project's own Vite dev server serves one extra page that imports every module of the game but
5
+ * its entry — so nothing starts — and hands them to core's `snapshot`, in a headless Chrome: the
6
+ * declarations run where they were written to run, with the browser they may expect.
7
+ */
8
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, copyFileSync } from 'node:fs';
9
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { chromium } from 'playwright-core';
12
+ import { createServer, loadConfigFromFile } from 'vite';
13
+ import { convert } from './convert.js';
14
+ const PAGE = '/__scenoco_unity.html';
15
+ const ENTRY = 'virtual:scenoco-unity';
16
+ /** Every module under `src/` the page may import: TypeScript and TSX, the game's entries left out. */
17
+ export function gameModules(root) {
18
+ const html = existsSync(join(root, 'index.html')) ? readFileSync(join(root, 'index.html'), 'utf8') : '';
19
+ const entries = new Set([...html.matchAll(/<script[^>]*type="module"[^>]*src="([^"]+)"/g)].map((m) => m[1].replace(/^\//, '')));
20
+ const out = [];
21
+ const walk = (dir) => {
22
+ for (const name of readdirSync(dir)) {
23
+ const path = join(dir, name);
24
+ if (statSync(path).isDirectory())
25
+ walk(path);
26
+ else if (/\.tsx?$/.test(name) && !name.endsWith('.d.ts') && !/\.(test|spec)\.tsx?$/.test(name)) {
27
+ const rel = relative(root, path).split(sep).join('/');
28
+ if (!entries.has(rel))
29
+ out.push(`/${rel}`);
30
+ }
31
+ }
32
+ };
33
+ if (existsSync(join(root, 'src')))
34
+ walk(join(root, 'src'));
35
+ return out.sort();
36
+ }
37
+ /** The game's own page with its entries taken out: a behaviour that reads its HUD from the DOM finds it, and nothing starts. */
38
+ function html(root) {
39
+ const file = join(root, 'index.html');
40
+ const own = existsSync(file) ? readFileSync(file, 'utf8').replace(/<script[^>]*type="module"[^>]*src="[^"]*"[^>]*>\s*<\/script>/g, '') : '<!doctype html><meta charset="utf-8">';
41
+ const script = `<script type="module" src="/@id/${ENTRY}"></script>`;
42
+ return own.includes('</body>') ? own.replace('</body>', `${script}</body>`) : own + script;
43
+ }
44
+ function page(root, modules) {
45
+ // Each import spelled out: Vite gives `import(variable)` a `?import` query, and with it a second
46
+ // copy of the module — whose prefabs would not be the ones the scenes hold.
47
+ const code = [
48
+ `import { snapshot } from '@scenoco-three/core/snapshot';`,
49
+ 'window.__scenocoSnapshot = async () => {',
50
+ ' const modules = {}, failed = [];',
51
+ ...modules.map((file) => ` try { modules[${JSON.stringify(file)}] = await import(${JSON.stringify(file)}); } catch (e) { failed.push(${JSON.stringify(file)} + ': ' + e.message); }`),
52
+ ' const out = snapshot(modules);',
53
+ " out.warnings.unshift(...failed.map((f) => f + ' — its prefabs are not exported.'));",
54
+ ' return out;',
55
+ '};',
56
+ ].join('\n');
57
+ return {
58
+ name: 'scenoco-unity-export',
59
+ resolveId: (id) => (id === ENTRY ? `\0${ENTRY}` : undefined),
60
+ load: (id) => (id === `\0${ENTRY}` ? code : undefined),
61
+ configureServer(server) {
62
+ server.middlewares.use((req, res, next) => {
63
+ if (req.url?.split('?')[0] !== PAGE)
64
+ return next();
65
+ res.setHeader('Content-Type', 'text/html');
66
+ res.end(html(root));
67
+ });
68
+ },
69
+ };
70
+ }
71
+ /** Build the project's prefabs and scenes in its own page, and describe them. */
72
+ export async function snapshotProject(root, options = {}) {
73
+ root = resolve(root);
74
+ const configFile = existsSync(join(root, 'vite.config.ts')) ? join(root, 'vite.config.ts') : false;
75
+ const loaded = configFile ? await loadConfigFromFile({ command: 'serve', mode: 'development' }, configFile, root, 'silent') : null;
76
+ const server = await createServer({
77
+ configFile,
78
+ root: resolve(root, loaded?.config.root ?? '.'),
79
+ logLevel: 'silent',
80
+ clearScreen: false,
81
+ plugins: [page(root, gameModules(root))],
82
+ // Not pre-bundled: the snapshot and the scenes must share one copy of core's JSX runtime.
83
+ optimizeDeps: { exclude: ['@scenoco-three/core'] },
84
+ server: { port: 0, hmr: false },
85
+ });
86
+ await server.listen();
87
+ const browser = await chromium.launch({ channel: 'chrome', headless: !options.headed });
88
+ try {
89
+ const tab = await browser.newPage();
90
+ const errors = [];
91
+ tab.on('pageerror', (e) => errors.push(e.message));
92
+ tab.on('console', (m) => m.type() === 'error' && errors.push(m.text()));
93
+ await tab.goto(new URL(PAGE, server.resolvedUrls.local[0]).href);
94
+ await tab.waitForFunction(() => '__scenocoSnapshot' in window, null, { timeout: 30_000 }).catch(async () => {
95
+ // The server's own reason, which the browser reports only as a status code.
96
+ const reason = await tab.evaluate((url) => fetch(url).then((r) => (r.ok ? '' : r.text())), `/@id/${ENTRY}`).catch(() => '');
97
+ const missing = /snapshot/.test(reason) ? ' — the project\'s @scenoco-three/core is older than this exporter: update it.' : '';
98
+ throw new Error(`the export page did not load: ${reason.split('\n')[0] || errors.join(' — ') || 'no error was reported'}${missing}`);
99
+ });
100
+ try {
101
+ return await tab.evaluate(() => window.__scenocoSnapshot());
102
+ }
103
+ catch (error) {
104
+ throw new Error(`importing the game's modules failed: ${error instanceof Error ? error.message : String(error)}${errors.length ? ` — ${errors.join(' — ')}` : ''}`);
105
+ }
106
+ }
107
+ finally {
108
+ await browser.close();
109
+ await server.close();
110
+ }
111
+ }
112
+ /** Convert a snapshot and write it into the Unity project: data for the importer, the importer, C# stubs, textures. */
113
+ export function writeUnity(snap, project, root) {
114
+ project = resolve(project);
115
+ if (!existsSync(join(project, 'Assets')) || !existsSync(join(project, 'ProjectSettings'))) {
116
+ throw new Error(`${project} is not a Unity project: it has no Assets/ and ProjectSettings/.`);
117
+ }
118
+ const relativeSource = (s) => {
119
+ const at = s.lastIndexOf(':', s.lastIndexOf(':') - 1);
120
+ const file = s.slice(0, at);
121
+ return `${relative(root, file.startsWith('/') && existsSync(file) ? file : join(root, file)).split(sep).join('/')}${s.slice(at)}`;
122
+ };
123
+ // Sources are absolute in the page; a person reads them from the project.
124
+ const tidy = (snapshot) => JSON.parse(JSON.stringify(snapshot), (key, value) => (key === 'source' && typeof value === 'string' ? relativeSource(value) : value));
125
+ const converted = convert(tidy(snap), {
126
+ firstScene: firstScene(root),
127
+ textures: (url) => {
128
+ const path = decodeURIComponent(new URL(url, 'http://x').pathname);
129
+ for (const file of [join(root, 'public', path), join(root, path)])
130
+ if (existsSync(file) && statSync(file).isFile())
131
+ return { file, name: basename(file) };
132
+ return null;
133
+ },
134
+ });
135
+ const out = (path) => {
136
+ const full = join(project, path);
137
+ mkdirSync(dirname(full), { recursive: true });
138
+ return full;
139
+ };
140
+ writeFileSync(out('Assets/SceNoCo/export.json'), JSON.stringify(converted.data));
141
+ // A dot file: Unity does not import it, the importer reads it as "a fresh export is waiting".
142
+ writeFileSync(out('Assets/SceNoCo/.pending'), new Date().toISOString());
143
+ copyFileSync(fileURLToPath(new URL('../unity/SceNoCoImporter.cs', import.meta.url)), out('Assets/Editor/SceNoCoImporter.cs'));
144
+ for (const [from, to] of converted.copies)
145
+ copyFileSync(from, out(to));
146
+ const declared = classesIn(join(project, 'Assets'));
147
+ const scripts = [];
148
+ for (const [type, code] of converted.scripts) {
149
+ if (declared.has(type))
150
+ continue;
151
+ writeFileSync(out(`Assets/Scripts/${type}.cs`), code);
152
+ scripts.push(type);
153
+ }
154
+ return { converted, scripts };
155
+ }
156
+ /** The scene `main.ts` plays: `play(Main)` names it. */
157
+ function firstScene(root) {
158
+ const main = join(root, 'src', 'main.ts');
159
+ return existsSync(main) ? /\bplay\(\s*(\w+)/.exec(readFileSync(main, 'utf8'))?.[1] : undefined;
160
+ }
161
+ function classesIn(dir) {
162
+ const out = new Set();
163
+ const walk = (d) => {
164
+ for (const name of readdirSync(d)) {
165
+ const path = join(d, name);
166
+ if (statSync(path).isDirectory())
167
+ walk(path);
168
+ else if (name.endsWith('.cs'))
169
+ for (const m of readFileSync(path, 'utf8').matchAll(/\bclass\s+(\w+)/g))
170
+ out.add(m[1]);
171
+ }
172
+ };
173
+ walk(dir);
174
+ return out;
175
+ }
package/package.json CHANGED
@@ -1,6 +1,59 @@
1
1
  {
2
2
  "name": "@scenoco-three/unity",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.7.0",
4
+ "description": "Export a SceNoCo game's prefabs, scenes, meshes and materials into a Unity project: nested prefabs, shared materials, behaviours as MonoBehaviours with their fields wired.",
5
+ "keywords": [
6
+ "three",
7
+ "threejs",
8
+ "scenoco",
9
+ "game-engine",
10
+ "game-framework",
11
+ "gamedev",
12
+ "web-game",
13
+ "webgl",
14
+ "typescript",
15
+ "unity",
16
+ "unity-export",
17
+ "prefab",
18
+ "porting"
19
+ ],
20
+ "homepage": "https://github.com/RomaRogov/scenoco-three/tree/main/packages/unity#readme",
21
+ "bugs": "https://github.com/RomaRogov/scenoco-three/issues",
22
+ "type": "module",
23
+ "license": "MIT",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/RomaRogov/scenoco-three.git",
27
+ "directory": "packages/unity"
28
+ },
29
+ "bin": {
30
+ "scenoco-unity": "./dist/cli.js"
31
+ },
32
+ "main": "./dist/export.js",
33
+ "types": "./dist/export.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/export.d.ts",
37
+ "import": "./dist/export.js"
38
+ }
39
+ },
40
+ "files": [
41
+ "dist",
42
+ "unity"
43
+ ],
44
+ "publishConfig": {
45
+ "access": "public"
46
+ },
47
+ "scripts": {
48
+ "prebuild": "rm -rf dist",
49
+ "build": "tsc -p tsconfig.build.json && chmod +x dist/cli.js",
50
+ "typecheck": "tsc --noEmit -p tsconfig.json"
51
+ },
52
+ "dependencies": {
53
+ "playwright-core": "^1.62.1"
54
+ },
55
+ "peerDependencies": {
56
+ "@scenoco-three/core": "0.7.0",
57
+ "vite": ">=6"
58
+ }
59
+ }