@bitmagic/cli 0.1.64-dev.2 → 0.1.64-dev.3
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 +10 -0
- package/assets/blender-level/README.md +25 -0
- package/assets/blender-level/audit.py +232 -0
- package/assets/blender-level/bmlevel/__init__.py +294 -0
- package/assets/blender-level/bmlevel/core.py +438 -0
- package/assets/blender-level/bmlevel/scene_bpy.py +186 -0
- package/assets/blender-level/bmlevel/scene_null.py +44 -0
- package/assets/blender-level/example_level.py +80 -0
- package/assets/blender-level/find_blender.py +62 -0
- package/assets/blender-level/review.py +117 -0
- package/assets/blender-level/sync_world.py +85 -0
- package/assets/blender-level/tests/test_core.py +189 -0
- package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
- package/dist/scaffold/blender-level-skill-files.js +548 -0
- package/dist/scaffold/blender-level-skill-files.js.map +1 -0
- package/dist/scaffold/project-files.js +13 -6
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +6 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/skill-files.js +2 -1
- package/dist/scaffold/skill-files.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -1209,6 +1209,16 @@ booting one level's terrain under another's objects).
|
|
|
1209
1209
|
Runtime level switching is engine API — `engine.loadLevel(levelId)` from game code. These commands
|
|
1210
1210
|
manage the registry, not gameplay.
|
|
1211
1211
|
|
|
1212
|
+
## Building a level in Blender
|
|
1213
|
+
|
|
1214
|
+
A hand-designed low-poly world — an interior, a station, a stylised set — can be written as a short
|
|
1215
|
+
Python script against the `bmlevel` library that ships in this package (`assets/blender-level/`),
|
|
1216
|
+
built by Blender into one GLB plus the collider / door / light / landmark JSON, audited with ray
|
|
1217
|
+
casts and rendered for review, then registered with `bitmagic assets add … --keep-glb` and loaded
|
|
1218
|
+
by the engine's `MeshLevel`. The scaffolded `building-levels-in-blender` skill walks through it;
|
|
1219
|
+
its first section says when that path applies (a frontier model, and Blender installed) and sends
|
|
1220
|
+
everything else to `bitmagic forge`.
|
|
1221
|
+
|
|
1212
1222
|
## Publishing
|
|
1213
1223
|
|
|
1214
1224
|
`bitmagic publish` is two gated steps in one command:
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# blender-level
|
|
2
|
+
|
|
3
|
+
The Python and Blender scripts behind the `building-levels-in-blender` skill (see
|
|
4
|
+
`cli/src/scaffold/blender-level-skill-files.ts`). A level is a short Python script written against
|
|
5
|
+
`bmlevel` — boxes, prisms, walls, doors, stairs, signage, rooms, lights, landmarks, all in game
|
|
6
|
+
coordinates — that Blender runs to produce one GLB (joined per material), an editable `.blend`
|
|
7
|
+
(named parts), a `bitmagic-mesh-level` JSON the engine's `MeshLevel` loads, and a `doors.json` in
|
|
8
|
+
the shape of `worldProfileData.doors[]`. Shipped here (package.json `files`) rather than rendered
|
|
9
|
+
into every project: most games never build a level this way, and the scripts are large. The
|
|
10
|
+
skill's step 1 tells an agent how to copy them into a project's `tools/blender-level/`.
|
|
11
|
+
|
|
12
|
+
| File | Runs under | Does |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `find_blender.py` | python3 | prints the Blender executable, or how to install one |
|
|
15
|
+
| `bmlevel/` | Blender, or python3 for a dry run | the authoring vocabulary (`__init__.py`), the bpy-free maths and JSON writers (`core.py`), the Blender backend (`scene_bpy.py`) and the recording backend (`scene_null.py`) |
|
|
16
|
+
| `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
|
|
17
|
+
| `audit.py` | Blender | ray-cast audit of the COLLIDERS: floor continuity, door and route clearance, sealed envelopes, z-fighting candidates |
|
|
18
|
+
| `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet |
|
|
19
|
+
| `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
|
|
20
|
+
| `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
|
|
21
|
+
|
|
22
|
+
The JSON contract is owned by the engine (`game/src/engine/meshlevel/MeshLevelSchema.ts`,
|
|
23
|
+
documented in `game/agent-docs/mesh-level.md`); `core.py` writes exactly that shape, and the
|
|
24
|
+
engine's validator is the arbiter when the two disagree. The intended end state is a
|
|
25
|
+
`bitmagic level build` command wrapping these once a second game has been through them.
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Ray-cast audit of a built level, run inside Blender against the COLLIDERS the engine will get
|
|
2
|
+
(not the art — decoration is exactly what must not be solid):
|
|
3
|
+
|
|
4
|
+
"$BLENDER" -b --python tools/blender-level/audit.py -- build/level/hut.mesh-level.json [--doors closed] [--json out.json]
|
|
5
|
+
|
|
6
|
+
Checks, all driven by the level JSON:
|
|
7
|
+
floor_continuity every volume: a ray down from 1 m above its floor must hit within 1.5 m on a 0.5 m grid
|
|
8
|
+
door_clearance every door (doors open): nothing solid across the opening at three heights
|
|
9
|
+
link_clearance every link: a player capsule (r 0.4, h 1.8) marched from A through the door to B never
|
|
10
|
+
hits a wall and always has a floor under it
|
|
11
|
+
sealed_envelope (--doors closed only) every roofed volume: rays up and outward from inside hit something —
|
|
12
|
+
no hole in a roof or wall the player could fall through
|
|
13
|
+
coplanar_overlap pairs of box colliders with a shared face plane and overlapping extents — z-fighting
|
|
14
|
+
candidates; a WARNING, not a failure
|
|
15
|
+
|
|
16
|
+
Exit 1 on any failure. `--json` also writes the full result for a script to read.
|
|
17
|
+
"""
|
|
18
|
+
import json
|
|
19
|
+
import math
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
import bmesh
|
|
24
|
+
from mathutils import Vector
|
|
25
|
+
from mathutils.bvhtree import BVHTree
|
|
26
|
+
|
|
27
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
28
|
+
from bmlevel.core import box_corners, flat_to_points, rotate_xz, to_blender # noqa: E402
|
|
29
|
+
|
|
30
|
+
argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
|
|
31
|
+
if not argv:
|
|
32
|
+
print(__doc__)
|
|
33
|
+
sys.exit(2)
|
|
34
|
+
LEVEL_PATH = argv[0]
|
|
35
|
+
DOORS_CLOSED = '--doors' in argv and argv[argv.index('--doors') + 1] == 'closed'
|
|
36
|
+
JSON_OUT = argv[argv.index('--json') + 1] if '--json' in argv else None
|
|
37
|
+
DOORS_PATH = LEVEL_PATH.replace('.mesh-level.json', '.doors.json')
|
|
38
|
+
|
|
39
|
+
level = json.load(open(LEVEL_PATH))
|
|
40
|
+
doors = json.load(open(DOORS_PATH)) if os.path.exists(DOORS_PATH) else []
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------- BVH from colliders
|
|
43
|
+
|
|
44
|
+
verts: list[Vector] = []
|
|
45
|
+
faces: list[tuple] = []
|
|
46
|
+
BOX_FACES = [(0, 1, 3, 2), (4, 6, 7, 5), (0, 4, 5, 1), (2, 3, 7, 6), (0, 2, 6, 4), (1, 5, 7, 3)]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def add_box(center, size, yaw) -> None:
|
|
50
|
+
base = len(verts)
|
|
51
|
+
verts.extend(Vector(to_blender(p)) for p in box_corners(center, size, yaw))
|
|
52
|
+
faces.extend(tuple(base + i for i in f) for f in BOX_FACES)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def add_hull(flat) -> None:
|
|
56
|
+
bm = bmesh.new()
|
|
57
|
+
for p in flat_to_points(flat):
|
|
58
|
+
bm.verts.new(to_blender(p))
|
|
59
|
+
bm.verts.ensure_lookup_table()
|
|
60
|
+
bmesh.ops.convex_hull(bm, input=list(bm.verts))
|
|
61
|
+
base = len(verts)
|
|
62
|
+
index = {v: i for i, v in enumerate(bm.verts)}
|
|
63
|
+
verts.extend(v.co.copy() for v in bm.verts)
|
|
64
|
+
faces.extend(tuple(base + index[v] for v in f.verts) for f in bm.faces)
|
|
65
|
+
bm.free()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
boxes = []
|
|
69
|
+
for c in level['colliders']:
|
|
70
|
+
if c['shape'] == 'box':
|
|
71
|
+
add_box(c['position'], c['size'], c.get('yaw', 0.0))
|
|
72
|
+
boxes.append(c)
|
|
73
|
+
else:
|
|
74
|
+
add_hull(c['vertices'])
|
|
75
|
+
if DOORS_CLOSED:
|
|
76
|
+
for d in doors:
|
|
77
|
+
p = d['position']
|
|
78
|
+
add_box((p['x'], p['y'], p['z']), (d['width'], d['height'], d['thickness']), d.get('rotationY', 0.0))
|
|
79
|
+
bvh = BVHTree.FromPolygons(verts, faces) if faces else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def cast(origin, direction, limit):
|
|
83
|
+
"""Ray in GAME coordinates; returns hit distance or None."""
|
|
84
|
+
if bvh is None:
|
|
85
|
+
return None
|
|
86
|
+
o = Vector(to_blender(origin))
|
|
87
|
+
d = Vector(to_blender(direction))
|
|
88
|
+
hit = bvh.ray_cast(o, d, limit)
|
|
89
|
+
return hit[3] if hit[0] is not None else None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------- checks
|
|
93
|
+
|
|
94
|
+
results: dict[str, dict] = {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def record(check: str, ok: bool, detail: dict) -> None:
|
|
98
|
+
r = results.setdefault(check, {'checks': 0, 'failures': []})
|
|
99
|
+
r['checks'] += 1
|
|
100
|
+
if not ok:
|
|
101
|
+
r['failures'].append(detail)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def volume_grid(v, step=0.5, inset=0.35):
|
|
105
|
+
px, py, pz = v['position']
|
|
106
|
+
sx, sy, sz = v['size']
|
|
107
|
+
yaw = v.get('yaw', 0.0)
|
|
108
|
+
xs = [x for x in frange(-sx / 2 + inset, sx / 2 - inset, step)]
|
|
109
|
+
zs = [z for z in frange(-sz / 2 + inset, sz / 2 - inset, step)]
|
|
110
|
+
for lx in xs:
|
|
111
|
+
for lz in zs:
|
|
112
|
+
wx, wz = rotate_xz(lx, lz, yaw)
|
|
113
|
+
yield (px + wx, py - sy / 2, pz + wz), (lx, lz)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def frange(a, b, step):
|
|
117
|
+
n = max(1, int(round((b - a) / step)))
|
|
118
|
+
return [a + (b - a) * i / n for i in range(n + 1)] if b > a else [(a + b) / 2]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
volumes = {v['name']: v for v in level.get('volumes', [])}
|
|
122
|
+
for v in level.get('volumes', []):
|
|
123
|
+
for (x, floor_y, z), local in volume_grid(v):
|
|
124
|
+
hit = cast((x, floor_y + 1.0, z), (0, -1, 0), 2.5)
|
|
125
|
+
record('floor_continuity', hit is not None and hit <= 1.5, {'volume': v['name'], 'at': [round(x, 2), round(z, 2)], 'local': [round(local[0], 2), round(local[1], 2)]})
|
|
126
|
+
|
|
127
|
+
if DOORS_CLOSED:
|
|
128
|
+
for v in level.get('volumes', []):
|
|
129
|
+
if 'unroofed' in v.get('tags', []):
|
|
130
|
+
continue
|
|
131
|
+
sx, sy, sz = v['size']
|
|
132
|
+
yaw = v.get('yaw', 0.0)
|
|
133
|
+
for (x, floor_y, z), local in volume_grid(v, step=1.0):
|
|
134
|
+
up = cast((x, floor_y + 0.5, z), (0, 1, 0), sy + 1.0)
|
|
135
|
+
record('sealed_envelope', up is not None, {'volume': v['name'], 'kind': 'roof', 'at': [round(x, 2), round(z, 2)]})
|
|
136
|
+
for dx, dz, limit in ((1, 0, sx / 2 - local[0] + 1.0), (-1, 0, sx / 2 + local[0] + 1.0), (0, 1, sz / 2 - local[1] + 1.0), (0, -1, sz / 2 + local[1] + 1.0)):
|
|
137
|
+
wx, wz = rotate_xz(dx, dz, yaw)
|
|
138
|
+
side = cast((x, floor_y + sy / 2, z), (wx, 0, wz), limit)
|
|
139
|
+
record('sealed_envelope', side is not None, {'volume': v['name'], 'kind': 'wall', 'at': [round(x, 2), round(z, 2)], 'dir': [dx, dz]})
|
|
140
|
+
else:
|
|
141
|
+
for d in doors:
|
|
142
|
+
p = d['position']
|
|
143
|
+
yaw = d.get('rotationY', 0.0)
|
|
144
|
+
fx, fz = rotate_xz(0.0, 1.0, yaw)
|
|
145
|
+
rx, rz = rotate_xz(1.0, 0.0, yaw)
|
|
146
|
+
base_y = p['y'] - d['height'] / 2
|
|
147
|
+
span = d['thickness'] + 1.2
|
|
148
|
+
for lateral in frange(-d['width'] / 2 + 0.12, d['width'] / 2 - 0.12, 0.25):
|
|
149
|
+
for h in (0.3, 1.0, d['height'] - 0.25):
|
|
150
|
+
origin = (p['x'] + rx * lateral - fx * span / 2, base_y + h, p['z'] + rz * lateral - fz * span / 2)
|
|
151
|
+
hit = cast(origin, (fx, 0, fz), span)
|
|
152
|
+
record('door_clearance', hit is None, {'door': d['id'], 'lateral': round(lateral, 2), 'height': round(h, 2)})
|
|
153
|
+
|
|
154
|
+
door_by_id = {d['id']: d for d in doors}
|
|
155
|
+
for l in level.get('extras', {}).get('links', []):
|
|
156
|
+
a, b = volumes.get(l['from']), volumes.get(l['to'])
|
|
157
|
+
if not a or not b:
|
|
158
|
+
record('link_clearance', False, {'link': l, 'reason': 'unknown volume'})
|
|
159
|
+
continue
|
|
160
|
+
waypoints = [tuple(a['position'])]
|
|
161
|
+
if l.get('door'):
|
|
162
|
+
d = door_by_id.get(l['door'])
|
|
163
|
+
if not d:
|
|
164
|
+
record('link_clearance', False, {'link': l, 'reason': 'unknown door'})
|
|
165
|
+
continue
|
|
166
|
+
waypoints.append((d['position']['x'], d['position']['y'] - d['height'] / 2 + 0.9, d['position']['z']))
|
|
167
|
+
waypoints.append(tuple(b['position']))
|
|
168
|
+
for i in range(len(waypoints) - 1):
|
|
169
|
+
(x0, y0, z0), (x1, y1, z1) = waypoints[i], waypoints[i + 1]
|
|
170
|
+
n = max(1, int(math.hypot(x1 - x0, z1 - z0) / 0.5))
|
|
171
|
+
for k in range(n + 1):
|
|
172
|
+
t = k / n
|
|
173
|
+
x, z = x0 + (x1 - x0) * t, z0 + (z1 - z0) * t
|
|
174
|
+
# Probe for the floor from just above waist height: a door waypoint sits under its
|
|
175
|
+
# header, and a ray from higher up would start inside the lintel.
|
|
176
|
+
probe_y = y0 + (y1 - y0) * t + 0.5
|
|
177
|
+
floor = cast((x, probe_y, z), (0, -1, 0), 3.0)
|
|
178
|
+
record('link_clearance', floor is not None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'reason': 'no floor'})
|
|
179
|
+
if floor is None:
|
|
180
|
+
continue
|
|
181
|
+
fy = probe_y - floor
|
|
182
|
+
for h in (0.5, 1.5):
|
|
183
|
+
for dx, dz in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
|
184
|
+
# Rays run along the march direction's perpendiculars and along it; a wall
|
|
185
|
+
# within capsule radius means the route squeezes through something.
|
|
186
|
+
blocked = cast((x, fy + h, z), (dx, 0, dz), 0.4)
|
|
187
|
+
record('link_clearance', blocked is None, {'link': [l['from'], l['to']], 'at': [round(x, 2), round(z, 2)], 'height': h, 'dir': [dx, dz], 'reason': 'wall within 0.4 m'})
|
|
188
|
+
|
|
189
|
+
# Z-fighting candidates: two boxes sharing a face plane with overlapping extents (axis-aligned only).
|
|
190
|
+
warnings = []
|
|
191
|
+
def faces_of(b):
|
|
192
|
+
if abs(b.get('yaw', 0.0)) > 1e-6:
|
|
193
|
+
return []
|
|
194
|
+
px, py, pz = b['position']
|
|
195
|
+
sx, sy, sz = b['size']
|
|
196
|
+
out = []
|
|
197
|
+
rect = [(px - sx / 2, px + sx / 2), (py - sy / 2, py + sy / 2), (pz - sz / 2, pz + sz / 2)]
|
|
198
|
+
for axis in range(3):
|
|
199
|
+
for sign, plane in ((-1, rect[axis][0]), (1, rect[axis][1])):
|
|
200
|
+
out.append((axis, sign, plane, rect))
|
|
201
|
+
return out
|
|
202
|
+
for i in range(len(boxes)):
|
|
203
|
+
for j in range(i + 1, len(boxes)):
|
|
204
|
+
for axis, sign, plane, rect in faces_of(boxes[i]):
|
|
205
|
+
for axis2, sign2, plane2, rect2 in faces_of(boxes[j]):
|
|
206
|
+
# Same plane AND same facing: two visible faces fighting. A wall standing on a floor
|
|
207
|
+
# (floor top against wall bottom) is a contact, not a fight.
|
|
208
|
+
if axis != axis2 or sign != sign2 or abs(plane - plane2) > 0.001:
|
|
209
|
+
continue
|
|
210
|
+
overlap = all(min(rect[k][1], rect2[k][1]) - max(rect[k][0], rect2[k][0]) > 0.01 for k in range(3) if k != axis)
|
|
211
|
+
if overlap:
|
|
212
|
+
warnings.append({'a': boxes[i]['name'], 'b': boxes[j]['name'], 'axis': 'xyz'[axis], 'plane': round(plane, 3)})
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------- report
|
|
216
|
+
|
|
217
|
+
failed = 0
|
|
218
|
+
mode = 'doors closed' if DOORS_CLOSED else 'doors open'
|
|
219
|
+
print(f'AUDIT {os.path.basename(LEVEL_PATH)} ({mode}): {len(level["colliders"])} colliders, {len(doors)} doors')
|
|
220
|
+
for check, r in results.items():
|
|
221
|
+
n = len(r['failures'])
|
|
222
|
+
failed += n
|
|
223
|
+
print(f' {check:18s} {r["checks"]:6d} rays {n:4d} failed' + ('' if n == 0 else ' e.g. ' + json.dumps(r['failures'][0])))
|
|
224
|
+
if not results:
|
|
225
|
+
print(' (no volumes, doors or links to audit — declare volume()/link() in the level script)')
|
|
226
|
+
if warnings:
|
|
227
|
+
print(f' coplanar_overlap {len(warnings):4d} warning(s) — shared face planes, likely z-fighting: ' + ', '.join(f"{w['a']} / {w['b']}" for w in warnings[:6]))
|
|
228
|
+
if JSON_OUT:
|
|
229
|
+
json.dump({'mode': mode, 'results': results, 'coplanar_warnings': warnings}, open(JSON_OUT, 'w'), indent=1)
|
|
230
|
+
print(f' wrote {JSON_OUT}')
|
|
231
|
+
print('AUDIT_RESULT ' + ('PASS' if failed == 0 else f'FAIL {failed}'))
|
|
232
|
+
sys.exit(0 if failed == 0 else 1)
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""bmlevel — write a low-poly game level as a short Python script, build it in Blender, and get
|
|
2
|
+
one GLB plus the collider / door / light JSON the Bitmagic engine's `MeshLevel` loads.
|
|
3
|
+
|
|
4
|
+
from bmlevel import *
|
|
5
|
+
mat('stone', (0.5, 0.5, 0.48))
|
|
6
|
+
with context('hut', origin=(0, 0, 0)):
|
|
7
|
+
slab('floor', (0, 0), (8, 6), material='stone')
|
|
8
|
+
wall('north', (-4, -3), (4, -3), height=3, material='stone', opening={'at': 4, 'width': 1.4, 'height': 2.2})
|
|
9
|
+
door('front', (0, 0, -3), yaw=0)
|
|
10
|
+
spawn((0, 0.1, 0))
|
|
11
|
+
export('build/level', 'hut')
|
|
12
|
+
|
|
13
|
+
Every position is in GAME coordinates (metres, Y up, +Z forward, yaw about +Y) inside the current
|
|
14
|
+
`context()`; the Blender axis flip happens once, in the scene backend. Anything built with
|
|
15
|
+
`solid=True` (the default) also becomes a collider record; `decor()` is the same call for things
|
|
16
|
+
the player must never bump into. See core.py for the coordinate argument and the JSON shapes.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import contextlib
|
|
21
|
+
import math
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
from typing import Sequence
|
|
25
|
+
|
|
26
|
+
from bmlevel.core import (
|
|
27
|
+
BoxCollider, DoorRecord, Frame, HullCollider, Landmark, LevelData, Link, MaterialRecord,
|
|
28
|
+
PointLight, SpotLight, Stairs, Volume, box_corners, hex_color, prism_vertices, ramp_vertices,
|
|
29
|
+
require_convex_outline, rotate_xz,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
try: # inside Blender
|
|
33
|
+
import bpy # noqa: F401
|
|
34
|
+
from bmlevel.scene_bpy import BpyScene as Scene
|
|
35
|
+
except ImportError: # plain python3: record only
|
|
36
|
+
from bmlevel.scene_null import NullScene as Scene
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
'mat', 'context', 'box', 'decor', 'prism', 'slab', 'wall', 'glazing', 'door', 'stairs', 'text',
|
|
40
|
+
'volume', 'link', 'lamp', 'spot', 'landmark', 'spawn', 'export', 'level', 'scene', 'reset', 'DRY_RUN',
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
DRY_RUN = '--dry-run' in sys.argv or not Scene.available
|
|
44
|
+
|
|
45
|
+
level = LevelData()
|
|
46
|
+
scene = Scene()
|
|
47
|
+
_frames: list[Frame] = [Frame()]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reset() -> None:
|
|
51
|
+
"""Start over (a script that builds several levels calls this between them)."""
|
|
52
|
+
global level
|
|
53
|
+
level = LevelData()
|
|
54
|
+
scene.reset()
|
|
55
|
+
_frames[:] = [Frame()]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _frame() -> Frame:
|
|
59
|
+
return _frames[-1]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _vec3(p: Sequence[float], what: str) -> tuple[float, float, float]:
|
|
63
|
+
if len(p) != 3:
|
|
64
|
+
raise ValueError(f'{what} must be (x, y, z), got {p!r}')
|
|
65
|
+
return (float(p[0]), float(p[1]), float(p[2]))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------- materials
|
|
69
|
+
|
|
70
|
+
def mat(name: str, rgb: Sequence[float], metal: float = 0.0, roughness: float = 0.5, glow: float = 0.0, alpha: float = 1.0) -> None:
|
|
71
|
+
"""Declare a material. Name conventions the engine reads: `glass*` renders as a thin
|
|
72
|
+
transparent pane, `emissive*` is guaranteed to glow (glow > 0 also exports emission)."""
|
|
73
|
+
if len(rgb) != 3:
|
|
74
|
+
raise ValueError(f'mat({name!r}): rgb must be (r, g, b) in 0..1')
|
|
75
|
+
level.materials[name] = MaterialRecord(name, tuple(float(c) for c in rgb), metal, roughness, glow, alpha)
|
|
76
|
+
scene.material(name, rgb, metal, roughness, glow, alpha)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------- frames
|
|
80
|
+
|
|
81
|
+
@contextlib.contextmanager
|
|
82
|
+
def context(name: str, origin: Sequence[float] = (0, 0, 0), yaw: float = 0.0):
|
|
83
|
+
"""A named part of the level with its own local frame (a room, a module, a corridor). Objects
|
|
84
|
+
made inside land in a Blender collection of that name. Contexts nest."""
|
|
85
|
+
_frames.append(_frame().child(_vec3(origin, 'context origin'), yaw))
|
|
86
|
+
scene.begin_collection(name)
|
|
87
|
+
try:
|
|
88
|
+
yield
|
|
89
|
+
finally:
|
|
90
|
+
scene.end_collection()
|
|
91
|
+
_frames.pop()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------- solids
|
|
95
|
+
|
|
96
|
+
def box(name: str, pos: Sequence[float], size: Sequence[float], material: str, solid: bool = True, yaw: float = 0.0, bevel: float = 0.0, group: str | None = None) -> None:
|
|
97
|
+
"""An oriented box: `pos` is its centre, `size` its full (width, height, depth) in metres."""
|
|
98
|
+
p = _frame().world(_vec3(pos, f'box {name!r} pos'))
|
|
99
|
+
s = _vec3(size, f'box {name!r} size')
|
|
100
|
+
if min(s) <= 0:
|
|
101
|
+
raise ValueError(f'box {name!r}: every size must be > 0')
|
|
102
|
+
w_yaw = _frame().yaw + yaw
|
|
103
|
+
scene.box(name, p, s, w_yaw, material, bevel)
|
|
104
|
+
if solid:
|
|
105
|
+
level.colliders.append(BoxCollider(name, p, s, w_yaw, group))
|
|
106
|
+
else:
|
|
107
|
+
level.visual_points.extend(box_corners(p, s, w_yaw))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def decor(name: str, pos: Sequence[float], size: Sequence[float], material: str, yaw: float = 0.0, bevel: float = 0.0) -> None:
|
|
111
|
+
"""A box the player never collides with: cladding, trim, lamps, fasteners, signage plates."""
|
|
112
|
+
box(name, pos, size, material, solid=False, yaw=yaw, bevel=bevel)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def prism(name: str, outline: Sequence[tuple[float, float]], bottom: float, top: float, material: str, solid: bool = True, group: str | None = None) -> None:
|
|
116
|
+
"""A polygon in local XZ extruded from `bottom` to `top`. Solid prisms must be convex (a
|
|
117
|
+
concave one raises — split it), because the collider is a convex hull."""
|
|
118
|
+
if top <= bottom:
|
|
119
|
+
raise ValueError(f'prism {name!r}: top must be above bottom')
|
|
120
|
+
if solid:
|
|
121
|
+
require_convex_outline(name, outline)
|
|
122
|
+
verts = prism_vertices(_frame(), outline, bottom, top)
|
|
123
|
+
scene.prism(name, verts, material)
|
|
124
|
+
if solid:
|
|
125
|
+
level.colliders.append(HullCollider(name, verts, group))
|
|
126
|
+
else:
|
|
127
|
+
level.visual_points.extend((verts[i], verts[i + 1], verts[i + 2]) for i in range(0, len(verts), 3))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def slab(name: str, pos_xz: Sequence[float], size_xz: Sequence[float], material: str, y: float = 0.0, thickness: float = 0.5, yaw: float = 0.0) -> None:
|
|
131
|
+
"""A floor or ceiling plate whose TOP surface is at `y`."""
|
|
132
|
+
box(name, (pos_xz[0], y - thickness / 2, pos_xz[1]), (size_xz[0], thickness, size_xz[1]), material, yaw=yaw)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def wall(name: str, start: Sequence[float], end: Sequence[float], height: float, material: str, thickness: float = 0.3, base: float = 0.0, opening: dict | None = None) -> None:
|
|
136
|
+
"""A wall from local (x, z) `start` to `end`. `opening={'at': m, 'width': w, 'height': h}` cuts
|
|
137
|
+
a doorway centred `at` metres from the start, leaving a lintel above it."""
|
|
138
|
+
(x0, z0), (x1, z1) = (float(start[0]), float(start[1])), (float(end[0]), float(end[1]))
|
|
139
|
+
length = math.hypot(x1 - x0, z1 - z0)
|
|
140
|
+
if length <= 0:
|
|
141
|
+
raise ValueError(f'wall {name!r}: start and end coincide')
|
|
142
|
+
yaw = math.atan2(x1 - x0, z1 - z0) # local +Z along the wall at yaw 0
|
|
143
|
+
|
|
144
|
+
def segment(seg_name: str, t0: float, t1: float, seg_base: float, seg_top: float) -> None:
|
|
145
|
+
if t1 - t0 <= 1e-6 or seg_top - seg_base <= 1e-6:
|
|
146
|
+
return
|
|
147
|
+
tm = (t0 + t1) / 2
|
|
148
|
+
cx, cz = x0 + (x1 - x0) * tm / length, z0 + (z1 - z0) * tm / length
|
|
149
|
+
box(seg_name, (cx, (seg_base + seg_top) / 2, cz), (thickness, seg_top - seg_base, t1 - t0), material, yaw=yaw)
|
|
150
|
+
|
|
151
|
+
if opening is None:
|
|
152
|
+
segment(name, 0.0, length, base, base + height)
|
|
153
|
+
return
|
|
154
|
+
unknown = set(opening) - {'at', 'width', 'height'}
|
|
155
|
+
if unknown:
|
|
156
|
+
raise ValueError(f'wall {name!r}: opening keys must be at/width/height, got {sorted(unknown)}')
|
|
157
|
+
at, w, h = float(opening['at']), float(opening['width']), float(opening['height'])
|
|
158
|
+
if not (0 < at - w / 2 and at + w / 2 < length):
|
|
159
|
+
raise ValueError(f'wall {name!r}: opening {at}±{w / 2} falls outside the wall length {length:.2f}')
|
|
160
|
+
if h >= height:
|
|
161
|
+
raise ValueError(f'wall {name!r}: opening height {h} must be below the wall height {height}')
|
|
162
|
+
segment(f'{name} left', 0.0, at - w / 2, base, base + height)
|
|
163
|
+
segment(f'{name} right', at + w / 2, length, base, base + height)
|
|
164
|
+
segment(f'{name} lintel', at - w / 2, at + w / 2, base + h, base + height)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def glazing(name: str, start: Sequence[float], end: Sequence[float], height: float, glass: str, frame: str, sill: float = 0.9, head: float = 0.4, base: float = 0.0, thickness: float = 0.3) -> None:
|
|
168
|
+
"""A window wall: solid sill and head bands in `frame`, a thin pane in `glass` between them.
|
|
169
|
+
The pane is not a collider — the sill and head are, which is what stops the player."""
|
|
170
|
+
wall(f'{name} sill', start, end, sill, frame, thickness, base)
|
|
171
|
+
wall(f'{name} head', start, end, head, frame, thickness, base + height - head)
|
|
172
|
+
(x0, z0), (x1, z1) = start, end
|
|
173
|
+
cx, cz = (x0 + x1) / 2, (z0 + z1) / 2
|
|
174
|
+
yaw = math.atan2(x1 - x0, z1 - z0)
|
|
175
|
+
pane_h = height - sill - head
|
|
176
|
+
box(f'{name} pane', (cx, base + sill + pane_h / 2, cz), (0.04, pane_h, math.hypot(x1 - x0, z1 - z0)), glass, solid=False, yaw=yaw)
|
|
177
|
+
# An invisible-to-decor but solid pane keeps the player from walking through the glass.
|
|
178
|
+
level.colliders.append(BoxCollider(f'{name} pane collider', _frame().world((cx, base + sill + pane_h / 2, cz)), (max(0.1, thickness / 2), pane_h, math.hypot(x1 - x0, z1 - z0)), _frame().yaw + yaw))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def door(id: str, pos: Sequence[float], yaw: float = 0.0, width: float = 1.4, height: float = 2.2, thickness: float = 0.1, kind: str = 'plain', animation: str = 'slide', jamb: str | None = None, jamb_depth: float = 0.3, auto_open_radius: float | None = None) -> None:
|
|
182
|
+
"""A doorway: `pos` is the floor point at its centre, the leaf spans `width` across local X at
|
|
183
|
+
`yaw`. Writes a `DoorDefinition` for world.json (the engine builds and animates the leaf) and,
|
|
184
|
+
when `jamb` names a material, solid jambs and a header around it."""
|
|
185
|
+
if kind not in ('plain', 'locked') or animation not in ('hinge', 'slide', 'dissolve'):
|
|
186
|
+
raise ValueError(f'door {id!r}: kind is plain|locked, animation is hinge|slide|dissolve')
|
|
187
|
+
p = _vec3(pos, f'door {id!r} pos')
|
|
188
|
+
w_yaw = _frame().yaw + yaw
|
|
189
|
+
centre = _frame().world((p[0], p[1] + height / 2, p[2]))
|
|
190
|
+
level.doors.append(DoorRecord(id, centre, w_yaw, width, height, thickness, kind, animation, auto_open_radius))
|
|
191
|
+
if jamb:
|
|
192
|
+
for side in (-1, 1):
|
|
193
|
+
ox, oz = rotate_xz(side * (width / 2 + 0.08), 0.0, yaw) # the jambs sit across the leaf, whatever the door's yaw
|
|
194
|
+
box(f'{id} jamb {"L" if side < 0 else "R"}', (p[0] + ox, p[1] + height / 2 + 0.1, p[2] + oz), (0.16, height + 0.2, jamb_depth), jamb, yaw=yaw)
|
|
195
|
+
# The header is a little deeper and taller than the jambs, so no two faces share a plane.
|
|
196
|
+
box(f'{id} header', (p[0], p[1] + height + 0.15, p[2]), (width + 0.36, 0.3, jamb_depth + 0.04), jamb, yaw=yaw)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def stairs(name: str, foot: Sequence[float], rise: float, run: float, material: str, width: float = 2.0, yaw: float = 0.0, steps: int | None = None, rail: str | None = None, treads_solid: bool = False) -> None:
|
|
200
|
+
"""A straight flight climbing `rise` over `run` along local +Z from `foot` (the bottom edge's
|
|
201
|
+
centre, at floor level). Visual treads, one smooth ramp collider (so walking up is not a
|
|
202
|
+
stutter), optional rails in `rail`. `treads_solid=True` makes every tread a collider too."""
|
|
203
|
+
f = _vec3(foot, f'stairs {name!r} foot')
|
|
204
|
+
n = steps or max(2, math.ceil(rise / 0.18))
|
|
205
|
+
tread, riser = run / n, rise / n
|
|
206
|
+
for i in range(n):
|
|
207
|
+
top = f[1] + (i + 1) * riser
|
|
208
|
+
box(f'{name} tread {i:02d}', (f[0], (f[1] + top) / 2, f[2] + (i + 0.5) * tread), (width, top - f[1], tread), material, solid=treads_solid, yaw=yaw)
|
|
209
|
+
frame = _frame().child((0, 0, 0), yaw) if yaw else _frame()
|
|
210
|
+
level.colliders.append(HullCollider(f'{name} ramp', ramp_vertices(frame, f, width, rise, run)))
|
|
211
|
+
if rail:
|
|
212
|
+
for side in (-1, 1):
|
|
213
|
+
x = f[0] + side * (width / 2 + 0.05)
|
|
214
|
+
pts = []
|
|
215
|
+
for z, y in ((f[2], f[1]), (f[2] + run, f[1] + rise)):
|
|
216
|
+
for dy in (0.85, 0.95):
|
|
217
|
+
for dx in (-0.03, 0.03):
|
|
218
|
+
pts.extend(frame.world((x + dx, y + dy, z)))
|
|
219
|
+
level.colliders.append(HullCollider(f'{name} rail {"L" if side < 0 else "R"}', pts))
|
|
220
|
+
scene.hull(f'{name} rail {"L" if side < 0 else "R"}', pts, rail)
|
|
221
|
+
for i in range(0, n, max(1, n // 3)):
|
|
222
|
+
box(f'{name} post {"L" if side < 0 else "R"} {i}', (x, f[1] + (i + 1) * riser + 0.45, f[2] + (i + 0.5) * tread), (0.06, 0.9, 0.06), rail, solid=False, yaw=yaw)
|
|
223
|
+
level.stairs.append(Stairs(name, _frame().world(f), frame.world((f[0], f[1] + rise, f[2] + run))))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def text(name: str, string: str, pos: Sequence[float], material: str, yaw: float = 0.0, size: float = 0.5, extrude: float = 0.01, both_faces: bool = False) -> None:
|
|
227
|
+
"""3D signage, readable from local -Z at yaw 0 (facing an approaching player). Blender's
|
|
228
|
+
bundled font, no font file. `both_faces` adds a mirrored copy for the other approach."""
|
|
229
|
+
p = _frame().world(_vec3(pos, f'text {name!r} pos'))
|
|
230
|
+
scene.text(name, string, p, _frame().yaw + yaw, size, material, extrude, both_faces)
|
|
231
|
+
level.visual_points.append(p)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------- data only
|
|
235
|
+
|
|
236
|
+
def volume(name: str, pos: Sequence[float], size: Sequence[float], yaw: float = 0.0, tags: Sequence[str] = (), roofed: bool = True) -> None:
|
|
237
|
+
"""A named region (a room, an airlock, a trigger area): `pos` is its centre. The audit checks
|
|
238
|
+
floor continuity inside it and, when `roofed`, a sealed envelope. Tags reach the game."""
|
|
239
|
+
p = _frame().world(_vec3(pos, f'volume {name!r} pos'))
|
|
240
|
+
t = [str(x) for x in tags]
|
|
241
|
+
if not roofed:
|
|
242
|
+
t.append('unroofed')
|
|
243
|
+
level.volumes.append(Volume(name, p, _vec3(size, f'volume {name!r} size'), _frame().yaw + yaw, t))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def link(a: str, b: str, door: str | None = None) -> None:
|
|
247
|
+
"""Declare that a player can walk from volume `a` to volume `b` (through `door`, if named).
|
|
248
|
+
The audit marches the route with a player-sized capsule."""
|
|
249
|
+
level.links.append(Link(a, b, door))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def lamp(name: str, pos: Sequence[float], color, intensity: float = 8.0, distance: float = 20.0, decay: float = 2.0) -> None:
|
|
253
|
+
"""A point light. Point lights share a small pool in the engine — place them where it matters."""
|
|
254
|
+
level.lights.append(PointLight(name, _frame().world(_vec3(pos, f'lamp {name!r} pos')), hex_color(color), intensity, distance, decay))
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def spot(name: str, pos: Sequence[float], target: Sequence[float], color, intensity: float = 20.0, angle: float = math.pi / 4, penumbra: float = 0.3, cast_shadow: bool = True) -> None:
|
|
258
|
+
"""A spot light; the first two with `cast_shadow` bake shadows from the static level."""
|
|
259
|
+
level.lights.append(SpotLight(name, _frame().world(_vec3(pos, f'spot {name!r} pos')), _frame().world(_vec3(target, f'spot {name!r} target')), hex_color(color), intensity, angle, penumbra, cast_shadow))
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def landmark(name: str, pos: Sequence[float], yaw: float = 0.0, tags: Sequence[str] = ()) -> None:
|
|
263
|
+
"""A named point the game asks for by name (`level.landmark('exit')`, `landmarksTagged('guard')`)."""
|
|
264
|
+
level.landmarks.append(Landmark(name, _frame().world(_vec3(pos, f'landmark {name!r} pos')), _frame().yaw + yaw, [str(t) for t in tags]))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def spawn(pos: Sequence[float], yaw: float = 0.0) -> None:
|
|
268
|
+
"""Where the player starts: the `spawn` landmark, which sync_world.py also writes into world.json."""
|
|
269
|
+
landmark('spawn', pos, yaw)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------- output
|
|
273
|
+
|
|
274
|
+
def export(out_dir: str, name: str) -> dict:
|
|
275
|
+
"""Write `<name>.mesh-level.json`, `<name>.doors.json`, `<name>.blend` (named parts, editable)
|
|
276
|
+
and `<name>.glb` (joined per material) into `out_dir`, and print the summary — including the
|
|
277
|
+
exact `bitmagic assets add` line, whose `--height` must be the level's native height."""
|
|
278
|
+
for l in level.links:
|
|
279
|
+
names = {v.name for v in level.volumes}
|
|
280
|
+
for end in (l.a, l.b):
|
|
281
|
+
if end not in names:
|
|
282
|
+
raise ValueError(f'link({l.a!r}, {l.b!r}): no volume named {end!r}')
|
|
283
|
+
if l.door and l.door not in {d.id for d in level.doors}:
|
|
284
|
+
raise ValueError(f'link({l.a!r}, {l.b!r}, door={l.door!r}): no door with that id')
|
|
285
|
+
level_path, doors_path = level.write(out_dir, name)
|
|
286
|
+
glb_path = os.path.join(out_dir, f'{name}.glb')
|
|
287
|
+
blend_path = os.path.join(out_dir, f'{name}.blend')
|
|
288
|
+
size = 0
|
|
289
|
+
if not DRY_RUN:
|
|
290
|
+
scene.save_blend(blend_path)
|
|
291
|
+
size = scene.export_glb(glb_path)
|
|
292
|
+
print(level.summary(name, glb_path), flush=True)
|
|
293
|
+
print(f'WROTE {level_path} {doors_path}' + ('' if DRY_RUN else f' {blend_path} {glb_path} ({size} bytes)'), flush=True)
|
|
294
|
+
return {'level': level_path, 'doors': doors_path, 'glb': glb_path, 'blend': blend_path, 'dry_run': DRY_RUN}
|