@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/README.md +30 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +42 -0
- package/dist/convert.d.ts +157 -0
- package/dist/convert.js +423 -0
- package/dist/export.d.ts +15 -0
- package/dist/export.js +175 -0
- package/package.json +57 -4
- package/unity/SceNoCoImporter.cs +735 -0
package/README.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @scenoco-three/unity
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A SceNoCo game's prefabs and scenes, written into a Unity project as Unity's own assets.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx scenoco-unity path/to/UnityProject --root path/to/game
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Unity builds them the next time it compiles (or from **SceNoCo ▸ Import**):
|
|
10
|
+
|
|
11
|
+
- **Prefabs** in `Assets/Prefabs/`, one per prefab a `.tsx` file exports. `<ArrowBase />` written with
|
|
12
|
+
no attributes stays a nested prefab; a prefab given attributes is copied in.
|
|
13
|
+
- **Scenes** in `Assets/Scenes/`, listed in Build Settings with the one `main.ts` plays first: lights,
|
|
14
|
+
camera, fog, background and ambient light.
|
|
15
|
+
- **Meshes** in `Assets/Meshes/`, from three.js's own geometry, so a cone or a torus is exactly the
|
|
16
|
+
shape the web game draws. Two geometries with the same vertices are one mesh.
|
|
17
|
+
- **Materials** in `Assets/Materials/` (URP Lit or Unlit), shared exactly as three.js shares them: one
|
|
18
|
+
made in `assets.ts` is one asset for every renderer that uses it, so Unity batches them; one written
|
|
19
|
+
inside an element is that element's own, even if another looks the same.
|
|
20
|
+
- **Behaviours** as MonoBehaviours. A game behaviour gets a C# stub in `Assets/Scripts/` with the
|
|
21
|
+
fields its elements write, already set on every prefab and scene; its logic is yours to port. The
|
|
22
|
+
stub is written once and never overwritten. Rapier's and Box2D's components become Unity's own
|
|
23
|
+
rigidbodies and colliders.
|
|
24
|
+
|
|
25
|
+
The game is read in its own page, through its own Vite dev server and a headless Chrome: every
|
|
26
|
+
module but the entry is imported, so nothing starts, and each prefab is built with its behaviours
|
|
27
|
+
dormant. What a behaviour builds at runtime is not exported — only what the TSX declares. Whatever
|
|
28
|
+
cannot be carried across is listed as a warning, with the file and line it was written at.
|
|
29
|
+
|
|
30
|
+
three.js is right-handed and Unity left-handed: the export mirrors Z, and divides light intensities
|
|
31
|
+
by π.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scenoco-unity <unity-project> [--root <game>] [--headed]
|
|
4
|
+
*
|
|
5
|
+
* Writes the game's prefabs, scenes, meshes and materials into a Unity project: Unity builds them
|
|
6
|
+
* with its own API the next time it compiles (or from the menu, SceNoCo ▸ Import).
|
|
7
|
+
*/
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { snapshotProject, writeUnity } from './export.js';
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
const flag = (name) => {
|
|
12
|
+
const i = args.indexOf(name);
|
|
13
|
+
if (i < 0)
|
|
14
|
+
return undefined;
|
|
15
|
+
const value = args[i + 1];
|
|
16
|
+
args.splice(i, 2);
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
const headed = args.includes('--headed');
|
|
20
|
+
if (headed)
|
|
21
|
+
args.splice(args.indexOf('--headed'), 1);
|
|
22
|
+
const root = resolve(flag('--root') ?? '.');
|
|
23
|
+
const project = args[0];
|
|
24
|
+
if (!project || args.length > 1) {
|
|
25
|
+
console.error('usage: scenoco-unity <unity-project> [--root <game>] [--headed]');
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const snap = await snapshotProject(root, { headed });
|
|
30
|
+
const { converted, scripts } = writeUnity(snap, project, root);
|
|
31
|
+
const d = converted.data;
|
|
32
|
+
console.log(`${d.prefabs.length} prefabs, ${d.scenes.length} scenes, ${d.meshes.length} meshes, ${d.materials.length} materials → ${resolve(project)}`);
|
|
33
|
+
if (scripts.length)
|
|
34
|
+
console.log(`C# stubs for ${scripts.join(', ')} in Assets/Scripts — the fields are wired, the logic is yours to port.`);
|
|
35
|
+
for (const w of d.warnings)
|
|
36
|
+
console.log(`warning: ${w}`);
|
|
37
|
+
console.log('Unity builds them when it next compiles, or from SceNoCo ▸ Import.');
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A snapshot in Unity's terms: its handedness, its units, its asset paths, and C# for every game
|
|
3
|
+
* behaviour a prefab carries.
|
|
4
|
+
*
|
|
5
|
+
* three.js is right-handed and Unity left-handed, both Y up: mirroring Z maps one onto the other.
|
|
6
|
+
* A position or vector negates z, a rotation negates x and y of its quaternion, a mesh negates z of
|
|
7
|
+
* its vertices and normals, and reverses its triangles. A camera looks down its local -Z in three and
|
|
8
|
+
* +Z in Unity, which the same mirror carries across — so every vertex lands on the same pixel, and a
|
|
9
|
+
* triangle winds on screen as it did in three: counter-clockwise, which Unity culls as a back face. A light's intensity divides by π:
|
|
10
|
+
* three's lights are physical, Unity folds π into its own.
|
|
11
|
+
*
|
|
12
|
+
* Everything here is data in and data out; the importer in Unity only creates what it is told.
|
|
13
|
+
*/
|
|
14
|
+
import type { Snapshot } from '@scenoco-three/core/snapshot';
|
|
15
|
+
export type UnityValue = null | number | boolean | string | {
|
|
16
|
+
v2: number[];
|
|
17
|
+
} | {
|
|
18
|
+
v3: number[];
|
|
19
|
+
} | {
|
|
20
|
+
v4: number[];
|
|
21
|
+
} | {
|
|
22
|
+
quat: number[];
|
|
23
|
+
} | {
|
|
24
|
+
color: string;
|
|
25
|
+
} | {
|
|
26
|
+
array: UnityValue[];
|
|
27
|
+
} | {
|
|
28
|
+
node: string;
|
|
29
|
+
} | {
|
|
30
|
+
component: string;
|
|
31
|
+
node: string;
|
|
32
|
+
} | {
|
|
33
|
+
asset: string;
|
|
34
|
+
};
|
|
35
|
+
export interface UnityComponent {
|
|
36
|
+
type: string;
|
|
37
|
+
/** A game behaviour, as opposed to one of Unity's own (physics). */
|
|
38
|
+
game: boolean;
|
|
39
|
+
fields: Record<string, UnityValue>;
|
|
40
|
+
source?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface UnityLight {
|
|
43
|
+
type: 'Directional' | 'Point' | 'Spot';
|
|
44
|
+
color: string;
|
|
45
|
+
intensity: number;
|
|
46
|
+
shadows: boolean;
|
|
47
|
+
range?: number;
|
|
48
|
+
/** Full cone angle in degrees, Unity's spotAngle. */
|
|
49
|
+
spotAngle?: number;
|
|
50
|
+
innerSpotAngle?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface UnityCamera {
|
|
53
|
+
orthographic: boolean;
|
|
54
|
+
near: number;
|
|
55
|
+
far: number;
|
|
56
|
+
fov?: number;
|
|
57
|
+
size?: number;
|
|
58
|
+
}
|
|
59
|
+
export interface UnityNode {
|
|
60
|
+
name: string;
|
|
61
|
+
position: number[];
|
|
62
|
+
rotation: number[];
|
|
63
|
+
scale: number[];
|
|
64
|
+
/** Hidden in three with nothing on it to keep running: an inactive GameObject. */
|
|
65
|
+
active: boolean;
|
|
66
|
+
/** Hidden in three while a behaviour on it still runs: its renderers off, the object active. */
|
|
67
|
+
renderersEnabled: boolean;
|
|
68
|
+
mesh?: string;
|
|
69
|
+
materials?: string[];
|
|
70
|
+
castShadows?: boolean;
|
|
71
|
+
receiveShadows?: boolean;
|
|
72
|
+
light?: UnityLight;
|
|
73
|
+
camera?: UnityCamera;
|
|
74
|
+
components: UnityComponent[];
|
|
75
|
+
/** A nested prefab's asset path. */
|
|
76
|
+
prefab?: string;
|
|
77
|
+
children: UnityNode[];
|
|
78
|
+
source?: string;
|
|
79
|
+
}
|
|
80
|
+
export interface UnityMesh {
|
|
81
|
+
path: string;
|
|
82
|
+
vertices: number[];
|
|
83
|
+
normals?: number[];
|
|
84
|
+
uv?: number[];
|
|
85
|
+
/** One triangle list per submesh, wound for Unity. */
|
|
86
|
+
submeshes: number[][];
|
|
87
|
+
}
|
|
88
|
+
export interface UnityMaterial {
|
|
89
|
+
path: string;
|
|
90
|
+
shader: 'lit' | 'unlit';
|
|
91
|
+
color: string;
|
|
92
|
+
opacity: number;
|
|
93
|
+
transparent: boolean;
|
|
94
|
+
alphaClip?: number;
|
|
95
|
+
/** Unity's CullMode: 0 off, 1 front, 2 back. */
|
|
96
|
+
cull: number;
|
|
97
|
+
zWrite: boolean;
|
|
98
|
+
smoothness?: number;
|
|
99
|
+
metallic?: number;
|
|
100
|
+
emission?: string;
|
|
101
|
+
emissionIntensity?: number;
|
|
102
|
+
/** The texture's asset path, once the exporter copied it in. */
|
|
103
|
+
texture?: string;
|
|
104
|
+
}
|
|
105
|
+
export interface UnityPrefab {
|
|
106
|
+
path: string;
|
|
107
|
+
root: UnityNode;
|
|
108
|
+
}
|
|
109
|
+
export interface UnityScene {
|
|
110
|
+
path: string;
|
|
111
|
+
roots: UnityNode[];
|
|
112
|
+
/** Components the scene's own element carried: Unity has no scene object, so a root holds them. */
|
|
113
|
+
background?: string;
|
|
114
|
+
fog?: {
|
|
115
|
+
mode: 'linear';
|
|
116
|
+
color: string;
|
|
117
|
+
start: number;
|
|
118
|
+
end: number;
|
|
119
|
+
} | {
|
|
120
|
+
mode: 'exp2';
|
|
121
|
+
color: string;
|
|
122
|
+
density: number;
|
|
123
|
+
};
|
|
124
|
+
ambient?: {
|
|
125
|
+
sky: string;
|
|
126
|
+
equator: string;
|
|
127
|
+
ground: string;
|
|
128
|
+
intensity: number;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export interface UnityExport {
|
|
132
|
+
meshes: UnityMesh[];
|
|
133
|
+
materials: UnityMaterial[];
|
|
134
|
+
/** Ordered so a nested prefab comes before the prefabs that use it. */
|
|
135
|
+
prefabs: UnityPrefab[];
|
|
136
|
+
/** The first is the one to play first. */
|
|
137
|
+
scenes: UnityScene[];
|
|
138
|
+
warnings: string[];
|
|
139
|
+
}
|
|
140
|
+
/** A texture's URL as the page loaded it, and where its file is in the project. */
|
|
141
|
+
export type TextureSource = (url: string) => {
|
|
142
|
+
file: string;
|
|
143
|
+
name: string;
|
|
144
|
+
} | null;
|
|
145
|
+
export interface Converted {
|
|
146
|
+
data: UnityExport;
|
|
147
|
+
/** C# for each game behaviour, by class name. */
|
|
148
|
+
scripts: Map<string, string>;
|
|
149
|
+
/** Files to copy into the Unity project: source → path under it. */
|
|
150
|
+
copies: Map<string, string>;
|
|
151
|
+
}
|
|
152
|
+
/** A game behaviour's C# class name. */
|
|
153
|
+
export declare const csName: (type: string) => string;
|
|
154
|
+
export declare function convert(snap: Snapshot, options?: {
|
|
155
|
+
firstScene?: string;
|
|
156
|
+
textures?: TextureSource;
|
|
157
|
+
}): Converted;
|
package/dist/convert.js
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Names Unity already gives a component or a class every script sees. A game behaviour called one
|
|
3
|
+
* of them cannot be added to an object (Unity refuses a script named `Grid`), or shadows what a port
|
|
4
|
+
* writes (`Time.deltaTime`), so its C# class is `GridBehaviour`.
|
|
5
|
+
*/
|
|
6
|
+
const UNITY_NAMES = new Set(('Transform RectTransform Camera Light Animator Animation AudioSource AudioListener Rigidbody Rigidbody2D Collider ' +
|
|
7
|
+
'BoxCollider SphereCollider CapsuleCollider MeshCollider CharacterController Joint HingeJoint FixedJoint SpringJoint Cloth ' +
|
|
8
|
+
'MeshFilter MeshRenderer SkinnedMeshRenderer Renderer LineRenderer TrailRenderer SpriteRenderer ParticleSystem Terrain Tree ' +
|
|
9
|
+
'WindZone LODGroup Projector Skybox Grid Tilemap TilemapRenderer Canvas CanvasGroup CanvasRenderer Text Image RawImage Button ' +
|
|
10
|
+
'Toggle Slider Scrollbar Dropdown InputField Mask Outline Shadow LayoutElement EventSystem NavMeshAgent NavMeshObstacle ' +
|
|
11
|
+
'ReflectionProbe LightProbeGroup Halo LensFlare FlareLayer VideoPlayer PlayableDirector ConstantForce SortingGroup Behaviour ' +
|
|
12
|
+
'MonoBehaviour Component GameObject Object Physics Physics2D Input Time Random Debug Mathf Vector2 Vector3 Vector4 Quaternion ' +
|
|
13
|
+
'Color Material Mesh Texture Texture2D Shader Application Screen Cursor Gizmos Resources SceneManager Event Ray Plane Bounds')
|
|
14
|
+
.split(' '));
|
|
15
|
+
/** A game behaviour's C# class name. */
|
|
16
|
+
export const csName = (type) => (UNITY_NAMES.has(type) ? `${type}Behaviour` : type);
|
|
17
|
+
const flipV = ([x, y, z]) => [x, y, z === 0 ? 0 : -z];
|
|
18
|
+
const flipQ = ([x, y, z, w]) => [x === 0 ? 0 : -x, y === 0 ? 0 : -y, z, w];
|
|
19
|
+
export function convert(snap, options = {}) {
|
|
20
|
+
const warnings = [...snap.warnings];
|
|
21
|
+
const paths = new Paths();
|
|
22
|
+
const copies = new Map();
|
|
23
|
+
// three draws a geometry's groups only for a mesh given a list of materials; with one material it
|
|
24
|
+
// draws everything with it. A BoxGeometry has six groups, so this decides whether it is one submesh or six.
|
|
25
|
+
const multi = new Set();
|
|
26
|
+
const scan = (n) => {
|
|
27
|
+
if (n.geometry && (n.materials?.length ?? 0) > 1)
|
|
28
|
+
multi.add(n.geometry);
|
|
29
|
+
n.children.forEach(scan);
|
|
30
|
+
};
|
|
31
|
+
snap.prefabs.forEach((p) => scan(p.root));
|
|
32
|
+
const meshPath = new Map();
|
|
33
|
+
const meshes = Object.entries(snap.geometries).map(([id, g]) => {
|
|
34
|
+
const path = paths.take('Assets/Meshes', g.name, '.asset');
|
|
35
|
+
meshPath.set(id, path);
|
|
36
|
+
return mesh(multi.has(id) ? g : { ...g, groups: [] }, path);
|
|
37
|
+
});
|
|
38
|
+
const materialPath = new Map();
|
|
39
|
+
const materials = Object.entries(snap.materials).map(([id, m]) => {
|
|
40
|
+
const path = paths.take('Assets/Materials', m.name, '.mat');
|
|
41
|
+
materialPath.set(id, path);
|
|
42
|
+
return material(m, path, options.textures, copies, warnings);
|
|
43
|
+
});
|
|
44
|
+
const prefabPath = new Map();
|
|
45
|
+
const sceneName = new Map();
|
|
46
|
+
for (const p of snap.prefabs) {
|
|
47
|
+
if (p.scene)
|
|
48
|
+
sceneName.set(p.id, paths.take('Assets/Scenes', p.name, '.unity').replace(/^.*\/|\.unity$/g, ''));
|
|
49
|
+
else
|
|
50
|
+
prefabPath.set(p.id, paths.take('Assets/Prefabs', p.name, '.prefab'));
|
|
51
|
+
}
|
|
52
|
+
const value = (v) => {
|
|
53
|
+
if (v === null || typeof v !== 'object')
|
|
54
|
+
return v;
|
|
55
|
+
if ('v3' in v)
|
|
56
|
+
return { v3: flipV(v.v3) };
|
|
57
|
+
if ('quat' in v)
|
|
58
|
+
return { quat: flipQ(v.quat) };
|
|
59
|
+
if ('array' in v)
|
|
60
|
+
return { array: v.array.map(value) };
|
|
61
|
+
if ('prefab' in v)
|
|
62
|
+
return sceneName.get(v.prefab) ?? (prefabPath.has(v.prefab) ? { asset: prefabPath.get(v.prefab) } : null);
|
|
63
|
+
if ('component' in v)
|
|
64
|
+
return { component: csName(v.component), node: v.node };
|
|
65
|
+
if ('material' in v)
|
|
66
|
+
return { asset: materialPath.get(v.material) };
|
|
67
|
+
if ('geometry' in v)
|
|
68
|
+
return { asset: meshPath.get(v.geometry) };
|
|
69
|
+
return v;
|
|
70
|
+
};
|
|
71
|
+
let ambient;
|
|
72
|
+
const node = (n) => {
|
|
73
|
+
const out = {
|
|
74
|
+
name: n.name,
|
|
75
|
+
position: flipV(n.position),
|
|
76
|
+
rotation: flipQ(n.quaternion),
|
|
77
|
+
scale: n.scale,
|
|
78
|
+
active: true,
|
|
79
|
+
renderersEnabled: true,
|
|
80
|
+
components: n.components.map((c) => {
|
|
81
|
+
const fields = {};
|
|
82
|
+
for (const [k, v] of Object.entries(c.fields))
|
|
83
|
+
fields[k] = value(v);
|
|
84
|
+
const component = { type: c.game ? csName(c.type) : c.type, game: c.game, fields };
|
|
85
|
+
if (c.source)
|
|
86
|
+
component.source = c.source;
|
|
87
|
+
return component;
|
|
88
|
+
}),
|
|
89
|
+
children: n.children.map(node),
|
|
90
|
+
};
|
|
91
|
+
if (n.source)
|
|
92
|
+
out.source = n.source;
|
|
93
|
+
if (!n.visible) {
|
|
94
|
+
// three hides the subtree and keeps its behaviours running; Unity's inactive object stops them.
|
|
95
|
+
if (hasComponents(n))
|
|
96
|
+
out.renderersEnabled = false;
|
|
97
|
+
else
|
|
98
|
+
out.active = false;
|
|
99
|
+
}
|
|
100
|
+
if (n.prefab) {
|
|
101
|
+
const path = prefabPath.get(n.prefab);
|
|
102
|
+
if (path)
|
|
103
|
+
out.prefab = path;
|
|
104
|
+
else
|
|
105
|
+
warnings.push(`${n.source ?? n.name}: nested ${n.prefab} is not a prefab of its own — left empty.`);
|
|
106
|
+
}
|
|
107
|
+
if (n.geometry)
|
|
108
|
+
out.mesh = meshPath.get(n.geometry);
|
|
109
|
+
if (n.materials && n.geometry)
|
|
110
|
+
out.materials = n.materials.map((id) => materialPath.get(id));
|
|
111
|
+
if (n.geometry) {
|
|
112
|
+
out.castShadows = n.castShadow;
|
|
113
|
+
out.receiveShadows = n.receiveShadow;
|
|
114
|
+
}
|
|
115
|
+
if (n.camera) {
|
|
116
|
+
const c = n.camera;
|
|
117
|
+
out.camera = c.fov !== undefined
|
|
118
|
+
? { orthographic: false, near: c.near, far: c.far, fov: c.fov / c.zoom }
|
|
119
|
+
: { orthographic: true, near: c.near, far: c.far, size: (c.top - c.bottom) / 2 / c.zoom };
|
|
120
|
+
}
|
|
121
|
+
if (n.light) {
|
|
122
|
+
const l = n.light;
|
|
123
|
+
if (l.type === 'HemisphereLight' || l.type === 'AmbientLight') {
|
|
124
|
+
if (ambient)
|
|
125
|
+
warnings.push(`${n.source ?? n.name}: a second ambient light — only the first sets the scene's ambient.`);
|
|
126
|
+
else
|
|
127
|
+
ambient = { sky: l.color, equator: l.groundColor ? mix(l.color, l.groundColor) : l.color, ground: l.groundColor ?? l.color, intensity: l.intensity / Math.PI };
|
|
128
|
+
}
|
|
129
|
+
else if (l.type === 'DirectionalLight' || l.type === 'PointLight' || l.type === 'SpotLight') {
|
|
130
|
+
const light = { type: l.type.replace('Light', ''), color: l.color, intensity: l.intensity / Math.PI, shadows: l.castShadow };
|
|
131
|
+
if (l.type !== 'DirectionalLight')
|
|
132
|
+
light.range = l.distance || Math.sqrt(l.intensity / Math.PI) * 10;
|
|
133
|
+
if (l.type === 'SpotLight') {
|
|
134
|
+
light.spotAngle = ((l.angle ?? Math.PI / 3) * 2 * 180) / Math.PI;
|
|
135
|
+
light.innerSpotAngle = light.spotAngle * (1 - (l.penumbra ?? 0));
|
|
136
|
+
}
|
|
137
|
+
if (l.direction)
|
|
138
|
+
out.rotation = lookRotation(flipV(l.direction));
|
|
139
|
+
out.light = light;
|
|
140
|
+
}
|
|
141
|
+
else
|
|
142
|
+
warnings.push(`${n.source ?? n.name}: a ${l.type} is not exported.`);
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
};
|
|
146
|
+
const prefabs = [];
|
|
147
|
+
const scenes = [];
|
|
148
|
+
for (const p of order(snap.prefabs)) {
|
|
149
|
+
if (!p.scene) {
|
|
150
|
+
prefabs.push({ path: prefabPath.get(p.id), root: node(p.root) });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
ambient = undefined;
|
|
154
|
+
const root = node(p.root);
|
|
155
|
+
// The scene's own components, if any, need an object to live on.
|
|
156
|
+
const roots = root.components.length ? [{ ...root, name: p.name, position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1], children: [] }, ...root.children] : root.children;
|
|
157
|
+
const scene = { path: `Assets/Scenes/${sceneName.get(p.id)}.unity`, roots };
|
|
158
|
+
if (p.background)
|
|
159
|
+
scene.background = p.background;
|
|
160
|
+
if (p.fog)
|
|
161
|
+
scene.fog = p.fog.type === 'linear' ? { mode: 'linear', color: p.fog.color, start: p.fog.near, end: p.fog.far } : { mode: 'exp2', color: p.fog.color, density: p.fog.density };
|
|
162
|
+
if (ambient)
|
|
163
|
+
scene.ambient = ambient;
|
|
164
|
+
scenes.push(scene);
|
|
165
|
+
}
|
|
166
|
+
if (options.firstScene) {
|
|
167
|
+
const first = scenes.findIndex((s) => s.path.endsWith(`/${options.firstScene}.unity`));
|
|
168
|
+
if (first > 0)
|
|
169
|
+
scenes.unshift(...scenes.splice(first, 1));
|
|
170
|
+
}
|
|
171
|
+
return { data: { meshes, materials, prefabs, scenes, warnings }, scripts: scripts(snap, value), copies };
|
|
172
|
+
}
|
|
173
|
+
function hasComponents(n) {
|
|
174
|
+
return n.components.length > 0 || n.children.some(hasComponents);
|
|
175
|
+
}
|
|
176
|
+
/** Asset paths, one per name per folder, made safe for a file system and for Unity. */
|
|
177
|
+
class Paths {
|
|
178
|
+
taken = new Set();
|
|
179
|
+
take(folder, name, ext) {
|
|
180
|
+
const base = name.replace(/[^\w.-]+/g, '_').replace(/^_+|_+$/g, '') || 'Unnamed';
|
|
181
|
+
let path = `${folder}/${base}${ext}`;
|
|
182
|
+
for (let i = 2; this.taken.has(path.toLowerCase()); i++)
|
|
183
|
+
path = `${folder}/${base}_${i}${ext}`;
|
|
184
|
+
this.taken.add(path.toLowerCase());
|
|
185
|
+
return path;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function mesh(g, path) {
|
|
189
|
+
const vertices = g.position.map((v, i) => (i % 3 === 2 && v !== 0 ? -v : v));
|
|
190
|
+
const out = { path, vertices, submeshes: [] };
|
|
191
|
+
if (g.normal)
|
|
192
|
+
out.normals = g.normal.map((v, i) => (i % 3 === 2 && v !== 0 ? -v : v));
|
|
193
|
+
if (g.uv)
|
|
194
|
+
out.uv = g.uv;
|
|
195
|
+
// Mirrored with its camera, a triangle still winds counter-clockwise on screen — three's front, Unity's back.
|
|
196
|
+
const wind = (from, count) => {
|
|
197
|
+
const out = [];
|
|
198
|
+
for (let i = from; i + 2 < from + count; i += 3)
|
|
199
|
+
out.push(g.index[i], g.index[i + 2], g.index[i + 1]);
|
|
200
|
+
return out;
|
|
201
|
+
};
|
|
202
|
+
if (!g.groups.length)
|
|
203
|
+
out.submeshes.push(wind(0, g.index.length));
|
|
204
|
+
else {
|
|
205
|
+
const count = Math.max(...g.groups.map((gr) => gr.materialIndex)) + 1;
|
|
206
|
+
out.submeshes = Array.from({ length: count }, () => []);
|
|
207
|
+
for (const gr of g.groups)
|
|
208
|
+
out.submeshes[gr.materialIndex].push(...wind(gr.start, Math.min(gr.count, g.index.length - gr.start)));
|
|
209
|
+
}
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
function material(m, path, textures, copies, warnings) {
|
|
213
|
+
const lit = !/Basic/.test(m.type);
|
|
214
|
+
const out = {
|
|
215
|
+
path,
|
|
216
|
+
shader: lit ? 'lit' : 'unlit',
|
|
217
|
+
color: m.color ?? '#ffffff',
|
|
218
|
+
opacity: m.opacity,
|
|
219
|
+
transparent: m.transparent,
|
|
220
|
+
cull: m.side === 2 ? 0 : m.side === 1 ? 1 : 2,
|
|
221
|
+
zWrite: m.depthWrite,
|
|
222
|
+
};
|
|
223
|
+
if (m.alphaTest > 0)
|
|
224
|
+
out.alphaClip = m.alphaTest;
|
|
225
|
+
if (lit) {
|
|
226
|
+
out.smoothness = m.roughness !== undefined ? 1 - m.roughness : m.shininess !== undefined ? Math.min(1, m.shininess / 100) : 0.5;
|
|
227
|
+
out.metallic = m.metalness ?? 0;
|
|
228
|
+
}
|
|
229
|
+
if (m.emissive && m.emissive !== '#000000' && (m.emissiveIntensity ?? 1) > 0) {
|
|
230
|
+
out.emission = m.emissive;
|
|
231
|
+
out.emissionIntensity = m.emissiveIntensity ?? 1;
|
|
232
|
+
}
|
|
233
|
+
if (m.map) {
|
|
234
|
+
const found = textures?.(m.map);
|
|
235
|
+
if (found) {
|
|
236
|
+
out.texture = `Assets/Textures/${found.name}`;
|
|
237
|
+
copies.set(found.file, out.texture);
|
|
238
|
+
}
|
|
239
|
+
else
|
|
240
|
+
warnings.push(`${m.source ?? m.name}: texture ${m.map} was not found in the project — left off.`);
|
|
241
|
+
}
|
|
242
|
+
if (m.vertexColors)
|
|
243
|
+
warnings.push(`${m.source ?? m.name}: vertex colours are not exported.`);
|
|
244
|
+
if (m.flatShading)
|
|
245
|
+
warnings.push(`${m.source ?? m.name}: flat shading is not exported — the mesh keeps its normals.`);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/** Prefabs before the prefabs and scenes that nest them, so each exists when it is placed. */
|
|
249
|
+
function order(prefabs) {
|
|
250
|
+
const byId = new Map(prefabs.map((p) => [p.id, p]));
|
|
251
|
+
const done = new Set();
|
|
252
|
+
const out = [];
|
|
253
|
+
const visit = (p, path) => {
|
|
254
|
+
if (done.has(p.id) || path.has(p.id))
|
|
255
|
+
return;
|
|
256
|
+
path.add(p.id);
|
|
257
|
+
const deps = (n) => [...(n.prefab ? [n.prefab] : []), ...n.children.flatMap(deps)];
|
|
258
|
+
for (const dep of deps(p.root)) {
|
|
259
|
+
const d = byId.get(dep);
|
|
260
|
+
if (d)
|
|
261
|
+
visit(d, path);
|
|
262
|
+
}
|
|
263
|
+
done.add(p.id);
|
|
264
|
+
out.push(p);
|
|
265
|
+
};
|
|
266
|
+
for (const p of prefabs)
|
|
267
|
+
visit(p, new Set());
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
/** Unity's Quaternion.LookRotation(forward, up = Y). */
|
|
271
|
+
function lookRotation([fx, fy, fz]) {
|
|
272
|
+
let f = [fx, fy, fz];
|
|
273
|
+
const len = Math.hypot(...f) || 1;
|
|
274
|
+
f = f.map((v) => v / len);
|
|
275
|
+
let up = [0, 1, 0];
|
|
276
|
+
if (Math.abs(f[1]) > 0.999)
|
|
277
|
+
up = [0, 0, 1];
|
|
278
|
+
const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
|
|
279
|
+
let r = cross(up, f);
|
|
280
|
+
const rl = Math.hypot(...r) || 1;
|
|
281
|
+
r = r.map((v) => v / rl);
|
|
282
|
+
const u = cross(f, r);
|
|
283
|
+
// Columns r, u, f are the rotated axes; turn the matrix into a quaternion.
|
|
284
|
+
const [m00, m01, m02, m10, m11, m12, m20, m21, m22] = [r[0], u[0], f[0], r[1], u[1], f[1], r[2], u[2], f[2]];
|
|
285
|
+
const trace = m00 + m11 + m22;
|
|
286
|
+
let x, y, z, w;
|
|
287
|
+
if (trace > 0) {
|
|
288
|
+
const s = Math.sqrt(trace + 1) * 2;
|
|
289
|
+
w = s / 4;
|
|
290
|
+
x = (m21 - m12) / s;
|
|
291
|
+
y = (m02 - m20) / s;
|
|
292
|
+
z = (m10 - m01) / s;
|
|
293
|
+
}
|
|
294
|
+
else if (m00 > m11 && m00 > m22) {
|
|
295
|
+
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
|
296
|
+
w = (m21 - m12) / s;
|
|
297
|
+
x = s / 4;
|
|
298
|
+
y = (m01 + m10) / s;
|
|
299
|
+
z = (m02 + m20) / s;
|
|
300
|
+
}
|
|
301
|
+
else if (m11 > m22) {
|
|
302
|
+
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
|
303
|
+
w = (m02 - m20) / s;
|
|
304
|
+
x = (m01 + m10) / s;
|
|
305
|
+
y = s / 4;
|
|
306
|
+
z = (m12 + m21) / s;
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
|
310
|
+
w = (m10 - m01) / s;
|
|
311
|
+
x = (m02 + m20) / s;
|
|
312
|
+
y = (m12 + m21) / s;
|
|
313
|
+
z = s / 4;
|
|
314
|
+
}
|
|
315
|
+
return [x, y, z, w].map((v) => Math.round(v * 1e6) / 1e6);
|
|
316
|
+
}
|
|
317
|
+
function mix(a, b) {
|
|
318
|
+
const p = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
319
|
+
const [x, y] = [p(a), p(b)];
|
|
320
|
+
return `#${x.map((v, i) => Math.round((v + y[i]) / 2).toString(16).padStart(2, '0')).join('')}`;
|
|
321
|
+
}
|
|
322
|
+
// --- C# for game behaviours ----------------------------------------------------------------------
|
|
323
|
+
/**
|
|
324
|
+
* One MonoBehaviour per game behaviour, with the fields its elements write — typed from the values
|
|
325
|
+
* written, started from the defaults a fresh instance holds. The logic is a port's to write; these
|
|
326
|
+
* exist so the prefabs can carry the components and their settings now.
|
|
327
|
+
*/
|
|
328
|
+
function scripts(snap, value) {
|
|
329
|
+
const seen = new Map();
|
|
330
|
+
const visit = (n) => {
|
|
331
|
+
for (const c of n.components) {
|
|
332
|
+
if (!c.game)
|
|
333
|
+
continue;
|
|
334
|
+
const fields = seen.get(c.type) ?? new Map();
|
|
335
|
+
seen.set(c.type, fields);
|
|
336
|
+
for (const [k, v] of Object.entries(c.fields))
|
|
337
|
+
fields.set(k, [...(fields.get(k) ?? []), value(v)]);
|
|
338
|
+
}
|
|
339
|
+
n.children.forEach(visit);
|
|
340
|
+
};
|
|
341
|
+
snap.prefabs.forEach((p) => visit(p.root));
|
|
342
|
+
const out = new Map();
|
|
343
|
+
for (const [type, fields] of seen) {
|
|
344
|
+
const info = snap.classes[type];
|
|
345
|
+
const lines = [...fields].map(([name, values]) => {
|
|
346
|
+
const cs = csType(values);
|
|
347
|
+
const fallback = info?.defaults[name];
|
|
348
|
+
const init = fallback === undefined ? '' : csValue(value(fallback), cs);
|
|
349
|
+
return cs ? ` public ${cs} ${name}${init ? ` = ${init}` : ''};` : ` // ${name}: always empty where it is written, so its type is unknown`;
|
|
350
|
+
});
|
|
351
|
+
const name = csName(type);
|
|
352
|
+
out.set(name, [
|
|
353
|
+
'using UnityEngine;',
|
|
354
|
+
'',
|
|
355
|
+
`// The fields ${info?.file ?? 'the game'} sets on ${type} in its scenes and prefabs. Port its logic here;`,
|
|
356
|
+
'// keep the field names, the prefabs are wired by them. Written by scenoco-unity once, never overwritten.',
|
|
357
|
+
...(name !== type ? [`// Named ${name}: Unity has its own ${type}.`] : []),
|
|
358
|
+
`public class ${name} : MonoBehaviour`,
|
|
359
|
+
'{',
|
|
360
|
+
...lines,
|
|
361
|
+
'}',
|
|
362
|
+
'',
|
|
363
|
+
].join('\n'));
|
|
364
|
+
}
|
|
365
|
+
return out;
|
|
366
|
+
}
|
|
367
|
+
function csType(values) {
|
|
368
|
+
const known = values.find((v) => v !== null && !(typeof v === 'object' && 'array' in v && v.array.length === 0)) ?? values[0];
|
|
369
|
+
if (known === undefined || known === null)
|
|
370
|
+
return undefined;
|
|
371
|
+
if (typeof known === 'number')
|
|
372
|
+
return 'float';
|
|
373
|
+
if (typeof known === 'boolean')
|
|
374
|
+
return 'bool';
|
|
375
|
+
if (typeof known === 'string')
|
|
376
|
+
return 'string';
|
|
377
|
+
if ('v2' in known)
|
|
378
|
+
return 'Vector2';
|
|
379
|
+
if ('v3' in known)
|
|
380
|
+
return 'Vector3';
|
|
381
|
+
if ('v4' in known)
|
|
382
|
+
return 'Vector4';
|
|
383
|
+
if ('quat' in known)
|
|
384
|
+
return 'Quaternion';
|
|
385
|
+
if ('color' in known)
|
|
386
|
+
return 'Color';
|
|
387
|
+
if ('component' in known)
|
|
388
|
+
return known.component;
|
|
389
|
+
if ('node' in known)
|
|
390
|
+
return 'Transform';
|
|
391
|
+
if ('asset' in known)
|
|
392
|
+
return known.asset.endsWith('.prefab') ? 'GameObject' : known.asset.endsWith('.mat') ? 'Material' : 'Mesh';
|
|
393
|
+
if ('array' in known) {
|
|
394
|
+
const inner = csType(values.flatMap((v) => (v && typeof v === 'object' && 'array' in v ? v.array : [])));
|
|
395
|
+
return inner ? `${inner}[]` : undefined;
|
|
396
|
+
}
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
function csValue(v, type) {
|
|
400
|
+
const f = (n) => `${Number.isInteger(n) ? n : n}f`;
|
|
401
|
+
if (v === null || type === undefined)
|
|
402
|
+
return '';
|
|
403
|
+
if (typeof v === 'number')
|
|
404
|
+
return type === 'float' ? f(v) : '';
|
|
405
|
+
if (typeof v === 'boolean')
|
|
406
|
+
return type === 'bool' ? String(v) : '';
|
|
407
|
+
if (typeof v === 'string')
|
|
408
|
+
return type === 'string' ? JSON.stringify(v) : '';
|
|
409
|
+
if ('v2' in v && type === 'Vector2')
|
|
410
|
+
return `new Vector2(${v.v2.map(f).join(', ')})`;
|
|
411
|
+
if ('v3' in v && type === 'Vector3')
|
|
412
|
+
return `new Vector3(${v.v3.map(f).join(', ')})`;
|
|
413
|
+
if ('color' in v && type === 'Color') {
|
|
414
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(v.color.slice(i, i + 2), 16) / 255);
|
|
415
|
+
return `new Color(${[r, g, b].map((n) => f(Math.round(n * 1000) / 1000)).join(', ')})`;
|
|
416
|
+
}
|
|
417
|
+
if ('array' in v && type.endsWith('[]')) {
|
|
418
|
+
const inner = type.slice(0, -2);
|
|
419
|
+
const items = v.array.map((i) => csValue(i, inner));
|
|
420
|
+
return items.length && items.every(Boolean) ? `{ ${items.join(', ')} }` : '';
|
|
421
|
+
}
|
|
422
|
+
return '';
|
|
423
|
+
}
|
package/dist/export.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Snapshot } from '@scenoco-three/core/snapshot';
|
|
2
|
+
import { type Converted } from './convert.js';
|
|
3
|
+
/** Every module under `src/` the page may import: TypeScript and TSX, the game's entries left out. */
|
|
4
|
+
export declare function gameModules(root: string): string[];
|
|
5
|
+
/** Build the project's prefabs and scenes in its own page, and describe them. */
|
|
6
|
+
export declare function snapshotProject(root: string, options?: {
|
|
7
|
+
headed?: boolean;
|
|
8
|
+
}): Promise<Snapshot>;
|
|
9
|
+
export interface Written {
|
|
10
|
+
converted: Converted;
|
|
11
|
+
/** Stubs written; the rest already had a class of that name in the project. */
|
|
12
|
+
scripts: string[];
|
|
13
|
+
}
|
|
14
|
+
/** Convert a snapshot and write it into the Unity project: data for the importer, the importer, C# stubs, textures. */
|
|
15
|
+
export declare function writeUnity(snap: Snapshot, project: string, root: string): Written;
|