@bitmagic/cli 0.1.64-dev.2 → 0.1.64-dev.4
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 +37 -1
- 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/cli.d.ts +1 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/generate.d.ts +43 -1
- package/dist/commands/generate.js +210 -12
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/tools.d.ts +1 -0
- package/dist/commands/tools.js +73 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/generate/animation-from-video.d.ts +11 -3
- package/dist/generate/animation-from-video.js +12 -2
- package/dist/generate/animation-from-video.js.map +1 -1
- package/dist/generate/video-review.d.ts +28 -0
- package/dist/generate/video-review.js +120 -0
- package/dist/generate/video-review.js.map +1 -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/claude-settings.js +2 -0
- package/dist/scaffold/claude-settings.js.map +1 -1
- package/dist/scaffold/project-files.js +18 -7
- 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/dist/scaffold/unity-skill-files.js +1 -2
- package/dist/scaffold/unity-skill-files.js.map +1 -1
- package/dist/tools/bundles.d.ts +21 -0
- package/dist/tools/bundles.js +67 -0
- package/dist/tools/bundles.js.map +1 -0
- package/dist/trailer/ffmpeg.d.ts +10 -0
- package/dist/trailer/ffmpeg.js +51 -6
- package/dist/trailer/ffmpeg.js.map +1 -1
- package/dist/trailer/music-beats.js +4 -39
- package/dist/trailer/music-beats.js.map +1 -1
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -202,7 +202,7 @@ browser reloads itself on every change and the assets panel shows each generatio
|
|
|
202
202
|
| `bitmagic publish [--visibility public\|private] [--name <name>] [--description <desc>] [--force] [--single-file] [--no-qr]` | Verify-gate, build if needed, and upload straight to GCS. `--single-file` publishes the offline bundle described under `bitmagic build`; switching the flag between runs always rebuilds, since it changes nothing on disk for the reuse check to notice. **Keeps the game's current visibility**; a new game is private until published with `--visibility public`. Shows the published URL as a QR code so you can scan it onto a phone — drawn in the terminal, or written to `.bitmagic/publish-qr.png` when it cannot be. `--no-qr` skips both. |
|
|
203
203
|
| `bitmagic self-update [--tag latest\|dev]` | Update the **CLI itself** to the newest build of its line, installing into the place this CLI actually lives rather than wherever the `npm` on your `PATH` would put it — which under nvm is routinely somewhere else. Reads the project's line from `bitmagic.json` when there is one, so it also fixes a wrong-line install; `--tag` overrides. Verifies the version on disk moved, and refuses (naming the path) for an `npx` run, a source checkout, or an install shape it cannot recognise. Not to be confused with `upgrade`, which is about the project's engine. |
|
|
204
204
|
| `bitmagic upgrade [--engine <version>] [--force]` | Refresh vendored platform files and the CLI's own skills to the currently published engine. Refuses a dirty git tree unless `--force`. Also records the project's `environment` in `bitmagic.json` if it has none yet — the engine it just vendored came from that api-server — and says so when it does. |
|
|
205
|
-
| `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see below. `skybox` and `background` write the **same** field: pick one. |
|
|
205
|
+
| `bitmagic generate <skybox\|background\|block-type\|sound\|music\|image\|video\|character\|animation\|model\|prop\|vehicle> ...` | Generate one asset directly into `src/work/world.json`. Costs sparks. `model` mints a **new** 3D object from a prompt, `prop` upgrades an existing placeholder **in place** (both produce voxels directly; `--mesh` takes the older mesh path), `vehicle` adds a new drivable one, `animation` takes a parameter spec rather than a prompt, `block-type` registers a custom voxel block, `video` makes a short clip for a cutscene (played with `engine.playVideo("<id>")`) — see **Cutscene clips** below. `skybox` and `background` write the **same** field: pick one. |
|
|
206
206
|
| `bitmagic assets add <file> [--name <n>] [--asset-id <id>] [--keep-glb] [--voxel-size <m>] [--max-voxel-size <m>] [--height <m>] [--hollow\|--solid] [--force]` | Upload **your own** file — `.glb`, `.vxl`, `.png/.jpg/.webp`, `.opus/.ogg/.m4a/.mp3`, `.json` — into the game's assets and write its entry into `src/work/world.json`. A `.glb` is voxelized in a headless browser (Chrome + a free port in 3000–3199, like `prop`) unless `--keep-glb`. Free — no sparks. See **Adding your own assets** below. |
|
|
207
207
|
| `bitmagic assets list` | The assets in `world.json`: id, name, type, size, placed instances, and total references. Local, no network. |
|
|
208
208
|
| `bitmagic assets materials <assetId> [--set <file.json>] [--auto] [--clear]` | Show what an asset's voxels look like, or say what they are **made of** — so a sword's steel blade shines and its leather grip does not. With no flag it prints the asset's colour groups; `--set` applies your verdicts; `--auto` lets a model decide (what `generate prop` and `generate model` already do on their own); `--clear` removes them. Free — no sparks, nothing generated. See **Voxel materials** below. |
|
|
@@ -215,6 +215,7 @@ browser reloads itself on every change and the assets panel shows each generatio
|
|
|
215
215
|
| `bitmagic trailer analyze \| beats \| cut \| render \| merge` | The steps of `make` as separate commands: score a recording into highlight windows with contact sheets; detect a track's beat grid; turn `trailer/shots.json` into a frame-exact `edl.json`; render an EDL (`--ui` redraws the HUD, `--logo`, `--music …`); join recordings from separate sittings. |
|
|
216
216
|
| `bitmagic trailer publish <file.mp4> [--no-attach]` | Upload a trailer (`.mp4`/`.webm`, ≤ 200 MiB, header-checked so a renamed image is refused before upload) to the CDN under `game-media/<gameId>/` and put it at the front of the game page gallery. Re-running replaces the previous trailer (remembered in `.bitmagic/trailer/published.json`); the page is capped at 12 media items. Works before the first publish — the page goes live on the first `bitmagic publish --visibility public`. `--no-attach` uploads only. |
|
|
217
217
|
| `bitmagic forge --prompt "..." [--city\|--dungeon\|--platformer\|--freeform] [--name <name>] [--resume <jobId>]` | Design and bake a whole playable level into `world.json`. Costs sparks, and takes many minutes — the bake alone can take twenty. `--resume <jobId>` picks a failed run back up from the last step that finished, rather than redoing the whole thing. |
|
|
218
|
+
| `bitmagic tools <list\|path\|install> [bundle]` | The script bundles the CLI ships for skills that drive Blender or Python (`blender-level`, `unity-import`): what they are, where this install keeps them, and `install` to copy one into the project's `tools/`. Local, no network, no sparks. |
|
|
218
219
|
| `bitmagic levels <list\|add\|rename\|set-start\|remove> ...` | Manage the levels `forge` creates: which one boots, their names, registering a bake you already have, deleting one. Local, no network, no sparks. See **Managing levels** below. |
|
|
219
220
|
|
|
220
221
|
Every command exits non-zero on failure with a single-line message (no stack trace) and, where a
|
|
@@ -430,6 +431,7 @@ my-game/
|
|
|
430
431
|
<name>/ # that recording's analysis.json, sheets/, beats.json, music.mp3, edl.json and renders
|
|
431
432
|
published.json # the trailer's CDN URL after bitmagic trailer publish
|
|
432
433
|
previews/ # rendered asset thumbnails, cached so they are drawn once
|
|
434
|
+
videos/<assetId>.png # 8 frames from a generated clip, tiled — what `generate video` leaves to look at
|
|
433
435
|
build/
|
|
434
436
|
index.html # bitmagic build's output bundle
|
|
435
437
|
manifest.json # engineVersion, fingerprint, bytes, sha256, builtAt
|
|
@@ -503,6 +505,30 @@ Re-running on an unchanged design generates nothing and costs nothing — it rep
|
|
|
503
505
|
cover. Edit the design and run again, or pass `--force`, to replace it. Because it writes
|
|
504
506
|
`world.json`, it invalidates a passing verify: run it before `bitmagic verify`, not after.
|
|
505
507
|
|
|
508
|
+
## Cutscene clips
|
|
509
|
+
|
|
510
|
+
`bitmagic generate video --prompt "the castle gates swing open, dust drifting"` makes a short 720p
|
|
511
|
+
clip, writes it into `world.json` as a `type: "video"` asset, and prints the `engine.playVideo("<id>")`
|
|
512
|
+
call that plays it as a fullscreen overlay. `--duration` sets the length (billed per second);
|
|
513
|
+
`--image` animates a still you already have as the first frame, which is much slower and takes the
|
|
514
|
+
clip's shape from the image rather than the 16:9 default.
|
|
515
|
+
|
|
516
|
+
Because a clip is paid for before anyone can tell whether it is any good, the command leaves you
|
|
517
|
+
something to look at: **8 frames spread across the clip, tiled into `.bitmagic/videos/<assetId>.png`**.
|
|
518
|
+
Open it to check the clip actually shows what was asked for, without downloading the mp4 and writing
|
|
519
|
+
an ffmpeg filter by hand. It needs `ffmpeg` on `PATH`; without it the command still succeeds and
|
|
520
|
+
says why there are no frames. `--no-contact-sheet` skips the download.
|
|
521
|
+
|
|
522
|
+
The same pass says **whether the clip carries an audio track** — today's generated clips are silent,
|
|
523
|
+
so a cutscene that needs sound needs a music bed under it, and that is invisible in the frames.
|
|
524
|
+
|
|
525
|
+
To watch it move rather than as stills, `bitmagic dev`'s assets panel plays it.
|
|
526
|
+
|
|
527
|
+
One rule when you wire it up: call `engine.playVideo()` **from the Play click or later**, never
|
|
528
|
+
before. Without a prior user gesture the browser blocks autoplay, and the engine resolves the call
|
|
529
|
+
immediately — so the cutscene silently does not run. `engine/agent-docs/video-cutscenes.md` covers
|
|
530
|
+
that and what to freeze while a cutscene plays.
|
|
531
|
+
|
|
506
532
|
## Reference image
|
|
507
533
|
|
|
508
534
|
When a game has a strong setting — a tropical island, a neon city — one accepted concept image can
|
|
@@ -1209,6 +1235,16 @@ booting one level's terrain under another's objects).
|
|
|
1209
1235
|
Runtime level switching is engine API — `engine.loadLevel(levelId)` from game code. These commands
|
|
1210
1236
|
manage the registry, not gameplay.
|
|
1211
1237
|
|
|
1238
|
+
## Building a level in Blender
|
|
1239
|
+
|
|
1240
|
+
A hand-designed low-poly world — an interior, a station, a stylised set — can be written as a short
|
|
1241
|
+
Python script against the `bmlevel` library that ships in this package (`assets/blender-level/`),
|
|
1242
|
+
built by Blender into one GLB plus the collider / door / light / landmark JSON, audited with ray
|
|
1243
|
+
casts and rendered for review, then registered with `bitmagic assets add … --keep-glb` and loaded
|
|
1244
|
+
by the engine's `MeshLevel`. The scaffolded `building-levels-in-blender` skill walks through it;
|
|
1245
|
+
its first section says when that path applies (a frontier model, and Blender installed) and sends
|
|
1246
|
+
everything else to `bitmagic forge`.
|
|
1247
|
+
|
|
1212
1248
|
## Publishing
|
|
1213
1249
|
|
|
1214
1250
|
`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}
|