@bitmagic/cli 0.1.64-dev.1 → 0.1.64-dev.10
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 +80 -5
- 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/assets/keep-glb.d.ts +20 -0
- package/dist/assets/keep-glb.js +45 -0
- package/dist/assets/keep-glb.js.map +1 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +2 -0
- package/dist/cli.js.map +1 -1
- package/dist/commands/assets.d.ts +3 -0
- package/dist/commands/assets.js +33 -3
- package/dist/commands/assets.js.map +1 -1
- package/dist/commands/cover.js +7 -1
- package/dist/commands/cover.js.map +1 -1
- package/dist/commands/forge.js +70 -11
- package/dist/commands/forge.js.map +1 -1
- package/dist/commands/generate.d.ts +88 -1
- package/dist/commands/generate.js +400 -21
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.js +33 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/judge.d.ts +0 -11
- package/dist/commands/judge.js +15 -2
- package/dist/commands/judge.js.map +1 -1
- package/dist/commands/levels.d.ts +1 -1
- package/dist/commands/levels.js +14 -3
- package/dist/commands/levels.js.map +1 -1
- package/dist/commands/reference.js +5 -1
- package/dist/commands/reference.js.map +1 -1
- package/dist/commands/reset-account.js +2 -11
- package/dist/commands/reset-account.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/commands/trailer.js +18 -4
- package/dist/commands/trailer.js.map +1 -1
- package/dist/editor/asset-detail-panel.d.ts +8 -3
- package/dist/editor/asset-detail-panel.js +11 -1
- package/dist/editor/asset-detail-panel.js.map +1 -1
- package/dist/editor/hud-replay-page.js +317 -22
- package/dist/editor/hud-replay-page.js.map +1 -1
- package/dist/editor/shell-page.js +117 -7
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/forge/apply-modifications.js +70 -2
- package/dist/forge/apply-modifications.js.map +1 -1
- package/dist/forge/progress.d.ts +10 -0
- package/dist/forge/progress.js +7 -6
- package/dist/forge/progress.js.map +1 -1
- package/dist/forge/run-pipeline.d.ts +8 -0
- package/dist/forge/run-pipeline.js +37 -2
- package/dist/forge/run-pipeline.js.map +1 -1
- package/dist/forge/stream.d.ts +7 -1
- package/dist/forge/stream.js +5 -2
- package/dist/forge/stream.js.map +1 -1
- package/dist/forge/two-d-gate.d.ts +72 -0
- package/dist/forge/two-d-gate.js +198 -0
- package/dist/forge/two-d-gate.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/model.d.ts +11 -0
- package/dist/generate/model.js +41 -1
- package/dist/generate/model.js.map +1 -1
- package/dist/generate/prop.d.ts +9 -0
- package/dist/generate/prop.js +59 -9
- package/dist/generate/prop.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/project/art-style.d.ts +34 -0
- package/dist/project/art-style.js +63 -0
- package/dist/project/art-style.js.map +1 -0
- package/dist/prompt.d.ts +9 -0
- package/dist/prompt.js +36 -0
- package/dist/prompt.js.map +1 -0
- package/dist/publish/bundle.d.ts +12 -0
- package/dist/publish/bundle.js +32 -0
- package/dist/publish/bundle.js.map +1 -1
- package/dist/render/qr-png.d.ts +6 -3
- package/dist/render/qr-png.js +8 -8
- package/dist/render/qr-png.js.map +1 -1
- package/dist/scaffold/blender-level-skill-files.d.ts +26 -0
- package/dist/scaffold/blender-level-skill-files.js +570 -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.d.ts +2 -1
- package/dist/scaffold/project-files.js +125 -42
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.d.ts +9 -0
- package/dist/scaffold/project.js +29 -3
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/reference-guidance.d.ts +1 -1
- package/dist/scaffold/reference-guidance.js +2 -1
- package/dist/scaffold/reference-guidance.js.map +1 -1
- package/dist/scaffold/skill-files.js +3 -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/hud-raster.d.ts +12 -12
- package/dist/trailer/hud-raster.js +85 -32
- package/dist/trailer/hud-raster.js.map +1 -1
- package/dist/trailer/hud-state.d.ts +75 -4
- package/dist/trailer/hud-state.js +132 -17
- package/dist/trailer/hud-state.js.map +1 -1
- package/dist/trailer/io.d.ts +2 -0
- package/dist/trailer/io.js +6 -1
- package/dist/trailer/io.js.map +1 -1
- package/dist/trailer/link.d.ts +1 -0
- package/dist/trailer/link.js +15 -0
- package/dist/trailer/link.js.map +1 -0
- package/dist/trailer/make.d.ts +2 -0
- package/dist/trailer/make.js +1 -0
- package/dist/trailer/make.js.map +1 -1
- package/dist/trailer/merge.js +32 -11
- package/dist/trailer/merge.js.map +1 -1
- package/dist/trailer/music-beats.js +4 -39
- package/dist/trailer/music-beats.js.map +1 -1
- package/dist/trailer/render.d.ts +2 -0
- package/dist/trailer/render.js +94 -48
- package/dist/trailer/render.js.map +1 -1
- package/dist/trailer/timeline-types.d.ts +19 -0
- package/dist/trailer/timeline-types.js +42 -2
- package/dist/trailer/timeline-types.js.map +1 -1
- package/dist/trailer/title-raster.js +2 -1
- package/dist/trailer/title-raster.js.map +1 -1
- package/dist/trailer/titles.js +6 -1
- package/dist/trailer/titles.js.map +1 -1
- package/dist/trailer/video-chain.d.ts +96 -8
- package/dist/trailer/video-chain.js +183 -0
- package/dist/trailer/video-chain.js.map +1 -1
- package/dist/verify/browser.js +4 -1
- package/dist/verify/browser.js.map +1 -1
- package/package.json +4 -4
|
@@ -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}
|