@scenoco-three/compiler 0.1.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/LICENSE +21 -0
- package/README.md +29 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +373 -0
- package/dist/cli/templates.d.ts +11 -0
- package/dist/cli/templates.js +401 -0
- package/dist/components.d.ts +43 -0
- package/dist/components.js +181 -0
- package/dist/dsl/SceneParser.d.ts +30 -0
- package/dist/dsl/SceneParser.js +59 -0
- package/dist/dsl/Validator.d.ts +15 -0
- package/dist/dsl/Validator.js +331 -0
- package/dist/dsl/bundle.d.ts +9 -0
- package/dist/dsl/bundle.js +151 -0
- package/dist/dsl/compile.d.ts +36 -0
- package/dist/dsl/compile.js +308 -0
- package/dist/dsl/diagnostics.d.ts +21 -0
- package/dist/dsl/diagnostics.js +38 -0
- package/dist/dsl/prefab.d.ts +30 -0
- package/dist/dsl/prefab.js +255 -0
- package/dist/dsl/serialize.d.ts +14 -0
- package/dist/dsl/serialize.js +157 -0
- package/dist/dsl/types.d.ts +45 -0
- package/dist/dsl/types.js +120 -0
- package/dist/exporters/docs.d.ts +16 -0
- package/dist/exporters/docs.js +190 -0
- package/dist/exporters/xsd.d.ts +13 -0
- package/dist/exporters/xsd.js +199 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +23 -0
- package/dist/watch.d.ts +33 -0
- package/dist/watch.js +112 -0
- package/package.json +35 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files written by `scenoco init` — kept as a pure path→content map so the
|
|
3
|
+
* scaffold can be unit-tested without touching the filesystem. The result is a
|
|
4
|
+
* runnable SceNoCo project: a starter component, a scene that uses it, and the
|
|
5
|
+
* Vite wiring so `npm run dev` serves it with no XML parser in the browser.
|
|
6
|
+
*/
|
|
7
|
+
export function scaffoldFiles(packageName, version = '^0.1.0') {
|
|
8
|
+
return {
|
|
9
|
+
'package.json': json({
|
|
10
|
+
name: packageName,
|
|
11
|
+
private: true,
|
|
12
|
+
type: 'module',
|
|
13
|
+
scripts: {
|
|
14
|
+
dev: 'vite',
|
|
15
|
+
build: 'vite build',
|
|
16
|
+
validate: 'scenoco validate src/scenes/hello.scene.xml --components src/components',
|
|
17
|
+
export: 'scenoco export --out schema --components src/components',
|
|
18
|
+
},
|
|
19
|
+
dependencies: {
|
|
20
|
+
'@scenoco-three/core': version,
|
|
21
|
+
three: '^0.184.0',
|
|
22
|
+
},
|
|
23
|
+
devDependencies: {
|
|
24
|
+
'@scenoco-three/compiler': version,
|
|
25
|
+
'@scenoco-three/vite': version,
|
|
26
|
+
'@types/three': '^0.184.0',
|
|
27
|
+
typescript: '^5.6.0',
|
|
28
|
+
vite: '^5.0.0',
|
|
29
|
+
},
|
|
30
|
+
}),
|
|
31
|
+
'tsconfig.json': json({
|
|
32
|
+
compilerOptions: {
|
|
33
|
+
target: 'ES2022',
|
|
34
|
+
lib: ['ES2022', 'DOM'],
|
|
35
|
+
module: 'ESNext',
|
|
36
|
+
moduleResolution: 'Bundler',
|
|
37
|
+
strict: true,
|
|
38
|
+
experimentalDecorators: true,
|
|
39
|
+
verbatimModuleSyntax: true,
|
|
40
|
+
skipLibCheck: true,
|
|
41
|
+
noEmit: true,
|
|
42
|
+
},
|
|
43
|
+
include: ['src', 'vite.config.ts'],
|
|
44
|
+
}),
|
|
45
|
+
'vite.config.ts': `import { defineConfig } from 'vite';
|
|
46
|
+
import { bundlePlugin } from '@scenoco-three/vite';
|
|
47
|
+
|
|
48
|
+
// Compiles \`*.scene.xml?bundle\` imports to bundles in Node, and registers the
|
|
49
|
+
// components each scene uses — so the browser ships no XML parser.
|
|
50
|
+
export default defineConfig({
|
|
51
|
+
plugins: [bundlePlugin({ componentRoots: ['src/components'] })],
|
|
52
|
+
});
|
|
53
|
+
`,
|
|
54
|
+
'src/vite-env.d.ts': `/// <reference types="vite/client" />
|
|
55
|
+
|
|
56
|
+
declare module '*.scene.xml?bundle' {
|
|
57
|
+
const bundle: import('@scenoco-three/core').Bundle;
|
|
58
|
+
export default bundle;
|
|
59
|
+
}
|
|
60
|
+
`,
|
|
61
|
+
'index.html': `<!doctype html>
|
|
62
|
+
<html lang="en">
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="utf-8" />
|
|
65
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
66
|
+
<title>${packageName}</title>
|
|
67
|
+
<style>
|
|
68
|
+
html, body { margin: 0; height: 100%; background: #1a1d24; }
|
|
69
|
+
canvas { display: block; width: 100%; height: 100%; }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<canvas id="app"></canvas>
|
|
74
|
+
<script type="module" src="/src/main.ts"></script>
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
77
|
+
`,
|
|
78
|
+
'src/main.ts': `import { Engine } from '@scenoco-three/core';
|
|
79
|
+
import scene from './scenes/hello.scene.xml?bundle';
|
|
80
|
+
|
|
81
|
+
const canvas = document.querySelector<HTMLCanvasElement>('#app')!;
|
|
82
|
+
const engine = new Engine({ canvas });
|
|
83
|
+
engine.camera.position.set(0, 1.5, 5);
|
|
84
|
+
engine.camera.lookAt(0, 0, 0);
|
|
85
|
+
engine.loadScene(scene);
|
|
86
|
+
`,
|
|
87
|
+
'src/components/Spin.ts': `import { Component, component, property } from '@scenoco-three/core';
|
|
88
|
+
|
|
89
|
+
@component({ name: 'Spin', description: 'Spins the host node around its Y axis over time' })
|
|
90
|
+
export class Spin extends Component {
|
|
91
|
+
@property({ type: 'float', default: 1, description: 'Rotation speed in radians per second' })
|
|
92
|
+
speed = 1;
|
|
93
|
+
|
|
94
|
+
// \`this.node\` IS a THREE.Object3D — use Three.js directly.
|
|
95
|
+
override update(dt: number): void {
|
|
96
|
+
this.node.rotation.y += this.speed * dt;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
`,
|
|
100
|
+
'src/scenes/hello.scene.xml': `<Scene>
|
|
101
|
+
<AmbientLight intensity="0.4" />
|
|
102
|
+
<DirectionalLight intensity="2" position="2 4 3" />
|
|
103
|
+
|
|
104
|
+
<Mesh id="cube" position="0 0 0">
|
|
105
|
+
<BoxGeometry width="1.5" height="1.5" depth="1.5" />
|
|
106
|
+
<MeshStandardMaterial color="#4f8ef7" roughness="0.3" metalness="0.6" />
|
|
107
|
+
<Components>
|
|
108
|
+
<Spin speed="1.2" />
|
|
109
|
+
</Components>
|
|
110
|
+
</Mesh>
|
|
111
|
+
</Scene>
|
|
112
|
+
`,
|
|
113
|
+
'AGENTS.md': agentsMd(packageName),
|
|
114
|
+
'.gitignore': `node_modules/
|
|
115
|
+
dist/
|
|
116
|
+
.scenoco/
|
|
117
|
+
schema/
|
|
118
|
+
`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function json(value) {
|
|
122
|
+
return JSON.stringify(value, null, 2) + '\n';
|
|
123
|
+
}
|
|
124
|
+
function agentsMd(packageName) {
|
|
125
|
+
return `# ${packageName} — SceNoCo agent cheat sheet
|
|
126
|
+
|
|
127
|
+
SceNoCo: 3D scenes are **XML files**; behaviour is **TypeScript components** decorated with
|
|
128
|
+
\`@component\`. The Vite plugin compiles XML → browser bundle at build time — no XML parser ships.
|
|
129
|
+
|
|
130
|
+
## Edit → validate → run loop
|
|
131
|
+
|
|
132
|
+
\`\`\`
|
|
133
|
+
scenoco new component MyBehavior # scaffold src/components/MyBehavior.ts
|
|
134
|
+
scenoco new scene level1 --type rpg # scaffold src/scenes/level1.scene.xml
|
|
135
|
+
npm run validate # XML type-checks; exits non-zero on error
|
|
136
|
+
npm run dev # live at http://localhost:5173
|
|
137
|
+
scenoco check # pre-flight: co-location, physics, camera issues
|
|
138
|
+
\`\`\`
|
|
139
|
+
|
|
140
|
+
\`npm run export\` writes \`schema/scene.xsd\` (IDE autocomplete) and \`schema/component-api.md\`
|
|
141
|
+
(full tag/attribute reference). Always read \`component-api.md\` before authoring a scene.
|
|
142
|
+
|
|
143
|
+
## Decorator rules (critical — get these wrong, nothing works)
|
|
144
|
+
|
|
145
|
+
| Decorator | Use case | Co-location rule |
|
|
146
|
+
|-----------|----------|-----------------|
|
|
147
|
+
| \`@component({ name })\` | Behaviour + data on one node | Any file |
|
|
148
|
+
| \`@system(ComponentType)\` | Cross-entity batch (e.g. all Enemies) | **MUST be in the same file as @component T** |
|
|
149
|
+
| \`@attachment({ name })\` | Leaf config (geometry, material, physics) | Any file |
|
|
150
|
+
| \`@node({ name })\` | Custom Object3D tag | Any file |
|
|
151
|
+
|
|
152
|
+
**@system-only files are silently skipped.** If EnemySystem is in its own file, it will never
|
|
153
|
+
be imported. Always put \`@system(Enemy)\` and \`@component({ name: 'Enemy' })\` in \`Enemy.ts\`.
|
|
154
|
+
|
|
155
|
+
## Physics decision tree
|
|
156
|
+
|
|
157
|
+
- **Arcade bounce / patrol (no gravity needed)** → do NOT use Rapier; write manual velocity math.
|
|
158
|
+
- **Gravity / character controller / joints** → import Rapier:
|
|
159
|
+
\`\`\`ts
|
|
160
|
+
// main.ts — MUST come before engine.loadScene()
|
|
161
|
+
import { initRapier } from '@scenoco-three/rapier';
|
|
162
|
+
await initRapier();
|
|
163
|
+
\`\`\`
|
|
164
|
+
Then add \`<Physics gravity="0 -9.81 0" />\`, \`<RigidBody kind="dynamic" />\`, \`<Collider />\` in XML.
|
|
165
|
+
|
|
166
|
+
## Camera patterns
|
|
167
|
+
|
|
168
|
+
| Pattern | How |
|
|
169
|
+
|---------|-----|
|
|
170
|
+
| 2D ortho (Breakout) | \`<OrthographicCamera id="cam" />\` in XML + \`engine.setActiveCamera(engine.findObject('cam')!)\` in main.ts |
|
|
171
|
+
| 3rd-person 3D (RPG) | \`ThirdPersonCamera @component\` on player; writes to \`engine.camera\` in \`lateUpdate\` |
|
|
172
|
+
| Side-scroll | \`PlatformCamera @component\` lerps \`engine.camera.position.x\` in \`lateUpdate\` |
|
|
173
|
+
|
|
174
|
+
## Input (touch + keyboard)
|
|
175
|
+
|
|
176
|
+
Never add \`addEventListener\` inside a component — you don't have reliable DOM access.
|
|
177
|
+
Use \`InputManager\` (singleton, init once in main.ts):
|
|
178
|
+
\`\`\`ts
|
|
179
|
+
InputManager.init(canvas); // in main.ts
|
|
180
|
+
InputManager.isPressed('jump'); // in component fixedUpdate
|
|
181
|
+
InputManager.justPressed('fire'); // rising edge — requires update() called before engine.tick()
|
|
182
|
+
\`\`\`
|
|
183
|
+
Touch buttons: \`<TouchButton action="jump" side="bottomRight" />\` as a component in XML.
|
|
184
|
+
|
|
185
|
+
## import type footgun
|
|
186
|
+
|
|
187
|
+
\`import type { GameManager }\` is erased at runtime. Passing it to \`findComponent\` crashes:
|
|
188
|
+
\`\`\`ts
|
|
189
|
+
// ✗ WRONG — import type erased; GameManager is undefined at runtime
|
|
190
|
+
import type { GameManager } from './GameManager.js';
|
|
191
|
+
this.engine.findComponent('gm', GameManager); // ReferenceError
|
|
192
|
+
|
|
193
|
+
// ✓ CORRECT — value import keeps the class reference
|
|
194
|
+
import { GameManager } from './GameManager.js';
|
|
195
|
+
this.engine.findComponent('gm', GameManager);
|
|
196
|
+
// OR — string name, no import needed at all:
|
|
197
|
+
this.engine.findComponent('gm', 'GameManager');
|
|
198
|
+
\`\`\`
|
|
199
|
+
|
|
200
|
+
## Level data — avoid XML explosion
|
|
201
|
+
|
|
202
|
+
48 bricks as 48 \`<Mesh>\` blocks exhausts an 8k context window. Use a spawner component instead:
|
|
203
|
+
\`\`\`xml
|
|
204
|
+
<Group id="bricks">
|
|
205
|
+
<Components><BrickSpawner data="rrrr/bbbb/gggg" cellW="1.3" cellH="0.6" /></Components>
|
|
206
|
+
</Group>
|
|
207
|
+
\`\`\`
|
|
208
|
+
The component calls \`engine.instantiate\` in \`onLoad\` — one XML line, N objects at runtime.
|
|
209
|
+
|
|
210
|
+
## Scene transitions (avoid mid-tick disposal)
|
|
211
|
+
|
|
212
|
+
\`\`\`ts
|
|
213
|
+
// Dispatch an event from inside a component:
|
|
214
|
+
window.dispatchEvent(new CustomEvent('game:nextlevel'));
|
|
215
|
+
|
|
216
|
+
// Handle in main.ts — setTimeout defers until after current tick:
|
|
217
|
+
window.addEventListener('game:nextlevel', () =>
|
|
218
|
+
setTimeout(() => engine.loadScene(nextBundle), 0)
|
|
219
|
+
);
|
|
220
|
+
\`\`\`
|
|
221
|
+
`;
|
|
222
|
+
}
|
|
223
|
+
/** Generate a component file boilerplate. */
|
|
224
|
+
export function componentFile(name) {
|
|
225
|
+
return `import { Component, component, property } from '@scenoco-three/core';
|
|
226
|
+
|
|
227
|
+
@component({ name: '${name}', description: '' })
|
|
228
|
+
export class ${name} extends Component {
|
|
229
|
+
@property({ type: 'float', default: 1 }) speed = 1;
|
|
230
|
+
|
|
231
|
+
override update(dt: number): void {
|
|
232
|
+
// this.node is a THREE.Object3D — use Three.js methods directly.
|
|
233
|
+
// To read input: import { InputManager } from './InputManager.js';
|
|
234
|
+
// To find another component: this.engine.findComponent('id', '${name}');
|
|
235
|
+
// To find a scene object: this.engine.findObject('objectId');
|
|
236
|
+
void dt;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
`;
|
|
240
|
+
}
|
|
241
|
+
/** Generate a scene XML skeleton for a given game type. */
|
|
242
|
+
export function sceneFile(name, type = 'basic') {
|
|
243
|
+
if (type === 'breakout')
|
|
244
|
+
return breakoutScene(name);
|
|
245
|
+
if (type === 'rpg')
|
|
246
|
+
return rpgScene(name);
|
|
247
|
+
if (type === 'platformer')
|
|
248
|
+
return platformerScene(name);
|
|
249
|
+
return basicScene(name);
|
|
250
|
+
}
|
|
251
|
+
function basicScene(_name) {
|
|
252
|
+
return `<Scene>
|
|
253
|
+
<AmbientLight intensity="0.5" />
|
|
254
|
+
<DirectionalLight intensity="2" position="5 10 5" />
|
|
255
|
+
|
|
256
|
+
<Mesh id="ground" position="0 -0.1 0">
|
|
257
|
+
<BoxGeometry width="20" height="0.2" depth="20" />
|
|
258
|
+
<MeshStandardMaterial color="#4a7c3a" roughness="0.9" />
|
|
259
|
+
</Mesh>
|
|
260
|
+
|
|
261
|
+
<Mesh id="player" position="0 0.5 0">
|
|
262
|
+
<BoxGeometry width="1" height="1" depth="1" />
|
|
263
|
+
<MeshStandardMaterial color="#4488ff" roughness="0.5" />
|
|
264
|
+
<Components>
|
|
265
|
+
<!-- Add your components here: <MyComponent speed="3" /> -->
|
|
266
|
+
</Components>
|
|
267
|
+
</Mesh>
|
|
268
|
+
</Scene>
|
|
269
|
+
`;
|
|
270
|
+
}
|
|
271
|
+
function breakoutScene(_name) {
|
|
272
|
+
return `<Scene>
|
|
273
|
+
<!--
|
|
274
|
+
Breakout / 2D ortho pattern.
|
|
275
|
+
AGENT NOTE: Call engine.setActiveCamera(engine.findObject('cam')!) in main.ts.
|
|
276
|
+
Without setActiveCamera the engine uses its default PerspectiveCamera.
|
|
277
|
+
-->
|
|
278
|
+
<OrthographicCamera id="cam" position="0 0 10" left="-7" right="7" top="5" bottom="-5" near="0.1" far="100" />
|
|
279
|
+
<AmbientLight intensity="1" />
|
|
280
|
+
|
|
281
|
+
<!-- Walls -->
|
|
282
|
+
<Mesh id="wallL" position="-7.1 0 0" visible="false">
|
|
283
|
+
<BoxGeometry width="0.2" height="12" depth="1" />
|
|
284
|
+
</Mesh>
|
|
285
|
+
<Mesh id="wallR" position="7.1 0 0" visible="false">
|
|
286
|
+
<BoxGeometry width="0.2" height="12" depth="1" />
|
|
287
|
+
</Mesh>
|
|
288
|
+
<Mesh id="wallT" position="0 5.1 0" visible="false">
|
|
289
|
+
<BoxGeometry width="14.2" height="0.2" depth="1" />
|
|
290
|
+
</Mesh>
|
|
291
|
+
|
|
292
|
+
<Mesh id="paddle" position="0 -4 0">
|
|
293
|
+
<BoxGeometry width="2" height="0.3" depth="0.3" />
|
|
294
|
+
<MeshStandardMaterial color="#00aaff" roughness="0.3" />
|
|
295
|
+
<Components><Paddle /></Components>
|
|
296
|
+
</Mesh>
|
|
297
|
+
|
|
298
|
+
<Mesh id="ball" position="0 -3 0">
|
|
299
|
+
<SphereGeometry radius="0.2" />
|
|
300
|
+
<MeshStandardMaterial color="#ffffff" roughness="0.2" />
|
|
301
|
+
<Components><Ball speed="7" /></Components>
|
|
302
|
+
</Mesh>
|
|
303
|
+
|
|
304
|
+
<!-- BrickSpawner: one line → many bricks. Avoids XML context explosion. -->
|
|
305
|
+
<Group id="bricks" position="-3.9 2.5 0">
|
|
306
|
+
<Components><BrickSpawner data="1111111/1111111/1111111" cellW="1.3" cellH="0.6" /></Components>
|
|
307
|
+
</Group>
|
|
308
|
+
|
|
309
|
+
<Components><GameManager id="gm" lives="3" level="1" /></Components>
|
|
310
|
+
</Scene>
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
function rpgScene(_name) {
|
|
314
|
+
return `<Scene>
|
|
315
|
+
<!--
|
|
316
|
+
3rd-person RPG pattern.
|
|
317
|
+
Physics: kinematic RigidBody for player (no gravity drift); static for environment.
|
|
318
|
+
Camera: ThirdPersonCamera @component handles orbit in lateUpdate.
|
|
319
|
+
AGENT NOTE: await initRapier() must be called in main.ts before loadScene().
|
|
320
|
+
-->
|
|
321
|
+
<Background color="#87ceeb" />
|
|
322
|
+
<Fog color="#9bd5ee" near="30" far="60" />
|
|
323
|
+
<AmbientLight intensity="0.5" />
|
|
324
|
+
<DirectionalLight intensity="2" position="10 20 10" />
|
|
325
|
+
|
|
326
|
+
<Physics gravity="0 -20 0" />
|
|
327
|
+
|
|
328
|
+
<Mesh id="ground" position="0 -0.1 0">
|
|
329
|
+
<BoxGeometry width="60" height="0.2" depth="60" />
|
|
330
|
+
<MeshStandardMaterial color="#4a7c3a" roughness="0.9" />
|
|
331
|
+
<Components><RigidBody kind="fixed" /><Collider shape="box" halfExtents="30 0.1 30" /></Components>
|
|
332
|
+
</Mesh>
|
|
333
|
+
|
|
334
|
+
<!--
|
|
335
|
+
Player: id="player" is REQUIRED — systems use engine.findObject('player').
|
|
336
|
+
RigidBody kind="kinematic" + setNextKinematicTranslation in fixedUpdate.
|
|
337
|
+
-->
|
|
338
|
+
<Group id="player" position="0 1 0">
|
|
339
|
+
<Mesh id="playerBody" position="0 0.5 0">
|
|
340
|
+
<CylinderGeometry radiusTop="0.25" radiusBottom="0.25" height="1.0" />
|
|
341
|
+
<MeshStandardMaterial color="#4466cc" roughness="0.5" />
|
|
342
|
+
</Mesh>
|
|
343
|
+
<Components>
|
|
344
|
+
<RigidBody kind="kinematic" />
|
|
345
|
+
<Collider shape="capsule" halfHeight="0.5" radius="0.3" />
|
|
346
|
+
<PlayerController speed="5" />
|
|
347
|
+
<ThirdPersonCamera armLength="6" height="1.5" />
|
|
348
|
+
<VirtualJoystick side="left" />
|
|
349
|
+
</Components>
|
|
350
|
+
</Group>
|
|
351
|
+
</Scene>
|
|
352
|
+
`;
|
|
353
|
+
}
|
|
354
|
+
function platformerScene(_name) {
|
|
355
|
+
return `<Scene>
|
|
356
|
+
<!--
|
|
357
|
+
Side-scrolling platformer pattern.
|
|
358
|
+
Physics: dynamic RigidBody for player (gravity applied by Rapier).
|
|
359
|
+
AGENT NOTE (lockRotation): add angularDamping="100" to RigidBody to prevent toppling;
|
|
360
|
+
also reset rotation each fixedUpdate: body.setRotation({x:0,y:0,z:0,w:1}, false).
|
|
361
|
+
AGENT NOTE: await initRapier() must be called in main.ts before loadScene().
|
|
362
|
+
Camera: PlatformCamera @component lerps engine.camera.x in lateUpdate — no camera node needed.
|
|
363
|
+
-->
|
|
364
|
+
<Background color="#5b8dd9" />
|
|
365
|
+
<AmbientLight intensity="0.6" />
|
|
366
|
+
<DirectionalLight intensity="1.8" position="10 20 5" />
|
|
367
|
+
|
|
368
|
+
<Physics gravity="0 -25 0" />
|
|
369
|
+
|
|
370
|
+
<Mesh id="ground" position="0 -0.3 0">
|
|
371
|
+
<BoxGeometry width="40" height="0.4" depth="2" />
|
|
372
|
+
<MeshStandardMaterial color="#3a6b2a" roughness="0.9" />
|
|
373
|
+
<Components><RigidBody kind="fixed" /><Collider shape="box" halfExtents="20 0.2 1" /></Components>
|
|
374
|
+
</Mesh>
|
|
375
|
+
|
|
376
|
+
<!-- Add platform PrefabInstances here: <PrefabInstance src="platform.prefab.xml" position="3 2 0" /> -->
|
|
377
|
+
|
|
378
|
+
<!--
|
|
379
|
+
Player: id="player" is REQUIRED.
|
|
380
|
+
TouchButtons provide mobile input; keyboard fallback goes in main.ts.
|
|
381
|
+
-->
|
|
382
|
+
<Group id="player" position="-4 2 0">
|
|
383
|
+
<Mesh id="playerBody">
|
|
384
|
+
<BoxGeometry width="0.6" height="0.9" depth="0.6" />
|
|
385
|
+
<MeshStandardMaterial color="#4488ff" roughness="0.5" />
|
|
386
|
+
</Mesh>
|
|
387
|
+
<Components>
|
|
388
|
+
<RigidBody kind="dynamic" angularDamping="100" />
|
|
389
|
+
<Collider shape="box" halfExtents="0.3 0.45 0.3" />
|
|
390
|
+
<PlatformerPlayer speed="5" jumpForce="12" />
|
|
391
|
+
<PlatformCamera followSpeed="5" offsetY="3" offsetZ="12" />
|
|
392
|
+
<TouchButton action="left" side="bottomLeft" label="◀" />
|
|
393
|
+
<TouchButton action="right" side="bottomCenter" label="▶" />
|
|
394
|
+
<TouchButton action="jump" side="bottomRight" label="▲" />
|
|
395
|
+
</Components>
|
|
396
|
+
</Group>
|
|
397
|
+
|
|
398
|
+
<Components><GameManager id="gm" lives="3" level="1" /></Components>
|
|
399
|
+
</Scene>
|
|
400
|
+
`;
|
|
401
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project component AND node discovery and Node-side loading.
|
|
3
|
+
*
|
|
4
|
+
* Scenes reference components by tag (`<Rotator …/>`) and use node tags
|
|
5
|
+
* (`<Mesh>`, a custom `<MyWidget>`); the validator, compiler, and exporters
|
|
6
|
+
* resolve those against the @scenoco-three/core registries, which are populated
|
|
7
|
+
* by *importing* the files that declare them — `@component` classes and
|
|
8
|
+
* `define{Node,Geometry,Material,Setting}(…)` calls. At build time those are the
|
|
9
|
+
* project's TypeScript sources, so this module finds them, transpiles them with
|
|
10
|
+
* esbuild (cached under `.scenoco/component-cache`), and imports them into the
|
|
11
|
+
* current Node process. The CLI and the Vite plugin both run on top of this.
|
|
12
|
+
*
|
|
13
|
+
* Re-loading: when a file's mtime changes, its previously registered names are
|
|
14
|
+
* unregistered first, so long-running processes (dev server, watch) always
|
|
15
|
+
* validate against the file's current contents.
|
|
16
|
+
*/
|
|
17
|
+
export interface ComponentScan {
|
|
18
|
+
/** Absolute file path → the component names + node tags the file declares. */
|
|
19
|
+
files: Map<string, string[]>;
|
|
20
|
+
/** Component name → the absolute file path that declares it. */
|
|
21
|
+
byName: Map<string, string>;
|
|
22
|
+
/** Node/geometry/material/setting tag → the absolute file that declares it. */
|
|
23
|
+
byTag: Map<string, string>;
|
|
24
|
+
}
|
|
25
|
+
/** Find files declaring `@component` classes / custom node tags, and their names. */
|
|
26
|
+
export declare function scanComponents(roots: string[], cwd?: string): ComponentScan;
|
|
27
|
+
export interface LoadOptions {
|
|
28
|
+
/**
|
|
29
|
+
* npm packages that register tags (components/nodes/settings) on import, e.g.
|
|
30
|
+
* `['@scenoco-three/rapier']`. They are imported once to register their tags
|
|
31
|
+
* in this process, and mapped into the scan so the Vite plugin usage-ships
|
|
32
|
+
* them (a scene that uses `<RigidBody>` imports the package; others don't).
|
|
33
|
+
*/
|
|
34
|
+
packages?: string[];
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Ensure every `@component` and custom node declared under `roots` (plus any tag
|
|
38
|
+
* `packages`) is registered in this process's registries. Idempotent and
|
|
39
|
+
* change-aware: if nothing changed it's a cheap scan; if anything changed,
|
|
40
|
+
* previous registrations are retired and the fresh set is imported. Returns the
|
|
41
|
+
* scan so callers can map names/tags to files/packages (for usage-based imports).
|
|
42
|
+
*/
|
|
43
|
+
export declare function loadComponents(roots: string[], cwd?: string, options?: LoadOptions): Promise<ComponentScan>;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
import { Registry, getNodeDef, getAttachmentDef, nodeTags, attachmentTags, unregisterNode, } from '@scenoco-three/core/internal';
|
|
6
|
+
import { build } from 'esbuild';
|
|
7
|
+
// A file is loadable if it declares a component or a node tag; both register by
|
|
8
|
+
// importing the module (class-decorator side effect). One idiom, one scan.
|
|
9
|
+
const DECORATOR_RE = /@component\s*\(/;
|
|
10
|
+
const NODE_DECORATOR_RE = /@(?:node|attachment|system)\s*\(/;
|
|
11
|
+
const SYSTEM_RE = /@system\s*\(/;
|
|
12
|
+
// Matches @component({ name: 'X' …) and @node|attachment({ name: 'X' …}).
|
|
13
|
+
// `name` is always an explicit string literal (the registry contract), making a
|
|
14
|
+
// static name→file map possible without executing anything. (@system keys off a
|
|
15
|
+
// component type, not a tag name, so it has no name to extract.)
|
|
16
|
+
const NAME_RE = /@component\s*\(\s*\{[^}]*?name\s*:\s*['"`]([^'"`]+)['"`]/g;
|
|
17
|
+
const TAG_RE = /@(?:node|attachment)\s*\(\s*\{[^}]*?name\s*:\s*['"`]([^'"`]+)['"`]/g;
|
|
18
|
+
const SOURCE_EXT_RE = /\.(?:[cm]?[jt]sx?)$/;
|
|
19
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', 'build', 'demo', 'coverage', '.scenoco']);
|
|
20
|
+
/** Find files declaring `@component` classes / custom node tags, and their names. */
|
|
21
|
+
export function scanComponents(roots, cwd = process.cwd()) {
|
|
22
|
+
const files = new Map();
|
|
23
|
+
const byName = new Map();
|
|
24
|
+
const byTag = new Map();
|
|
25
|
+
for (const root of roots) {
|
|
26
|
+
const absRoot = resolve(cwd, root);
|
|
27
|
+
if (!existsSync(absRoot))
|
|
28
|
+
continue;
|
|
29
|
+
const stack = [absRoot];
|
|
30
|
+
while (stack.length > 0) {
|
|
31
|
+
const dir = stack.pop();
|
|
32
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
33
|
+
if (entry.isDirectory()) {
|
|
34
|
+
if (!SKIP_DIRS.has(entry.name))
|
|
35
|
+
stack.push(resolve(dir, entry.name));
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (!SOURCE_EXT_RE.test(entry.name) || entry.name.endsWith('.d.ts'))
|
|
39
|
+
continue;
|
|
40
|
+
const filePath = resolve(dir, entry.name);
|
|
41
|
+
const source = readFileSync(filePath, 'utf8');
|
|
42
|
+
const hasComponent = DECORATOR_RE.test(source);
|
|
43
|
+
const hasNode = NODE_DECORATOR_RE.test(source);
|
|
44
|
+
if (!hasComponent && !hasNode)
|
|
45
|
+
continue;
|
|
46
|
+
const names = hasComponent ? [...source.matchAll(NAME_RE)].map((m) => m[1]) : [];
|
|
47
|
+
const tags = hasNode ? [...source.matchAll(TAG_RE)].map((m) => m[1]) : [];
|
|
48
|
+
if (names.length === 0 && tags.length === 0) {
|
|
49
|
+
// A file with @system but no @component/@node is a silent failure —
|
|
50
|
+
// the bundle plugin scanner never imports it. Warn so the author knows
|
|
51
|
+
// to co-locate the @system with its entity @component.
|
|
52
|
+
if (SYSTEM_RE.test(source)) {
|
|
53
|
+
const rel = filePath.replace(cwd + '/', '');
|
|
54
|
+
console.warn(`[scenoco] WARNING: ${rel} declares @system but has no @component or @node —` +
|
|
55
|
+
` it will NOT be imported by the bundle plugin.\n` +
|
|
56
|
+
` → Co-locate @system(T) in the same file as @component T.`);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
files.set(filePath, [...names, ...tags]);
|
|
61
|
+
for (const name of names)
|
|
62
|
+
byName.set(name, filePath);
|
|
63
|
+
for (const tag of tags)
|
|
64
|
+
byTag.set(tag, filePath);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { files, byName, byTag };
|
|
69
|
+
}
|
|
70
|
+
// Per-process load state. The registration set is loaded as ONE module (a
|
|
71
|
+
// synthetic entry importing every declaring file), so files that import each
|
|
72
|
+
// other — `Follow` referencing `Oscillator` for a typed @property — share one
|
|
73
|
+
// evaluation and register exactly once. Any change reloads the whole set.
|
|
74
|
+
let loadedKey = '';
|
|
75
|
+
let ownedNames = [];
|
|
76
|
+
let ownedTags = [];
|
|
77
|
+
/**
|
|
78
|
+
* Ensure every `@component` and custom node declared under `roots` (plus any tag
|
|
79
|
+
* `packages`) is registered in this process's registries. Idempotent and
|
|
80
|
+
* change-aware: if nothing changed it's a cheap scan; if anything changed,
|
|
81
|
+
* previous registrations are retired and the fresh set is imported. Returns the
|
|
82
|
+
* scan so callers can map names/tags to files/packages (for usage-based imports).
|
|
83
|
+
*/
|
|
84
|
+
export async function loadComponents(roots, cwd = process.cwd(), options = {}) {
|
|
85
|
+
const scan = scanComponents(roots, cwd);
|
|
86
|
+
// Project file names/tags only — package registrations persist across reloads
|
|
87
|
+
// (npm deps are stable; ESM caches the import), so they're never retired.
|
|
88
|
+
const projectNames = [...scan.byName.keys()];
|
|
89
|
+
const projectTags = [...scan.byTag.keys()];
|
|
90
|
+
await registerPackages(options.packages ?? [], scan);
|
|
91
|
+
const files = [...scan.files.keys()].sort();
|
|
92
|
+
const key = createHash('sha1')
|
|
93
|
+
.update(files.map((f) => `${f}:${statSync(f).mtimeMs}`).join('|'))
|
|
94
|
+
.digest('hex');
|
|
95
|
+
if (key === loadedKey)
|
|
96
|
+
return scan;
|
|
97
|
+
// Retire the previous project set so the fresh module registers cleanly. A
|
|
98
|
+
// genuine duplicate name/tag across two files still fails inside the new eval.
|
|
99
|
+
for (const name of ownedNames)
|
|
100
|
+
Registry.unregisterComponent(name);
|
|
101
|
+
for (const tag of ownedTags)
|
|
102
|
+
unregisterNode(tag);
|
|
103
|
+
ownedNames = [];
|
|
104
|
+
ownedTags = [];
|
|
105
|
+
loadedKey = '';
|
|
106
|
+
if (files.length > 0) {
|
|
107
|
+
await importComponentSet(files, key, cwd, options.packages ?? []);
|
|
108
|
+
ownedNames = projectNames;
|
|
109
|
+
ownedTags = projectTags;
|
|
110
|
+
loadedKey = key;
|
|
111
|
+
}
|
|
112
|
+
return scan;
|
|
113
|
+
}
|
|
114
|
+
// Tag packages, imported once each; we diff the registries to learn which tags
|
|
115
|
+
// each package contributes so the scan can map them to the package specifier.
|
|
116
|
+
const packageRegistrations = new Map();
|
|
117
|
+
async function registerPackages(packages, scan) {
|
|
118
|
+
for (const pkg of packages) {
|
|
119
|
+
let reg = packageRegistrations.get(pkg);
|
|
120
|
+
if (!reg) {
|
|
121
|
+
const beforeNames = new Set(Registry.componentNames());
|
|
122
|
+
const beforeTags = new Set([...nodeTags(), ...attachmentTags()]);
|
|
123
|
+
const mod = (await import(pkg));
|
|
124
|
+
// A tag package may export `sceneTags` (authoritative — works even if it
|
|
125
|
+
// was already imported); otherwise diff the registries for what it added.
|
|
126
|
+
if (mod.sceneTags) {
|
|
127
|
+
reg = {
|
|
128
|
+
names: mod.sceneTags.filter((t) => Registry.getComponent(t) !== undefined),
|
|
129
|
+
tags: mod.sceneTags.filter((t) => getNodeDef(t) !== undefined || getAttachmentDef(t) !== undefined),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const after = new Set([...nodeTags(), ...attachmentTags()]);
|
|
134
|
+
reg = {
|
|
135
|
+
names: Registry.componentNames().filter((n) => !beforeNames.has(n)),
|
|
136
|
+
tags: [...after].filter((t) => !beforeTags.has(t)),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
packageRegistrations.set(pkg, reg);
|
|
140
|
+
}
|
|
141
|
+
for (const n of reg.names)
|
|
142
|
+
scan.byName.set(n, pkg);
|
|
143
|
+
for (const t of reg.tags)
|
|
144
|
+
scan.byTag.set(t, pkg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Transpile + import the component set as one ES module. esbuild bundles a
|
|
149
|
+
* synthetic entry that imports every component file, with `@scenoco-three/core` and
|
|
150
|
+
* `three` left external so the module registers into the *same* Registry
|
|
151
|
+
* instance the compiler uses. The result is cached under
|
|
152
|
+
* `.scenoco/component-cache`, content-addressed by the set's path+mtime key.
|
|
153
|
+
*/
|
|
154
|
+
async function importComponentSet(files, key, cwd, extraExternals = []) {
|
|
155
|
+
const cacheDir = resolve(cwd, '.scenoco', 'component-cache');
|
|
156
|
+
const cachedModulePath = resolve(cacheDir, `${key}.mjs`);
|
|
157
|
+
if (!existsSync(cachedModulePath)) {
|
|
158
|
+
const entry = files.map((f) => `import ${JSON.stringify(f)};`).join('\n');
|
|
159
|
+
const bundled = await build({
|
|
160
|
+
stdin: { contents: entry, resolveDir: cwd, loader: 'ts' },
|
|
161
|
+
bundle: true,
|
|
162
|
+
write: false,
|
|
163
|
+
format: 'esm',
|
|
164
|
+
platform: 'node',
|
|
165
|
+
target: 'es2022',
|
|
166
|
+
// tag packages (e.g. @scenoco-three/rapier) must be external — they are
|
|
167
|
+
// already imported by registerPackages() and registered in the runtime
|
|
168
|
+
// Registry; bundling them again causes "already registered" errors.
|
|
169
|
+
external: ['@scenoco-three/core', 'three', ...extraExternals],
|
|
170
|
+
sourcemap: false,
|
|
171
|
+
logLevel: 'silent',
|
|
172
|
+
tsconfigRaw: { compilerOptions: { experimentalDecorators: true } },
|
|
173
|
+
});
|
|
174
|
+
const code = bundled.outputFiles[0]?.text;
|
|
175
|
+
if (!code)
|
|
176
|
+
throw new Error('esbuild produced no output for the component set');
|
|
177
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
178
|
+
writeFileSync(cachedModulePath, code);
|
|
179
|
+
}
|
|
180
|
+
await import(pathToFileURL(cachedModulePath).href);
|
|
181
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { SceDiagnostic } from './diagnostics.js';
|
|
2
|
+
/**
|
|
3
|
+
* A parsed XML element with its source position. This is the lightweight,
|
|
4
|
+
* position-aware tree the validator and compiler walk — built on a streaming
|
|
5
|
+
* SAX parser precisely so every node retains line/column (a DOMParser would
|
|
6
|
+
* throw that away, breaking the grep/line-edit fix loop).
|
|
7
|
+
*/
|
|
8
|
+
export interface SceElement {
|
|
9
|
+
tag: string;
|
|
10
|
+
attributes: Map<string, string>;
|
|
11
|
+
children: SceElement[];
|
|
12
|
+
parent: SceElement | null;
|
|
13
|
+
line: number;
|
|
14
|
+
column: number;
|
|
15
|
+
/** Set on elements spliced in from another file (prefab expansion). */
|
|
16
|
+
file?: string;
|
|
17
|
+
}
|
|
18
|
+
export type ParseResult = {
|
|
19
|
+
ok: true;
|
|
20
|
+
root: SceElement;
|
|
21
|
+
} | {
|
|
22
|
+
ok: false;
|
|
23
|
+
diagnostics: SceDiagnostic[];
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Parse a scene XML string into a position-aware element tree. Returns XML
|
|
27
|
+
* syntax errors (malformed markup) as diagnostics; semantic validation against
|
|
28
|
+
* the schema is a separate pass (see Validator).
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseScene(xml: string): ParseResult;
|