@bitmagic/cli 0.1.61-dev.9 → 0.1.62-dev.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.
Files changed (60) hide show
  1. package/README.md +27 -3
  2. package/assets/unity-import/README.md +13 -0
  3. package/assets/unity-import/blender_build.py +462 -0
  4. package/assets/unity-import/build_parts.py +263 -0
  5. package/assets/unity-import/convert_assets.py +193 -0
  6. package/assets/unity-import/fbx_ascii.py +156 -0
  7. package/assets/unity-import/fbx_axis.py +174 -0
  8. package/assets/unity-import/fbx_bounds.py +202 -0
  9. package/assets/unity-import/import_config.py +86 -0
  10. package/assets/unity-import/level_patches.example.json +23 -0
  11. package/assets/unity-import/mesh_walls.py +78 -0
  12. package/assets/unity-import/render_topdown.py +33 -0
  13. package/assets/unity-import/unity-import.example.json +27 -0
  14. package/assets/unity-import/unity_assets.py +114 -0
  15. package/assets/unity-import/unity_colliders2d.py +127 -0
  16. package/assets/unity-import/unity_extract.py +774 -0
  17. package/assets/unity-import/unity_markers.py +249 -0
  18. package/assets/unity-import/unity_renderers.py +279 -0
  19. package/assets/unity-import/write_arena_data.py +198 -0
  20. package/dist/cli.d.ts +12 -0
  21. package/dist/commands/import.d.ts +4 -0
  22. package/dist/commands/import.js +5 -0
  23. package/dist/commands/import.js.map +1 -1
  24. package/dist/commands/init.d.ts +4 -0
  25. package/dist/commands/init.js +20 -0
  26. package/dist/commands/init.js.map +1 -1
  27. package/dist/commands/upgrade.d.ts +4 -0
  28. package/dist/commands/upgrade.js +31 -5
  29. package/dist/commands/upgrade.js.map +1 -1
  30. package/dist/editor/server.js +3 -3
  31. package/dist/editor/shell-page.js +16 -1
  32. package/dist/editor/shell-page.js.map +1 -1
  33. package/dist/project/context.d.ts +9 -0
  34. package/dist/project/context.js +4 -1
  35. package/dist/project/context.js.map +1 -1
  36. package/dist/scaffold/agent-targets.d.ts +126 -0
  37. package/dist/scaffold/agent-targets.js +180 -0
  38. package/dist/scaffold/agent-targets.js.map +1 -0
  39. package/dist/scaffold/agents-md.d.ts +4 -1
  40. package/dist/scaffold/agents-md.js +6 -2
  41. package/dist/scaffold/agents-md.js.map +1 -1
  42. package/dist/scaffold/project-files.d.ts +40 -5
  43. package/dist/scaffold/project-files.js +67 -25
  44. package/dist/scaffold/project-files.js.map +1 -1
  45. package/dist/scaffold/project.d.ts +42 -15
  46. package/dist/scaffold/project.js +100 -38
  47. package/dist/scaffold/project.js.map +1 -1
  48. package/dist/scaffold/reference-guidance.d.ts +1 -1
  49. package/dist/scaffold/reference-guidance.js +2 -1
  50. package/dist/scaffold/reference-guidance.js.map +1 -1
  51. package/dist/scaffold/skill-files.d.ts +32 -9
  52. package/dist/scaffold/skill-files.js +71 -15
  53. package/dist/scaffold/skill-files.js.map +1 -1
  54. package/dist/scaffold/unity-skill-files.d.ts +30 -0
  55. package/dist/scaffold/unity-skill-files.js +474 -0
  56. package/dist/scaffold/unity-skill-files.js.map +1 -0
  57. package/dist/scaffold/upgrade-project.d.ts +31 -11
  58. package/dist/scaffold/upgrade-project.js +35 -23
  59. package/dist/scaffold/upgrade-project.js.map +1 -1
  60. package/package.json +4 -4
@@ -0,0 +1,174 @@
1
+ """Work out, per asset pack, how Unity's mesh axes relate to the raw FBX vertex axes.
2
+
3
+ They are not always the same. One pack's models import with their axes untouched, while another
4
+ kit's come in cyclically permuted — Unity's mesh is `(raw_y, raw_z, raw_x)`. The
5
+ difference lives in the exporter's node/geometric transforms and is a property of how each pack was
6
+ authored, so it cannot be read off the file's declared up-axis (both packs declare Y-up).
7
+
8
+ Rather than guess, this derives the permutation from Unity's own data: a GameObject carrying both a
9
+ MeshFilter and a BoxCollider states the mesh's dimensions outright in `m_Size`, with no FBX parsing
10
+ involved. Comparing that against the raw bounds identifies the permutation, and the answer is
11
+ consistent across a pack, so a pack with any evidence at all resolves every model in it.
12
+
13
+ Both the collider extractor and the GLB build must apply the same correction, or the geometry a
14
+ player sees and the geometry they collide with disagree.
15
+ """
16
+ import os
17
+ import re
18
+
19
+ # Candidate axis permutations, as index tuples into the raw FBX bounds.
20
+ PERMUTATIONS = {
21
+ 'identity': (0, 1, 2),
22
+ 'swapYZ': (0, 2, 1),
23
+ 'swapXY': (1, 0, 2),
24
+ 'swapXZ': (2, 1, 0),
25
+ 'cycYZX': (1, 2, 0),
26
+ 'cycZXY': (2, 0, 1),
27
+ }
28
+
29
+ # Unity converts a right-handed FBX into its own left-handed space by negating X, so a model's
30
+ # axes are not merely permuted — one of them is flipped. Sizes alone cannot see that (a box is the
31
+ # same size mirrored), which is why deriving from `m_Size` produced meshes that looked right on
32
+ # their own and assembled wrongly: every one was mirrored. `m_Center` does see it, because a mesh
33
+ # whose geometry sits off its own origin lands on the opposite side when flipped.
34
+ #
35
+ # Used when a pack has no BoxCollider evidence at all, in place of the old plain identity.
36
+ # Unity negates X converting a right-handed FBX into its left-handed space — on every import.
37
+ # This is the second half of the orientation fix (the first is the geometric transform, applied in
38
+ # fbx_bounds). It was found, lost and found again: m_Center fits said (-1,1,1) for 157 of 181
39
+ # models, an arena-symmetry metric that cannot see a uniform mirror said "worse", and the metric
40
+ # was wrongly trusted. A pair of end caps settled it: two quarter-rounds only join into one
41
+ # smooth half-round with the negation applied. Proven afterwards with no extra sign needed on top
42
+ # of the reader's output for 157/181 boxes, 137 of which are X-offset and can see a mirror.
43
+ DEFAULT_TRANSFORM = ((0, 1, 2), (-1, 1, 1))
44
+
45
+
46
+ def pack_of(path, assets_root):
47
+ """The top-level folder under Assets/ a model belongs to."""
48
+ rel = os.path.relpath(path, assets_root)
49
+ return rel.split(os.sep)[0]
50
+
51
+
52
+ def _ground_truth(ue, assets_root):
53
+ """Every (fbx path -> (mesh fileID, Unity m_Size, m_Center)) a mesh+BoxCollider pair gives us."""
54
+ out = {}
55
+ for base, _dirs, files in os.walk(assets_root):
56
+ for f in files:
57
+ if not f.endswith('.prefab'):
58
+ continue
59
+ docs = ue.parse_file(os.path.join(base, f))
60
+ for _goid, (cls, body) in docs.items():
61
+ if cls != '1':
62
+ continue
63
+ mesh = box = None
64
+ for m in re.finditer(r'- component: \{fileID: (\d+)\}', body):
65
+ cd = docs.get(m.group(1))
66
+ if not cd:
67
+ continue
68
+ if cd[0] == '33':
69
+ mm = re.search(r'm_Mesh: \{fileID: (-?\d+), guid: ([0-9a-f]{32})', cd[1])
70
+ if mm:
71
+ mesh = (mm.group(2), mm.group(1))
72
+ elif cd[0] == '65':
73
+ box = cd[1]
74
+ if mesh and box:
75
+ mp = ue.guid2path.get(mesh[0]) if isinstance(mesh, tuple) else ue.guid2path.get(mesh)
76
+ fid = mesh[1] if isinstance(mesh, tuple) else None
77
+ if mp and mp.lower().endswith('.fbx') and mp not in out:
78
+ out[mp] = (fid,
79
+ ue.vec3(box, 'm_Size', (0, 0, 0)),
80
+ ue.vec3(box, 'm_Center', (0, 0, 0)))
81
+ return out
82
+
83
+
84
+ def build_axis_map(ue, assets_root, verbose=False):
85
+ """{pack name: (permutation, signs)}, derived from Unity's own collider boxes.
86
+
87
+ Both halves matter. The permutation says which raw axis becomes which Unity axis; the signs say
88
+ which of them are flipped. Matching `m_Size` alone fixes only the first and silently leaves every
89
+ mesh mirrored.
90
+ """
91
+ import itertools
92
+ votes = {}
93
+ counts = {}
94
+ for mp, (fid, size, center) in _ground_truth(ue, assets_root).items():
95
+ guid = next((g for g, p in ue.guid2path.items() if p == mp), None)
96
+ bounds = ue.mesh_bounds_raw(guid, ue.guid2path, fid) if guid else None
97
+ if not bounds:
98
+ continue
99
+ mn, mx = bounds
100
+ if max(mx[i] - mn[i] for i in range(3)) < 1e-6:
101
+ continue
102
+ lo = [center[i] - size[i] / 2 for i in range(3)]
103
+ hi = [center[i] + size[i] / 2 for i in range(3)]
104
+ pack = pack_of(mp, assets_root)
105
+ counts[pack] = counts.get(pack, 0) + 1
106
+ bucket = votes.setdefault(pack, {})
107
+ for name, perm in PERMUTATIONS.items():
108
+ for signs in ((1, 1, 1),): # see DEFAULT_TRANSFORM: signed fits measured worse
109
+ ok = True
110
+ for i in range(3):
111
+ a = signs[i] * mn[perm[i]]
112
+ b = signs[i] * mx[perm[i]]
113
+ if abs(min(a, b) - lo[i]) > 0.15 or abs(max(a, b) - hi[i]) > 0.15:
114
+ ok = False
115
+ break
116
+ if ok:
117
+ key = (perm, signs)
118
+ bucket[key] = bucket.get(key, 0) + 1
119
+
120
+ axis_map = {}
121
+ for pack, bucket in votes.items():
122
+ if not bucket:
123
+ continue
124
+ best, n = max(bucket.items(), key=lambda kv: kv[1])
125
+ axis_map[pack] = best
126
+ if verbose:
127
+ print(f' axis: {pack:24s} -> perm {best[0]} signs {best[1]} '
128
+ f'({n} of {counts.get(pack, 0)} models)')
129
+ return axis_map
130
+
131
+
132
+ def _override():
133
+ """`axisOverride` in unity-import.json (or UNITY_AXIS_OVERRIDE): "Pack=swapYZ:-1,1,1;Sci-Fi Base=identity" — for experiments."""
134
+ from import_config import CFG as _CFG
135
+ raw = _CFG['axisOverride']
136
+ out = {}
137
+ for item in raw.split(';'):
138
+ if '=' not in item:
139
+ continue
140
+ pack, spec = item.split('=', 1)
141
+ name, _, signs = spec.partition(':')
142
+ perm = PERMUTATIONS.get(name.strip())
143
+ if not perm:
144
+ continue
145
+ sg = tuple(int(v) for v in signs.split(',')) if signs else (1, 1, 1)
146
+ out[pack.strip()] = (perm, sg)
147
+ return out
148
+
149
+
150
+ # No pins. What this file used to guess per pack is stated outright in the FBX itself
151
+ # (see fbx_bounds.apply_geometric); permutation_for returns identity.
152
+ PINNED = {}
153
+
154
+
155
+ def permutation_for(path, assets_root, axis_map):
156
+ """Always identity now.
157
+
158
+ The per-pack permutation this file derived was a stand-in for something the FBX states
159
+ outright: the Model node's GeometricRotation/GeometricTranslation, which Unity bakes into the
160
+ mesh and which `fbx_bounds.read_fbx` now applies to the raw vertices. With that in place, no
161
+ axis guessing is needed for any pack — a 3ds Max kit and a Maya kit both came out right from
162
+ the same code path. Kept so callers keep working; the
163
+ env override remains for experiments.
164
+ """
165
+ pack = pack_of(path, assets_root)
166
+ over = _override()
167
+ if pack in over:
168
+ return over[pack]
169
+ return DEFAULT_TRANSFORM
170
+
171
+
172
+ def apply_permutation(vec, transform):
173
+ perm, signs = transform
174
+ return [signs[0] * vec[perm[0]], signs[1] * vec[perm[1]], signs[2] * vec[perm[2]]]
@@ -0,0 +1,202 @@
1
+ import struct, zlib, sys, os, json
2
+
3
+ # A MeshCollider collides with triangles, not with its bounding box. Reading the vertices lets the
4
+ # caller ask whether a mesh actually reaches the gameplay plane, instead of assuming its box does.
5
+ KEEP_VERTICES = False
6
+ MAX_VERTICES = 4000
7
+ UNITY_NEGATE_X = True
8
+
9
+
10
+ def _rx(a):
11
+ import math
12
+ c, s_ = math.cos(a), math.sin(a); return ((1, 0, 0), (0, c, -s_), (0, s_, c))
13
+ def _ry(a):
14
+ import math
15
+ c, s_ = math.cos(a), math.sin(a); return ((c, 0, s_), (0, 1, 0), (-s_, 0, c))
16
+ def _rz(a):
17
+ import math
18
+ c, s_ = math.cos(a), math.sin(a); return ((c, -s_, 0), (s_, c, 0), (0, 0, 1))
19
+ def _mm(a, b):
20
+ return tuple(tuple(sum(a[i][k]*b[k][j] for k in range(3)) for j in range(3)) for i in range(3))
21
+ def _mv(m, v):
22
+ return tuple(sum(m[i][k]*v[k] for k in range(3)) for i in range(3))
23
+
24
+
25
+ def apply_geometric(v, g):
26
+ """Apply a Model node's geometric transform to one raw vertex, the way Unity bakes it.
27
+
28
+ FBX geometric rotation is an XYZ Euler (rotate about X, then Y, then Z, i.e. Rz*Ry*Rx), in
29
+ degrees; scaling is applied first and translation last. Verified against Unity's own
30
+ BoxCollider for Wall_7.FBX: raw vertices land a full unit away with the axes cycled, this
31
+ lands on the collider box to the millimetre.
32
+ """
33
+ import math
34
+ sc = g.get('GeometricScaling', (1.0, 1.0, 1.0))
35
+ rot = g.get('GeometricRotation', (0.0, 0.0, 0.0))
36
+ tr = g.get('GeometricTranslation', (0.0, 0.0, 0.0))
37
+ x, y, z = v[0]*sc[0], v[1]*sc[1], v[2]*sc[2]
38
+ if any(abs(r) > 1e-9 for r in rot):
39
+ R = _mm(_rz(math.radians(rot[2])), _mm(_ry(math.radians(rot[1])), _rx(math.radians(rot[0]))))
40
+ x, y, z = _mv(R, (x, y, z))
41
+ x, y, z = x + tr[0], y + tr[1], z + tr[2]
42
+ # Unity converts the right-handed FBX into its left-handed space by negating X — on every
43
+ # import, geometric transform or not. A bounding box cannot see a mirror, so this was invisible
44
+ # to every collider-box check; a pair of quarter-round end caps could see it, and
45
+ # curved the wrong way until it was applied.
46
+ return (-x, y, z) if UNITY_NEGATE_X else (x, y, z)
47
+
48
+
49
+ def read_fbx(path, keep_vertices=False):
50
+ global KEEP_VERTICES
51
+ KEEP_VERTICES = keep_vertices
52
+ data = open(path,'rb').read()
53
+ if not data.startswith(b'Kaydara FBX Binary'):
54
+ return None
55
+ version = struct.unpack_from('<I', data, 23)[0]
56
+ off = 27
57
+ wide = version >= 7500
58
+ meshes = []
59
+ _pending_verts = [] # list of (name, [ (minx,miny,minz),(maxx,maxy,maxz) ])
60
+ # FBX geometric transforms live on the Model node and apply to that node's mesh vertices
61
+ # ONLY. Unity bakes them into the mesh data; reading the raw vertex array skips them and
62
+ # leaves every 3ds Max export cycled by two right angles and displaced.
63
+ geo_list = []
64
+ _geo_cur = {}
65
+
66
+ def read_record(off, path_names):
67
+ if wide:
68
+ end, nprops, plen = struct.unpack_from('<QQQ', data, off); off += 24
69
+ else:
70
+ end, nprops, plen = struct.unpack_from('<III', data, off); off += 12
71
+ nlen = data[off]; off += 1
72
+ name = data[off:off+nlen].decode('utf-8','replace'); off += nlen
73
+ if end == 0:
74
+ return 0, None
75
+ props = []
76
+ pend = off + plen
77
+ for _ in range(nprops):
78
+ t = chr(data[off]); off += 1
79
+ if t in 'SR':
80
+ L = struct.unpack_from('<I', data, off)[0]; off += 4
81
+ props.append(data[off:off+L].decode('utf-8', 'replace') if t == 'S' else b''); off += L
82
+ continue
83
+ if t == 'Y': props.append(struct.unpack_from('<h', data, off)[0]); off += 2
84
+ elif t == 'C': props.append(data[off] != 0); off += 1
85
+ elif t == 'I': props.append(struct.unpack_from('<i', data, off)[0]); off += 4
86
+ elif t == 'F': props.append(struct.unpack_from('<f', data, off)[0]); off += 4
87
+ elif t == 'D': props.append(struct.unpack_from('<d', data, off)[0]); off += 8
88
+ elif t == 'L': props.append(struct.unpack_from('<q', data, off)[0]); off += 8
89
+ elif t in 'fdlib':
90
+ alen, enc, clen = struct.unpack_from('<III', data, off); off += 12
91
+ raw = data[off:off+clen]; off += clen
92
+ if enc == 1:
93
+ raw = zlib.decompress(raw)
94
+ fmt = {'f':'f','d':'d','l':'q','i':'i','b':'b'}[t]
95
+ if t in 'fd' or t in 'li':
96
+ props.append(('array', fmt, alen, raw))
97
+ else:
98
+ props.append(('array', fmt, alen, raw))
99
+ elif t in 'SR':
100
+ slen = struct.unpack_from('<I', data, off)[0]; off += 4
101
+ props.append(data[off:off+slen]); off += slen
102
+ else:
103
+ off = pend
104
+ break
105
+ off = pend
106
+ if name == 'Model':
107
+ if _geo_cur:
108
+ geo_list.append(dict(_geo_cur))
109
+ _geo_cur.clear()
110
+ _geo_cur['_model'] = True
111
+ if name == 'P' and props and _geo_cur.get('_model') and isinstance(props[0], str) \
112
+ and props[0] in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'):
113
+ vals = [v for v in props[4:] if isinstance(v, (int, float))]
114
+ if len(vals) == 3:
115
+ _geo_cur[props[0]] = tuple(float(v) for v in vals)
116
+ if name == 'Vertices' and props and isinstance(props[0], tuple):
117
+ _, fmt, alen, raw = props[0]
118
+ vals = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
119
+ mn = [1e30]*3; mx = [-1e30]*3
120
+ for i in range(0, len(vals)-2, 3):
121
+ for k in range(3):
122
+ v = vals[i+k]
123
+ if v < mn[k]: mn[k] = v
124
+ if v > mx[k]: mx[k] = v
125
+ _pending_verts.append(len(meshes))
126
+ verts = None
127
+ if KEEP_VERTICES:
128
+ # Kept whole: PolygonVertexIndex refers to these by position, so subsampling
129
+ # would silently scramble the faces.
130
+ verts = [(vals[i], vals[i + 1], vals[i + 2])
131
+ for i in range(0, len(vals) - 2, 3)]
132
+ meshes.append((path_names[-1] if path_names else '', mn, mx, verts, None, None))
133
+ if name == 'PolygonVertexIndex' and KEEP_VERTICES and props and isinstance(props[0], tuple):
134
+ _, fmt, alen, raw = props[0]
135
+ idx = struct.unpack('<%d%s' % (alen, fmt), raw[:alen*struct.calcsize(fmt)])
136
+ if _pending_verts:
137
+ at = _pending_verts[-1]
138
+ if at < len(meshes):
139
+ e = meshes[at]
140
+ meshes[at] = (e[0], e[1], e[2], e[3], idx, e[5])
141
+ # nested
142
+ nullsz = 25 if wide else 13
143
+ while off < end - nullsz:
144
+ off, _ = read_record(off, path_names + [name])
145
+ if off < end:
146
+ off = end
147
+ return off, name
148
+
149
+ unit_scale = None
150
+ nullsz = 25 if wide else 13
151
+ while off < len(data) - nullsz:
152
+ noff, nm = read_record(off, [])
153
+ if noff == 0 or noff <= off: break
154
+ off = noff
155
+ idx = data.find(b'UnitScaleFactor')
156
+ if idx > 0:
157
+ seg = data[idx:idx+120]
158
+ j = seg.find(b'\x04', 15) # 'D' type marker is 0x44; properties are S,S,S,S,D
159
+ k = seg.find(b'D', 15)
160
+ if k > 0 and k + 9 <= len(seg):
161
+ try:
162
+ unit_scale = struct.unpack_from('<d', seg, k+1)[0]
163
+ except Exception:
164
+ unit_scale = None
165
+ if _geo_cur:
166
+ geo_list.append(dict(_geo_cur))
167
+ geos = [g for g in geo_list if any(k in g for k in ('GeometricRotation', 'GeometricTranslation', 'GeometricScaling'))]
168
+ # Only Model records that are meshes carry geometry; pair in order when the counts line up,
169
+ # and otherwise only when there is exactly one of each.
170
+ paired = []
171
+ for i, e in enumerate(meshes):
172
+ g = None
173
+ if len(geos) == len(meshes):
174
+ g = geos[i]
175
+ elif len(geos) == 1 and len(meshes) == 1:
176
+ g = geos[0]
177
+ paired.append((e[0], e[1], e[2], e[3], e[4], g))
178
+ meshes = paired
179
+ # Bounds are reported AFTER the geometric transform, so every consumer sees Unity's mesh.
180
+ fixed = []
181
+ for (nm, mn, mx, verts, idx, g) in meshes:
182
+ if not g:
183
+ g = {} # still run apply_geometric: the X negation applies to every file
184
+ if True:
185
+ corners = [(x, y, z) for x in (mn[0], mx[0]) for y in (mn[1], mx[1]) for z in (mn[2], mx[2])]
186
+ tc = [apply_geometric(c, g) for c in corners]
187
+ mn = [min(c[k] for c in tc) for k in range(3)]
188
+ mx = [max(c[k] for c in tc) for k in range(3)]
189
+ if verts:
190
+ verts = [apply_geometric(v, g) for v in verts]
191
+ fixed.append((nm, mn, mx, verts, idx, g))
192
+ return fixed, unit_scale
193
+
194
+ if __name__ == '__main__':
195
+ for p in sys.argv[1:]:
196
+ r = read_fbx(p)
197
+ ms, us = (r if r else (None, None))
198
+ if not ms:
199
+ print(os.path.basename(p), 'FAILED'); continue
200
+ mn = [min(m[1][k] for m in ms) for k in range(3)]
201
+ mx = [max(m[2][k] for m in ms) for k in range(3)]
202
+ print(os.path.basename(p), 'meshes=%d' % len(ms), 'unit=', us, 'min=', [round(v,3) for v in mn], 'max=', [round(v,3) for v in mx])
@@ -0,0 +1,86 @@
1
+ """Per-project settings for the Unity level import, read from `tools/unity-import.json`.
2
+
3
+ Every value that belongs to a particular game — where its Unity project lives, which scene is the
4
+ level, which MonoBehaviour scripts mark spawns and bases, where the play plane sits — comes from
5
+ this one file, so the scripts themselves never need editing to run on another game. Copy
6
+ `unity-import.example.json` to `unity-import.json` and fill it in; the keys and their defaults are
7
+ listed in DEFAULTS. `UNITY_IMPORT_CONFIG` names a different file; `UNITY_ROOT`, `UNITY_PLANE_Z` and
8
+ `UNITY_AXIS_OVERRIDE` override single values from the environment for experiments.
9
+ """
10
+ import json
11
+ import os
12
+
13
+ _HERE = os.path.dirname(os.path.abspath(__file__))
14
+
15
+ DEFAULTS = {
16
+ # The Unity tree that holds Assets/ and ProjectSettings/ for the level (a repo can hold more
17
+ # than one; pick the one the creator's editor opens).
18
+ 'unityRoot': '',
19
+ # Scene path relative to unityRoot, and the level name the game data is written under.
20
+ 'scene': 'Assets/Scenes/Main.unity',
21
+ 'levelName': 'Main',
22
+ # Gameplay plane for a 2D-on-a-plane game: Unity z of the plane and the half-thickness a
23
+ # collider must cross to count as a wall (the ship's radius is a good value).
24
+ 'planeZ': -1.0,
25
+ 'planeHalf': 0.8,
26
+ # Instances farther than this from the origin are stray copies parked outside the level.
27
+ 'arenaLimit': 80.0,
28
+ # Output GLB (from convert_assets) and the asset name it is uploaded under.
29
+ 'glb': 'build/glb/level.glb',
30
+ 'assetName': 'level-arena',
31
+ # Where write_arena_data emits the game's level data.
32
+ 'outputTs': 'src/work/ArenaData.ts',
33
+ # Prefabs to convert when convert_assets is not run in scene mode.
34
+ 'prefabs': [],
35
+ # Prefab tags whose colliders are never walls (decoration that would seal a room).
36
+ 'badTags': [],
37
+ # MonoBehaviour scripts (file names) and prefab files that mark spawns, bases and pickups.
38
+ 'markerScripts': ['Spawner.cs', 'SpawnPoint.cs', 'PowerupSpawnPoint.cs'],
39
+ 'markerPrefabs': ['Base.prefab', 'Spawner.prefab', 'SpawnPoint.prefab', 'PowerupSpawnPoint.prefab'],
40
+ # Render-mesh name families that must be solid although they carry no 2D collider
41
+ # (mesh_walls slices them at the play plane).
42
+ 'wallFamilies': [],
43
+ # Per-pack axis override for experiments: "Pack=cycYZX:-1,1,1;Other=identity".
44
+ 'axisOverride': '',
45
+ }
46
+
47
+
48
+ def _load():
49
+ path = os.environ.get('UNITY_IMPORT_CONFIG') or os.path.join(_HERE, 'unity-import.json')
50
+ cfg = dict(DEFAULTS)
51
+ if os.path.exists(path):
52
+ with open(path, encoding='utf-8') as fh:
53
+ data = json.load(fh)
54
+ unknown = sorted(set(data) - set(DEFAULTS) - {'_comment'})
55
+ if unknown:
56
+ raise SystemExit(f'{path}: unknown keys {unknown}; known keys are {sorted(DEFAULTS)}')
57
+ cfg.update({k: v for k, v in data.items() if k != '_comment'})
58
+ if os.environ.get('UNITY_ROOT'):
59
+ cfg['unityRoot'] = os.environ['UNITY_ROOT']
60
+ if os.environ.get('UNITY_PLANE_Z'):
61
+ cfg['planeZ'] = float(os.environ['UNITY_PLANE_Z'])
62
+ if os.environ.get('UNITY_AXIS_OVERRIDE'):
63
+ cfg['axisOverride'] = os.environ['UNITY_AXIS_OVERRIDE']
64
+ cfg['unityRoot'] = os.path.expanduser(cfg['unityRoot'])
65
+ cfg['_path'] = path
66
+ return cfg
67
+
68
+
69
+ CFG = _load()
70
+
71
+
72
+ def unity_root():
73
+ root = CFG['unityRoot']
74
+ if not root or not os.path.isdir(os.path.join(root, 'Assets')):
75
+ raise SystemExit(f"unityRoot {root!r} has no Assets/ directory; set it in {CFG['_path']} "
76
+ f"(or UNITY_ROOT) to the Unity tree that holds the level")
77
+ return root
78
+
79
+
80
+ def scene_path():
81
+ return os.path.join(unity_root(), CFG['scene'])
82
+
83
+
84
+ def project_root():
85
+ """The game project (the directory that holds tools/)."""
86
+ return os.path.dirname(_HERE)
@@ -0,0 +1,23 @@
1
+ {
2
+ "_comment": "Declarative scene edits applied identically by unity_colliders2d.py and unity_renderers.py. EMPTY on purpose: every earlier entry turned out to be a walker bug (scene overrides that deactivate whole prefab instances or disable components were being ignored); the walkers honour those now. Keep for genuine gameplay edits only. move_objects: the four corner tower groups slide 3 units toward the centre on both axes so they overlap the tunnel runs (the scene leaves a 1-2 unit seam at the play plane).",
3
+ "add_instances": [],
4
+ "remove_instances": [],
5
+ "move_objects": [
6
+ {
7
+ "name": "CornerTowerTopLeft",
8
+ "toward_center": 3.0
9
+ },
10
+ {
11
+ "name": "CornerTowerTopRight",
12
+ "toward_center": 3.0
13
+ },
14
+ {
15
+ "name": "CornerTowerBottomLeft",
16
+ "toward_center": 3.0
17
+ },
18
+ {
19
+ "name": "CornerTowerBottomRight",
20
+ "toward_center": 3.0
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,78 @@
1
+ """Slice render meshes at the play plane into 2D wall rectangles.
2
+
3
+ Some level geometry has no 2D collider in Unity yet must be solid for gameplay here: the
4
+ perimeter wall ring of the origin game was plain scene decoration, and a
5
+ Rigidbody2D ship flies straight through it. This runs inside Blender: it loads the built arena
6
+ GLB, samples every triangle of the named families that crosses the slab around the play plane,
7
+ rasterises the hits into a 0.5-unit occupancy grid and merges runs into axis-aligned rectangles.
8
+
9
+ blender -b --python tools/mesh_walls.py -- build/glb/room-for-9.glb tools/render_walls.json
10
+
11
+ Coordinates: the GLB is (ux, uy, -uz); Blender imports glTF Y-up as Z-up, so Blender x = Unity x,
12
+ Blender z = Unity y (the map's vertical axis), Blender y = Unity z (depth, negative toward the
13
+ camera). The play plane is `planeZ` in unity-import.json.
14
+ """
15
+ import sys, os, json
16
+ import bpy, mathutils
17
+
18
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
+ from import_config import CFG as _CFG # noqa: E402
20
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
21
+ GLB = argv[0] if argv else _CFG['glb']
22
+ OUT = argv[1] if len(argv) > 1 else 'tools/render_walls.json'
23
+ FAMILIES = tuple(_CFG['wallFamilies'])
24
+ PLANE = float(_CFG['planeZ'])
25
+ HALF = 0.45 # slab half-depth: the tunnel floor sits at depth 0, keep it out
26
+ CELL = 0.5
27
+ LIMIT = 72.0
28
+
29
+ bpy.ops.wm.read_factory_settings(use_empty=True)
30
+ bpy.ops.import_scene.gltf(filepath=GLB)
31
+ N = int(2 * LIMIT / CELL)
32
+ grid = [[False] * N for _ in range(N)]
33
+
34
+ def mark(x, y):
35
+ i = int((x + LIMIT) / CELL); j = int((y + LIMIT) / CELL)
36
+ if 0 <= i < N and 0 <= j < N: grid[j][i] = True
37
+
38
+ count = 0
39
+ for o in bpy.data.objects:
40
+ if o.type != 'MESH' or not any(f in o.name for f in FAMILIES): continue
41
+ count += 1
42
+ m = o.matrix_world; me = o.data
43
+ vs = [m @ v.co for v in me.vertices]
44
+ for poly in me.polygons:
45
+ pts = [vs[i] for i in poly.vertices]
46
+ ds = [p.y for p in pts] # Blender y = Unity depth
47
+ if max(ds) < PLANE - HALF or min(ds) > PLANE + HALF: continue
48
+ for k in range(1, len(pts) - 1): # fan-triangulate
49
+ a, b, c = pts[0], pts[k], pts[k + 1]
50
+ steps = 14
51
+ for ui in range(steps + 1):
52
+ for vi in range(steps + 1 - ui):
53
+ u, v = ui / steps, vi / steps
54
+ q = a + (b - a) * u + (c - a) * v
55
+ if PLANE - HALF <= q.y <= PLANE + HALF: mark(q.x, q.z)
56
+
57
+ # close one-cell sampling holes (dilate then erode), then greedy maximal rectangles
58
+ def closed(g):
59
+ d = [[any(g[jj][ii] for jj in range(max(0, j - 1), min(N, j + 2)) for ii in range(max(0, i - 1), min(N, i + 2))) for i in range(N)] for j in range(N)]
60
+ return [[all(d[jj][ii] for jj in range(max(0, j - 1), min(N, j + 2)) for ii in range(max(0, i - 1), min(N, i + 2))) or g[j][i] for i in range(N)] for j in range(N)]
61
+ grid = closed(grid)
62
+ rects = []
63
+ free = [row[:] for row in grid]
64
+ for j in range(N):
65
+ for i in range(N):
66
+ if not free[j][i]: continue
67
+ i1 = i
68
+ while i1 < N and free[j][i1]: i1 += 1
69
+ j1 = j + 1
70
+ while j1 < N and all(free[j1][ii] for ii in range(i, i1)): j1 += 1
71
+ for jj in range(j, j1):
72
+ for ii in range(i, i1): free[jj][ii] = False
73
+ rects.append([i, j, i1, j1])
74
+ walls = [dict(x0=round(r[0] * CELL - LIMIT, 2), y0=round(r[1] * CELL - LIMIT, 2),
75
+ x1=round(r[2] * CELL - LIMIT, 2), y1=round(r[3] * CELL - LIMIT, 2), name='+'.join(FAMILIES) or 'sliced', tag='Sliced')
76
+ for r in rects]
77
+ json.dump(walls, open(OUT, 'w'), indent=1)
78
+ print(f"MESH_WALLS meshes {count} cells {sum(map(sum, grid))} rects {len(walls)} -> {OUT}")
@@ -0,0 +1,33 @@
1
+ """Orthographic top-down render of the built arena GLB, for eyeballing the level layout.
2
+
3
+ blender -b --python tools/render_topdown.py -- build/glb/room-for-9.glb .bitmagic/topdown.png
4
+
5
+ Camera looks along Unity -z... in Blender terms: the GLB imports with Blender y = Unity depth
6
+ (negative toward the camera), Blender z = Unity y (map vertical). So the camera sits at large
7
+ negative Blender y looking toward +y, with Blender z as image up and Blender x as image right.
8
+ """
9
+ import sys, os, bpy, math, mathutils
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+ from import_config import CFG as _CFG # noqa: E402
12
+ argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else []
13
+ GLB = argv[0] if argv else _CFG['glb']
14
+ OUT = argv[1] if len(argv) > 1 else '.bitmagic/topdown.png'
15
+ bpy.ops.wm.read_factory_settings(use_empty=True)
16
+ bpy.ops.import_scene.gltf(filepath=GLB)
17
+ sc = bpy.context.scene
18
+ cam_data = bpy.data.cameras.new('top'); cam_data.type = 'ORTHO'; cam_data.ortho_scale = 150
19
+ cam = bpy.data.objects.new('top', cam_data); sc.collection.objects.link(cam)
20
+ cam.location = (0, -200, 0)
21
+ cam.rotation_euler = (math.radians(90), 0, 0) # look along +y, z up
22
+ sc.camera = cam
23
+ sun = bpy.data.lights.new('sun', 'SUN'); sun.energy = 4.0
24
+ so = bpy.data.objects.new('sun', sun); sc.collection.objects.link(so)
25
+ so.rotation_euler = (math.radians(60), math.radians(20), 0)
26
+ sc.render.engine = 'BLENDER_EEVEE_NEXT' if hasattr(bpy.types, 'SceneEEVEE') and 'BLENDER_EEVEE_NEXT' in [e.identifier for e in bpy.types.RenderSettings.bl_rna.properties['engine'].enum_items] else 'BLENDER_EEVEE'
27
+ sc.render.resolution_x = 1400; sc.render.resolution_y = 1400
28
+ sc.render.filepath = OUT
29
+ sc.world = bpy.data.worlds.new('w'); sc.world.use_nodes = True
30
+ sc.world.node_tree.nodes['Background'].inputs[0].default_value = (0.02, 0.02, 0.03, 1)
31
+ sc.world.node_tree.nodes['Background'].inputs[1].default_value = 1.0
32
+ bpy.ops.render.render(write_still=True)
33
+ print('TOPDOWN', OUT)
@@ -0,0 +1,27 @@
1
+ {
2
+ "_comment": "Copy to unity-import.json and fill in. Every key is documented in import_config.py; the values here are placeholders.",
3
+ "unityRoot": "~/path/to/UnityProject",
4
+ "scene": "Assets/Scenes/Main.unity",
5
+ "levelName": "Main",
6
+ "planeZ": -1.0,
7
+ "planeHalf": 0.8,
8
+ "arenaLimit": 80.0,
9
+ "glb": "build/glb/level.glb",
10
+ "assetName": "level-arena",
11
+ "outputTs": "src/work/ArenaData.ts",
12
+ "prefabs": [],
13
+ "badTags": [],
14
+ "markerScripts": [
15
+ "Spawner.cs",
16
+ "SpawnPoint.cs",
17
+ "PowerupSpawnPoint.cs"
18
+ ],
19
+ "markerPrefabs": [
20
+ "Base.prefab",
21
+ "Spawner.prefab",
22
+ "SpawnPoint.prefab",
23
+ "PowerupSpawnPoint.prefab"
24
+ ],
25
+ "wallFamilies": [],
26
+ "axisOverride": ""
27
+ }