@bitmagic/cli 0.1.64-dev.14 → 0.1.64-dev.16

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 CHANGED
@@ -705,8 +705,10 @@ Click the name for the rest of the answer: the game's id, the environment it tal
705
705
  built for, its engine version — and the two pictures that decide how the whole project looks, the
706
706
  **accepted reference image** and the **cover art**, side by side. Both were previously visible only
707
707
  in a terminal (`bitmagic reference show`, `bitmagic cover`); the dialog draws them from the local
708
- copies under `.bitmagic/`, so they are there whether or not the recorded URL still resolves. A
709
- project missing one is told which command makes it. The reference's caption warns you when it was
708
+ copies under `.bitmagic/`, so they are there whether or not the recorded URL still resolves. Click
709
+ either thumbnail to see it full size — a couple of hundred pixels of an image generated at 1024 is
710
+ not enough to judge the look it exists to fix; click anywhere, or press Escape, and the dialog is
711
+ still underneath. A project missing one is told which command makes it. The reference's caption warns you when it was
710
712
  never re-hosted, because that URL — the one every later generation inherits — will expire.
711
713
 
712
714
  What you reach for all session sits in the bar: the **Game / Editor** tabs, **Auto-reload**, the
@@ -15,10 +15,15 @@ skill's step 1 tells an agent how to copy them into a project's `tools/blender-l
15
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
16
  | `example_level.py` | either | a complete hut-and-courtyard level using every helper; the tests' fixture |
17
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 |
18
+ | `review.py` | Blender | deterministic review renders (overview, every door, every stair, every room) and a contact sheet; `--shot 'FROM -> TO'` aims a frame anywhere, `--only` renders just those, `--lighting game` swaps the review fill for the engine's interior preset and the level's own lamps |
19
19
  | `sync_world.py` | python3 | carries the doors, spawn and `terrain.shape: 'none'` into `src/work/world.json` |
20
20
  | `tests/` | python3 | `python3 -m unittest discover -s tests` — no Blender needed; `cli`'s `pnpm test` runs it |
21
21
 
22
+ `review.py` and `audit.py` are the skill's **inner loop**: with the build they answer every
23
+ geometric and (under `--lighting game`) every lighting question about a level for a few seconds
24
+ and no network, which is what lets the engine handoff — `bitmagic assets add`, `sync_world.py`,
25
+ `bitmagic verify`, `bitmagic judge` — happen once per design pass instead of once per edit.
26
+
22
27
  The JSON contract is owned by the engine (`game/src/engine/meshlevel/MeshLevelSchema.ts`,
23
28
  documented in `game/agent-docs/mesh-level.md`); `core.py` writes exactly that shape, and the
24
29
  engine's validator is the arbiter when the two disagree. The intended end state is a
@@ -1,4 +1,6 @@
1
- """The bpy-free half of bmlevel: coordinates, records, collider math and the JSON writers.
1
+ """The bpy-free half of bmlevel: coordinates, records, collider math, the JSON writers and the
2
+ review cameras — both the standard set (`review_cameras`) and the ad-hoc `--shot` specs
3
+ (`resolve_point`, `custom_shot`) review.py aims from a level JSON.
2
4
 
3
5
  Everything a level script says is in GAME coordinates — metres, Y up, +Z forward, yaw in radians
4
6
  about +Y so that an entity's forward at yaw θ is (sin θ, 0, cos θ) (engine/agent-docs/
@@ -436,3 +438,78 @@ def review_cameras(level: dict, doors: Sequence[dict] = ()) -> list[dict]:
436
438
  fx, fz = rotate_xz(0.0, 1.0, yaw) if long_axis_z else rotate_xz(1.0, 0.0, yaw)
437
439
  shots.append({'name': f"volume-{v['name']}", 'kind': 'persp', 'position': (p[0] + ox, eye_y, p[2] + oz), 'target': (p[0] + ox + fx * 4, eye_y - 0.3, p[2] + oz + fz * 4)})
438
440
  return shots
441
+
442
+
443
+ # ---------------------------------------------------------------- ad-hoc shots
444
+
445
+ EYE_HEIGHT = 1.6
446
+ """Where a standing player's camera is above the floor — the height every review shot uses."""
447
+
448
+
449
+ def level_center(level: dict) -> Vec3:
450
+ """The centre of the collider bounds, at eye height above their floor: what a shot with no
451
+ target looks at."""
452
+ pts: list[Vec3] = []
453
+ for c in level.get('colliders', []):
454
+ if c['shape'] == 'box':
455
+ pts.extend(box_corners(c['position'], c['size'], c.get('yaw', 0.0)))
456
+ else:
457
+ pts.extend(flat_to_points(c['vertices']))
458
+ b = aabb(pts)
459
+ if not b:
460
+ return (0.0, EYE_HEIGHT, 0.0)
461
+ return ((b[0][0] + b[1][0]) / 2, b[0][1] + EYE_HEIGHT, (b[0][2] + b[1][2]) / 2)
462
+
463
+
464
+ def resolve_point(spec: str, level: dict, doors: Sequence[dict] = (), *, as_eye: bool = False) -> Vec3:
465
+ """A game-space point from one end of a `--shot` spec.
466
+
467
+ `landmark:<name>`, `volume:<name>`, `door:<id>` or a raw `x,y,z`. Landmarks, doors and volumes
468
+ are recorded at the floor, the door's centre and the room's centre respectively, so `as_eye`
469
+ (the FROM end) lifts each to a standing camera and the TO end aims at the thing itself. Raw
470
+ coordinates are used exactly as written, either end — they are the escape hatch.
471
+ """
472
+ spec = spec.strip()
473
+ kind, _, rest = spec.partition(':')
474
+ kind, rest = kind.strip().lower(), rest.strip()
475
+ if kind == 'landmark':
476
+ for l in level.get('landmarks', []):
477
+ if l['name'] == rest:
478
+ x, y, z = l['position']
479
+ return (x, y + (EYE_HEIGHT if as_eye else 1.0), z)
480
+ known = ', '.join(sorted(l['name'] for l in level.get('landmarks', []))) or '(none)'
481
+ raise ValueError(f'no landmark named {rest!r} — the level has: {known}')
482
+ if kind == 'volume':
483
+ for v in level.get('volumes', []):
484
+ if v['name'] == rest:
485
+ x, y, z = v['position']
486
+ return (x, y - v['size'][1] / 2 + EYE_HEIGHT, z) if as_eye else (x, y, z)
487
+ known = ', '.join(sorted(v['name'] for v in level.get('volumes', []))) or '(none)'
488
+ raise ValueError(f'no volume named {rest!r} — the level has: {known}')
489
+ if kind == 'door':
490
+ for d in doors:
491
+ if d['id'] == rest:
492
+ p = d['position']
493
+ x, y, z = (p['x'], p['y'], p['z']) if isinstance(p, dict) else tuple(p)
494
+ return (x, y - d['height'] / 2 + EYE_HEIGHT, z) if as_eye else (x, y, z)
495
+ known = ', '.join(sorted(d['id'] for d in doors)) or '(none)'
496
+ raise ValueError(f'no door with id {rest!r} — the level has: {known}')
497
+ parts = [p for p in spec.replace(' ', '').split(',') if p]
498
+ if len(parts) == 3:
499
+ try:
500
+ return (float(parts[0]), float(parts[1]), float(parts[2]))
501
+ except ValueError:
502
+ pass
503
+ raise ValueError(f'cannot read shot point {spec!r} — use landmark:<name>, volume:<name>, door:<id> or x,y,z')
504
+
505
+
506
+ def custom_shot(spec: str, index: int, level: dict, doors: Sequence[dict] = (), fov: float | None = None) -> dict:
507
+ """One `--shot` argument as a review shot. `'FROM -> TO'`, or `'FROM'` to look at the level's
508
+ centre. `index` numbers the frame (`shot-1`, `shot-2`, …)."""
509
+ frm, arrow, to = spec.partition('->')
510
+ position = resolve_point(frm, level, doors, as_eye=True)
511
+ target = resolve_point(to, level, doors) if arrow else level_center(level)
512
+ shot = {'name': f'shot-{index}', 'kind': 'persp', 'position': position, 'target': target, 'spec': spec.strip()}
513
+ if fov is not None:
514
+ shot['fov'] = fov
515
+ return shot
@@ -1,14 +1,23 @@
1
1
  """Headless review renders of a built level, run inside Blender:
2
2
 
3
3
  "$BLENDER" -b --python tools/blender-level/review.py -- build/level/hut.blend build/level/hut.mesh-level.json build/level/review/
4
+ [--lighting review|game] [--shot 'FROM -> TO']... [--only] [--fov <degrees>]
4
5
 
5
6
  Opens the .blend (named parts intact) and renders a deterministic set of shots from the level
6
7
  JSON — `overview` (top-down), `door-<id>-a/b` (both approaches of every door), `stairs-<name>`
7
8
  (from the foot), `volume-<name>` (inside every room) — into the output directory, plus
8
9
  `contact-sheet.png` with all of them. Read the sheet, then the shots that look wrong.
9
10
 
10
- EEVEE, 960 x 540, a camera-mounted fill light so interiors are legible whatever the JSON lights
11
- say. This is a look at the geometry, not the game's lighting — `bitmagic verify` shows that.
11
+ `--shot` adds a frame you aim yourself: both ends are `landmark:<name>`, `volume:<name>`,
12
+ `door:<id>` or a raw `x,y,z` in game coordinates, and `'FROM'` alone looks at the level's centre.
13
+ Repeat it; `--only` renders just those and skips the standard set and the sheet, which is the
14
+ one-render look at the thing you just changed.
15
+
16
+ `--lighting review` (the default) mounts a camera fill and a sun so nothing hides in shadow: this
17
+ is a look at the GEOMETRY. `--lighting game` instead runs the engine's `interior` preset and the
18
+ `lamp()`s and `spot()`s the level JSON declares, so the lighting pass can be judged here rather
19
+ than through a `bitmagic verify`. EEVEE is not three.js — trust "too dark" and "too bright", not
20
+ an exact colour.
12
21
  """
13
22
  import json
14
23
  import math
@@ -19,14 +28,40 @@ import bpy
19
28
  from mathutils import Vector
20
29
 
21
30
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
- from bmlevel.core import review_cameras, to_blender # noqa: E402
31
+ from bmlevel.core import custom_shot, review_cameras, to_blender # noqa: E402
23
32
 
24
33
  argv = sys.argv[sys.argv.index('--') + 1:] if '--' in sys.argv else sys.argv[1:]
25
- if len(argv) < 3:
34
+ positional = [a for a in argv if not a.startswith('--')]
35
+
36
+
37
+ def flag_value(name: str, default: str | None = None) -> str | None:
38
+ return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default
39
+
40
+
41
+ def flag_values(name: str) -> list[str]:
42
+ return [argv[i + 1] for i, a in enumerate(argv) if a == name and i + 1 < len(argv)]
43
+
44
+
45
+ # `--shot`, `--lighting` and `--fov` each consume the token after them, which would otherwise read
46
+ # as a positional path.
47
+ consumed = {argv[i + 1] for i, a in enumerate(argv) if a in ('--shot', '--lighting', '--fov') and i + 1 < len(argv)}
48
+ positional = [a for a in positional if a not in consumed]
49
+ if len(positional) < 3:
26
50
  print(__doc__)
27
51
  sys.exit(2)
28
- BLEND, LEVEL_PATH, OUT = argv[0], argv[1], argv[2]
52
+ BLEND, LEVEL_PATH, OUT = positional[0], positional[1], positional[2]
53
+ LIGHTING = (flag_value('--lighting', 'review') or 'review').lower()
54
+ if LIGHTING not in ('review', 'game'):
55
+ print(f"--lighting takes 'review' or 'game', not {LIGHTING!r}")
56
+ sys.exit(2)
57
+ SHOT_SPECS = flag_values('--shot')
58
+ ONLY = '--only' in argv
59
+ FOV = float(flag_value('--fov')) if flag_value('--fov') else None
60
+ if ONLY and not SHOT_SPECS:
61
+ print('--only needs at least one --shot')
62
+ sys.exit(2)
29
63
  WIDTH, HEIGHT = 960, 540
64
+ DEFAULT_LENS = 24.0
30
65
 
31
66
  level = json.load(open(LEVEL_PATH))
32
67
  doors_path = LEVEL_PATH.replace('.mesh-level.json', '.doors.json')
@@ -47,7 +82,10 @@ if scene.world is None:
47
82
  scene.world.use_nodes = True
48
83
  bg = scene.world.node_tree.nodes['Background']
49
84
  bg.inputs[0].default_value = (0.35, 0.4, 0.5, 1)
50
- bg.inputs[1].default_value = 0.6
85
+ # The engine's `interior` preset holds environment at 0.22 and the skybox at 0.3 against an
86
+ # ambient floor of 0.8 (engine/agent-docs/mesh-level.md); a world background is EEVEE's nearest
87
+ # equivalent to that plus the retargeted HemisphereLight. `review` lights it flat instead.
88
+ bg.inputs[1].default_value = 0.6 if LIGHTING == 'review' else 0.35
51
89
 
52
90
  cam_data = bpy.data.cameras.new('review')
53
91
  cam = bpy.data.objects.new('review', cam_data)
@@ -59,10 +97,57 @@ fill_data.shadow_soft_size = 2
59
97
  fill = bpy.data.objects.new('review-fill', fill_data)
60
98
  scene.collection.objects.link(fill)
61
99
  sun_data = bpy.data.lights.new('review-sun', 'SUN')
62
- sun_data.energy = 2.5
100
+ # The engine dims its sun to 8% indoors; `review` keeps a full one so geometry reads everywhere.
101
+ sun_data.energy = 2.5 if LIGHTING == 'review' else 2.5 * 0.08
63
102
  sun = bpy.data.objects.new('review-sun', sun_data)
64
103
  scene.collection.objects.link(sun)
65
104
  sun.rotation_euler = (math.radians(50), math.radians(15), math.radians(30))
105
+ if LIGHTING == 'game':
106
+ fill.hide_render = True
107
+
108
+
109
+ def hex_rgb(value) -> tuple[float, float, float]:
110
+ """`#rrggbb` (or a three.js colour number) to linear-ish rgb. The level JSON writes hex."""
111
+ if isinstance(value, str):
112
+ h = value.lstrip('#')
113
+ n = int(h, 16) if h else 0xFFFFFF
114
+ else:
115
+ n = int(value)
116
+ return (((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255)
117
+
118
+
119
+ def add_level_lights() -> int:
120
+ """The JSON's own lamps and spots as real EEVEE lights. three.js intensities are unitless and
121
+ Blender's are watts; 30 W per unit puts a default `lamp(intensity=8)` where the eye expects a
122
+ room light. The engine pools point lights (a room with eight lamps gets four in game) — this
123
+ lights all of them, so read brightness, not count."""
124
+ for light in level.get('lights', []):
125
+ color = hex_rgb(light.get('color', '#ffffff'))
126
+ if light['type'] == 'point':
127
+ data = bpy.data.lights.new(light['name'], 'POINT')
128
+ data.energy = light.get('intensity', 8.0) * 30
129
+ data.shadow_soft_size = 0.25
130
+ obj = bpy.data.objects.new(light['name'], data)
131
+ obj.location = to_blender(light['position'])
132
+ else:
133
+ data = bpy.data.lights.new(light['name'], 'SPOT')
134
+ data.energy = light.get('intensity', 20.0) * 30
135
+ # three.js `angle` is the half-cone; Blender's `spot_size` is the full one.
136
+ data.spot_size = min(light.get('angle', math.pi / 4) * 2, math.pi)
137
+ data.spot_blend = light.get('penumbra', 0.3)
138
+ data.shadow_soft_size = 0.1
139
+ data.use_shadow = bool(light.get('castShadow', True))
140
+ obj = bpy.data.objects.new(light['name'], data)
141
+ pos = Vector(to_blender(light['position']))
142
+ obj.location = pos
143
+ obj.rotation_euler = (Vector(to_blender(light['target'])) - pos).to_track_quat('-Z', 'Y').to_euler()
144
+ data.color = color
145
+ scene.collection.objects.link(obj)
146
+ return len(level.get('lights', []))
147
+
148
+
149
+ if LIGHTING == 'game':
150
+ print(f'LIGHTING game — interior preset, {add_level_lights()} level lights, emissive materials as authored')
66
151
 
67
152
 
68
153
  def render(shot: dict) -> str:
@@ -77,8 +162,14 @@ def render(shot: dict) -> str:
77
162
  fill.hide_render = True
78
163
  else:
79
164
  cam_data.type = 'PERSP'
80
- cam_data.lens = 24
81
- fill.hide_render = False
165
+ fov = shot.get('fov', FOV)
166
+ if fov:
167
+ cam_data.lens_unit = 'FOV'
168
+ cam_data.angle = math.radians(fov)
169
+ else:
170
+ cam_data.lens_unit = 'MILLIMETERS'
171
+ cam_data.lens = DEFAULT_LENS
172
+ fill.hide_render = LIGHTING == 'game'
82
173
  fill.location = pos + Vector((0, 0, 0.6))
83
174
  path = os.path.join(OUT, f"{shot['name']}.png")
84
175
  scene.render.filepath = path
@@ -86,10 +177,14 @@ def render(shot: dict) -> str:
86
177
  return path
87
178
 
88
179
 
89
- shots = review_cameras(level, doors)
180
+ shots = [] if ONLY else review_cameras(level, doors)
181
+ shots += [custom_shot(spec, i + 1, level, doors, FOV) for i, spec in enumerate(SHOT_SPECS)]
90
182
  paths = [render(s) for s in shots]
91
183
  for s, p in zip(shots, paths):
92
- print(f'SHOT {s["name"]} -> {p}')
184
+ print(f"SHOT {s['name']} -> {p}" + (f" ({s['spec']})" if 'spec' in s else ''))
185
+
186
+ if ONLY:
187
+ sys.exit(0)
93
188
 
94
189
  # Contact sheet: every shot at half size, four per row, labelled by file name in the log.
95
190
  import numpy as np # Blender bundles numpy # noqa: E402
@@ -185,5 +185,54 @@ class SyncWorldTests(unittest.TestCase):
185
185
  self.assertEqual({d['id'] for d in merged}, {'front', 'gone', 'new'})
186
186
 
187
187
 
188
+ class ShotSpecTests(unittest.TestCase):
189
+ """`--shot` specs: the ad-hoc camera review.py aims from the level JSON."""
190
+
191
+ LEVEL = {
192
+ 'colliders': [{'shape': 'box', 'position': [0, 0, 0], 'size': [10, 1, 10], 'yaw': 0}],
193
+ 'landmarks': [{'name': 'spawn', 'position': [1, 0.1, 2], 'yaw': 0, 'tags': []}],
194
+ 'volumes': [{'name': 'hall', 'position': [0, 1.75, 0], 'size': [12, 3.5, 8], 'yaw': 0, 'tags': []}],
195
+ }
196
+ DOORS = [{'id': 'north', 'position': {'x': 0, 'y': 1.1, 'z': -4}, 'rotationY': 0, 'width': 1.4, 'height': 2.2}]
197
+
198
+ def test_landmark_is_an_eye_at_the_from_end_and_the_thing_at_the_to_end(self):
199
+ self.assertEqual(core.resolve_point('landmark:spawn', self.LEVEL, as_eye=True), (1, 0.1 + core.EYE_HEIGHT, 2))
200
+ self.assertEqual(core.resolve_point('landmark:spawn', self.LEVEL), (1, 1.1, 2))
201
+
202
+ def test_volume_from_is_eye_height_above_its_floor_and_to_is_its_centre(self):
203
+ self.assertEqual(core.resolve_point('volume:hall', self.LEVEL, as_eye=True), (0, 1.75 - 1.75 + core.EYE_HEIGHT, 0))
204
+ self.assertEqual(core.resolve_point('volume:hall', self.LEVEL), (0, 1.75, 0))
205
+
206
+ def test_door_from_is_eye_height_above_its_threshold(self):
207
+ self.assertEqual(core.resolve_point('door:north', self.LEVEL, self.DOORS, as_eye=True), (0, 1.1 - 1.1 + core.EYE_HEIGHT, -4))
208
+ self.assertEqual(core.resolve_point('door:north', self.LEVEL, self.DOORS), (0, 1.1, -4))
209
+
210
+ def test_raw_coordinates_are_used_exactly_at_either_end(self):
211
+ self.assertEqual(core.resolve_point('3, -1.5,7', self.LEVEL, as_eye=True), (3.0, -1.5, 7.0))
212
+ self.assertEqual(core.resolve_point('3,-1.5,7', self.LEVEL), (3.0, -1.5, 7.0))
213
+
214
+ def test_an_unknown_name_lists_the_known_ones(self):
215
+ with self.assertRaises(ValueError) as raised:
216
+ core.resolve_point('landmark:altar', self.LEVEL)
217
+ self.assertIn('spawn', str(raised.exception))
218
+ with self.assertRaises(ValueError):
219
+ core.resolve_point('nonsense', self.LEVEL)
220
+
221
+ def test_a_shot_without_a_target_looks_at_the_level_centre(self):
222
+ shot = core.custom_shot('landmark:spawn', 1, self.LEVEL, self.DOORS)
223
+ self.assertEqual(shot['name'], 'shot-1')
224
+ self.assertEqual(shot['kind'], 'persp')
225
+ self.assertEqual(shot['target'], core.level_center(self.LEVEL))
226
+ self.assertNotIn('fov', shot)
227
+
228
+ def test_a_from_to_shot_carries_both_ends_and_the_fov(self):
229
+ shot = core.custom_shot('landmark:spawn -> volume:hall', 2, self.LEVEL, self.DOORS, fov=70)
230
+ self.assertEqual(shot['name'], 'shot-2')
231
+ self.assertEqual(shot['position'], (1, 0.1 + core.EYE_HEIGHT, 2))
232
+ self.assertEqual(shot['target'], (0, 1.75, 0))
233
+ self.assertEqual(shot['fov'], 70)
234
+ self.assertEqual(shot['spec'], 'landmark:spawn -> volume:hall')
235
+
236
+
188
237
  if __name__ == '__main__':
189
238
  unittest.main()
@@ -22,7 +22,7 @@
22
22
  * name, in the weight the bar already gave it, that happens to be clickable. Only the hover and
23
23
  * focus treatments are added, so it reads as a control the moment a pointer is near it.
24
24
  */
25
- export declare const GAME_INFO_PANEL_CSS = "\n /* The name IS the button. Bold and white like the label it replaces, with the id and env it\n absorbed now one click away rather than two more slots in the bar. */\n #game-info-toggle {\n display: flex;\n align-items: center;\n gap: 6px;\n max-width: 26vw;\n padding: 4px 8px;\n background: transparent;\n border: 1px solid transparent;\n border-radius: 6px;\n color: var(--bm-text);\n font-family: var(--bm-font);\n font-size: 14px;\n font-weight: 700;\n line-height: 1.2;\n cursor: pointer;\n transition: background .15s ease, border-color .15s ease;\n }\n #game-info-toggle:hover { background: var(--bm-surface-alt); border-color: var(--bm-border-muted); }\n #game-info-toggle:focus-visible { outline: none; box-shadow: var(--bm-focus-ring); }\n #game-info-toggle .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n /* The affordance. Dim, so the name still reads as the name rather than as a menu. */\n #game-info-toggle .chevron { flex: none; color: var(--bm-text-dim); font-size: 10px; }\n\n /* Level with the other two dialogs: all three are opened from different controls and never\n stack. */\n #info-overlay { z-index: 10; }\n #info-dialog { width: min(560px, 92vw); max-height: 86vh; overflow-y: auto; padding: 20px; }\n #info-dialog h2 { margin-bottom: 16px; }\n /* A definition list, because that is what this is: a fixed set of fields with one value each.\n Two columns so the values line up and the whole block is scannable without reading it. */\n #info-facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0 0 20px; }\n #info-facts dt { font-size: 12px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 1.4px; color: var(--bm-text-muted); }\n #info-facts dd { margin: 0; min-width: 0; color: var(--bm-text); overflow-wrap: anywhere; }\n /* Ids, versions and modes are read character by character; names and genres are read as words. */\n #info-facts dd.mono { font-family: var(--bm-font-mono); font-size: 12px; }\n #info-facts dd .note { color: var(--bm-text-dim); }\n /* The two pictures, side by side: they are the same kind of thing and are compared, not read\n in order. They wrap to one column when the dialog is at its narrowest. */\n #info-art { display: flex; flex-wrap: wrap; gap: 16px; }\n .info-art-card { flex: 1 1 200px; min-width: 0; }\n .info-art-card .bm-label { display: block; margin-bottom: 8px; }\n /* A square frame either way, so a present picture and a missing one leave the dialog the same\n height and the two cards stay level. */\n .info-art-frame {\n display: flex;\n align-items: center;\n justify-content: center;\n aspect-ratio: 1 / 1;\n padding: 12px;\n background: var(--bm-surface-alt);\n border: 1px solid var(--bm-border-muted);\n border-radius: 6px;\n text-align: center;\n }\n /* The frame holds the picture AND the \"not yet\" copy, and shows exactly one of them: the class\n is applied by openInfo(), because a page assembled as a string has no inline styles to toggle\n (DESIGN.md, and the same rule the Creator follows). */\n .info-art-frame img { display: none; max-width: 100%; max-height: 100%; border-radius: 4px; }\n .info-art-frame.has-image img { display: block; }\n .info-art-frame.has-image .empty { display: none; }\n /* What to run to make the missing one, rather than the bare fact that it is missing. */\n .info-art-frame .empty { font-size: 12px; color: var(--bm-text-dim); }\n .info-art-frame .empty code { display: block; margin-top: 6px; color: var(--bm-aqua);\n font-family: var(--bm-font-mono); }\n .info-art-card .caption { margin-top: 8px; font-size: 12px; color: var(--bm-text-dim);\n overflow-wrap: anywhere; }\n .info-art-card .caption a { color: var(--bm-aqua); text-decoration: none; }\n .info-art-card .caption a:hover { text-decoration: underline; }\n /* An expiring reference URL is the one thing on this card worth interrupting for. */\n .info-art-card .caption .stale { color: var(--bm-pumpkin); }\n /* The sidecar could not answer. Said in the dialog rather than in the bar's status line, which\n belongs to the save state and would be overwritten by the next poll. */\n #info-error { display: none; margin: 0 0 16px; font-size: 12px; color: var(--bm-pumpkin); }\n #info-error.show { display: block; }\n #info-actions { display: flex; justify-content: flex-end; margin-top: 20px; }\n";
25
+ export declare const GAME_INFO_PANEL_CSS = "\n /* The name IS the button. Bold and white like the label it replaces, with the id and env it\n absorbed now one click away rather than two more slots in the bar. */\n #game-info-toggle {\n display: flex;\n align-items: center;\n gap: 6px;\n max-width: 26vw;\n padding: 4px 8px;\n background: transparent;\n border: 1px solid transparent;\n border-radius: 6px;\n color: var(--bm-text);\n font-family: var(--bm-font);\n font-size: 14px;\n font-weight: 700;\n line-height: 1.2;\n cursor: pointer;\n transition: background .15s ease, border-color .15s ease;\n }\n #game-info-toggle:hover { background: var(--bm-surface-alt); border-color: var(--bm-border-muted); }\n #game-info-toggle:focus-visible { outline: none; box-shadow: var(--bm-focus-ring); }\n #game-info-toggle .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n /* The affordance. Dim, so the name still reads as the name rather than as a menu. */\n #game-info-toggle .chevron { flex: none; color: var(--bm-text-dim); font-size: 10px; }\n\n /* Level with the other two dialogs: all three are opened from different controls and never\n stack. */\n #info-overlay { z-index: 10; }\n #info-dialog { width: min(560px, 92vw); max-height: 86vh; overflow-y: auto; padding: 20px; }\n #info-dialog h2 { margin-bottom: 16px; }\n /* A definition list, because that is what this is: a fixed set of fields with one value each.\n Two columns so the values line up and the whole block is scannable without reading it. */\n #info-facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 16px; margin: 0 0 20px; }\n #info-facts dt { font-size: 12px; font-weight: 700; text-transform: uppercase;\n letter-spacing: 1.4px; color: var(--bm-text-muted); }\n #info-facts dd { margin: 0; min-width: 0; color: var(--bm-text); overflow-wrap: anywhere; }\n /* Ids, versions and modes are read character by character; names and genres are read as words. */\n #info-facts dd.mono { font-family: var(--bm-font-mono); font-size: 12px; }\n #info-facts dd .note { color: var(--bm-text-dim); }\n /* The two pictures, side by side: they are the same kind of thing and are compared, not read\n in order. They wrap to one column when the dialog is at its narrowest. */\n #info-art { display: flex; flex-wrap: wrap; gap: 16px; }\n .info-art-card { flex: 1 1 200px; min-width: 0; }\n .info-art-card .bm-label { display: block; margin-bottom: 8px; }\n /* A square frame either way, so a present picture and a missing one leave the dialog the same\n height and the two cards stay level.\n\n A button rather than a div, because with a picture in it that is what it is: the thumbnail is\n a couple of hundred pixels of an image generated at 1024, which is not enough to judge the\n look it is supposed to fix \u2014 clicking it opens the full size. Disabled while the card is\n empty, so the control is dead exactly when there is nothing to open, and reachable by keyboard\n for free either way. */\n .info-art-frame {\n display: flex;\n align-items: center;\n justify-content: center;\n width: 100%;\n aspect-ratio: 1 / 1;\n padding: 12px;\n background: var(--bm-surface-alt);\n border: 1px solid var(--bm-border-muted);\n border-radius: 6px;\n color: inherit;\n font: inherit;\n text-align: center;\n cursor: zoom-in;\n transition: border-color .15s ease;\n }\n .info-art-frame:hover:not(:disabled) { border-color: var(--bm-text-dim); }\n .info-art-frame:focus-visible { outline: none; box-shadow: var(--bm-focus-ring); }\n /* Not dimmed like an ordinary disabled control: there is nothing to enable here, and greying the\n \"not yet\" copy would hide the command that fixes it. */\n .info-art-frame:disabled { cursor: default; }\n /* The frame holds the picture AND the \"not yet\" copy, and shows exactly one of them: the class\n is applied by openInfo(), because a page assembled as a string has no inline styles to toggle\n (DESIGN.md, and the same rule the Creator follows). */\n .info-art-frame img { display: none; max-width: 100%; max-height: 100%; border-radius: 4px; }\n .info-art-frame.has-image img { display: block; }\n .info-art-frame.has-image .empty { display: none; }\n /* What to run to make the missing one, rather than the bare fact that it is missing. */\n .info-art-frame .empty { font-size: 12px; color: var(--bm-text-dim); }\n .info-art-frame .empty code { display: block; margin-top: 6px; color: var(--bm-aqua);\n font-family: var(--bm-font-mono); }\n .info-art-card .caption { margin-top: 8px; font-size: 12px; color: var(--bm-text-dim);\n overflow-wrap: anywhere; }\n .info-art-card .caption a { color: var(--bm-aqua); text-decoration: none; }\n .info-art-card .caption a:hover { text-decoration: underline; }\n /* An expiring reference URL is the one thing on this card worth interrupting for. */\n .info-art-card .caption .stale { color: var(--bm-pumpkin); }\n /* === The full-size view ===================================================================\n A layer of its own above the dialog rather than a new browser tab: the dev view is the window\n the creator is already in, and a tab showing a bare image on a loopback port is a place they\n then have to come back from. Click anywhere, or Escape, and the dialog is still underneath\n exactly as it was. */\n #info-zoom { z-index: 11; flex-direction: column; gap: 12px; cursor: zoom-out; }\n #info-zoom img { max-width: 92vw; max-height: 84vh; border-radius: 6px;\n box-shadow: var(--bm-shadow-dialog); }\n #info-zoom .hint { font-size: 12px; color: var(--bm-text-muted); }\n\n /* The sidecar could not answer. Said in the dialog rather than in the bar's status line, which\n belongs to the save state and would be overwritten by the next poll. */\n #info-error { display: none; margin: 0 0 16px; font-size: 12px; color: var(--bm-pumpkin); }\n #info-error.show { display: block; }\n #info-actions { display: flex; justify-content: flex-end; margin-top: 20px; }\n";
26
26
  /**
27
27
  * Markup for the Game-info dialog.
28
28
  *
@@ -30,4 +30,4 @@ export declare const GAME_INFO_PANEL_CSS = "\n /* The name IS the button. Bold
30
30
  * interpolated at render time: this page is built once when the server starts, and `bitmagic
31
31
  * cover` or `bitmagic reference accept` usually runs hours later in another terminal.
32
32
  */
33
- export declare const GAME_INFO_PANEL_HTML = " <div id=\"info-overlay\" class=\"bm-overlay\">\n <div id=\"info-dialog\" class=\"bm-dialog\">\n <h2 id=\"info-title\"></h2>\n <p id=\"info-error\"></p>\n <dl id=\"info-facts\">\n <dt>Game id</dt><dd id=\"info-game-id\" class=\"mono\"></dd>\n <dt>Environment</dt><dd id=\"info-environment\" class=\"mono\"></dd>\n <dt>Genre</dt><dd id=\"info-genre\"></dd>\n <dt>Physics</dt><dd id=\"info-physics\" class=\"mono\"></dd>\n <dt>Art style</dt><dd id=\"info-art-style\"></dd>\n <dt>Platform</dt><dd id=\"info-platform\"></dd>\n <dt>Engine</dt><dd id=\"info-engine\" class=\"mono\"></dd>\n </dl>\n <div id=\"info-art\">\n <div class=\"info-art-card\">\n <span class=\"bm-label\">Reference image</span>\n <div id=\"info-reference-frame\" class=\"info-art-frame\">\n <img id=\"info-reference-img\" alt=\"This game's accepted reference image\">\n <span id=\"info-reference-empty\" class=\"empty\">No reference image yet.<code>bitmagic reference make</code></span>\n </div>\n <p id=\"info-reference-caption\" class=\"caption\"></p>\n </div>\n <div class=\"info-art-card\">\n <span class=\"bm-label\">Cover art</span>\n <div id=\"info-cover-frame\" class=\"info-art-frame\">\n <img id=\"info-cover-img\" alt=\"This game's cover art\">\n <span id=\"info-cover-empty\" class=\"empty\">No cover art yet.<code>bitmagic cover</code></span>\n </div>\n <p id=\"info-cover-caption\" class=\"caption\"></p>\n </div>\n </div>\n <div id=\"info-actions\">\n <button id=\"info-close\" class=\"bm-pill bm-pill--primary\" type=\"button\">Close</button>\n </div>\n </div>\n </div>\n";
33
+ export declare const GAME_INFO_PANEL_HTML = " <div id=\"info-overlay\" class=\"bm-overlay\">\n <div id=\"info-dialog\" class=\"bm-dialog\">\n <h2 id=\"info-title\"></h2>\n <p id=\"info-error\"></p>\n <dl id=\"info-facts\">\n <dt>Game id</dt><dd id=\"info-game-id\" class=\"mono\"></dd>\n <dt>Environment</dt><dd id=\"info-environment\" class=\"mono\"></dd>\n <dt>Genre</dt><dd id=\"info-genre\"></dd>\n <dt>Physics</dt><dd id=\"info-physics\" class=\"mono\"></dd>\n <dt>Art style</dt><dd id=\"info-art-style\"></dd>\n <dt>Platform</dt><dd id=\"info-platform\"></dd>\n <dt>Engine</dt><dd id=\"info-engine\" class=\"mono\"></dd>\n </dl>\n <div id=\"info-art\">\n <div class=\"info-art-card\">\n <span class=\"bm-label\">Reference image</span>\n <button id=\"info-reference-frame\" class=\"info-art-frame\" type=\"button\" disabled title=\"See it full size\">\n <img id=\"info-reference-img\" alt=\"This game's accepted reference image\">\n <span id=\"info-reference-empty\" class=\"empty\">No reference image yet.<code>bitmagic reference make</code></span>\n </button>\n <p id=\"info-reference-caption\" class=\"caption\"></p>\n </div>\n <div class=\"info-art-card\">\n <span class=\"bm-label\">Cover art</span>\n <button id=\"info-cover-frame\" class=\"info-art-frame\" type=\"button\" disabled title=\"See it full size\">\n <img id=\"info-cover-img\" alt=\"This game's cover art\">\n <span id=\"info-cover-empty\" class=\"empty\">No cover art yet.<code>bitmagic cover</code></span>\n </button>\n <p id=\"info-cover-caption\" class=\"caption\"></p>\n </div>\n </div>\n <div id=\"info-actions\">\n <button id=\"info-close\" class=\"bm-pill bm-pill--primary\" type=\"button\">Close</button>\n </div>\n </div>\n </div>\n <div id=\"info-zoom\" class=\"bm-overlay\">\n <img id=\"info-zoom-img\" alt=\"\">\n <span class=\"hint\">Click anywhere to go back.</span>\n </div>\n";
@@ -68,18 +68,34 @@ export const GAME_INFO_PANEL_CSS = `
68
68
  .info-art-card { flex: 1 1 200px; min-width: 0; }
69
69
  .info-art-card .bm-label { display: block; margin-bottom: 8px; }
70
70
  /* A square frame either way, so a present picture and a missing one leave the dialog the same
71
- height and the two cards stay level. */
71
+ height and the two cards stay level.
72
+
73
+ A button rather than a div, because with a picture in it that is what it is: the thumbnail is
74
+ a couple of hundred pixels of an image generated at 1024, which is not enough to judge the
75
+ look it is supposed to fix — clicking it opens the full size. Disabled while the card is
76
+ empty, so the control is dead exactly when there is nothing to open, and reachable by keyboard
77
+ for free either way. */
72
78
  .info-art-frame {
73
79
  display: flex;
74
80
  align-items: center;
75
81
  justify-content: center;
82
+ width: 100%;
76
83
  aspect-ratio: 1 / 1;
77
84
  padding: 12px;
78
85
  background: var(--bm-surface-alt);
79
86
  border: 1px solid var(--bm-border-muted);
80
87
  border-radius: 6px;
88
+ color: inherit;
89
+ font: inherit;
81
90
  text-align: center;
91
+ cursor: zoom-in;
92
+ transition: border-color .15s ease;
82
93
  }
94
+ .info-art-frame:hover:not(:disabled) { border-color: var(--bm-text-dim); }
95
+ .info-art-frame:focus-visible { outline: none; box-shadow: var(--bm-focus-ring); }
96
+ /* Not dimmed like an ordinary disabled control: there is nothing to enable here, and greying the
97
+ "not yet" copy would hide the command that fixes it. */
98
+ .info-art-frame:disabled { cursor: default; }
83
99
  /* The frame holds the picture AND the "not yet" copy, and shows exactly one of them: the class
84
100
  is applied by openInfo(), because a page assembled as a string has no inline styles to toggle
85
101
  (DESIGN.md, and the same rule the Creator follows). */
@@ -96,6 +112,16 @@ export const GAME_INFO_PANEL_CSS = `
96
112
  .info-art-card .caption a:hover { text-decoration: underline; }
97
113
  /* An expiring reference URL is the one thing on this card worth interrupting for. */
98
114
  .info-art-card .caption .stale { color: var(--bm-pumpkin); }
115
+ /* === The full-size view ===================================================================
116
+ A layer of its own above the dialog rather than a new browser tab: the dev view is the window
117
+ the creator is already in, and a tab showing a bare image on a loopback port is a place they
118
+ then have to come back from. Click anywhere, or Escape, and the dialog is still underneath
119
+ exactly as it was. */
120
+ #info-zoom { z-index: 11; flex-direction: column; gap: 12px; cursor: zoom-out; }
121
+ #info-zoom img { max-width: 92vw; max-height: 84vh; border-radius: 6px;
122
+ box-shadow: var(--bm-shadow-dialog); }
123
+ #info-zoom .hint { font-size: 12px; color: var(--bm-text-muted); }
124
+
99
125
  /* The sidecar could not answer. Said in the dialog rather than in the bar's status line, which
100
126
  belongs to the save state and would be overwritten by the next poll. */
101
127
  #info-error { display: none; margin: 0 0 16px; font-size: 12px; color: var(--bm-pumpkin); }
@@ -125,18 +151,18 @@ export const GAME_INFO_PANEL_HTML = ` <div id="info-overlay" class="bm-overlay"
125
151
  <div id="info-art">
126
152
  <div class="info-art-card">
127
153
  <span class="bm-label">Reference image</span>
128
- <div id="info-reference-frame" class="info-art-frame">
154
+ <button id="info-reference-frame" class="info-art-frame" type="button" disabled title="See it full size">
129
155
  <img id="info-reference-img" alt="This game's accepted reference image">
130
156
  <span id="info-reference-empty" class="empty">No reference image yet.<code>bitmagic reference make</code></span>
131
- </div>
157
+ </button>
132
158
  <p id="info-reference-caption" class="caption"></p>
133
159
  </div>
134
160
  <div class="info-art-card">
135
161
  <span class="bm-label">Cover art</span>
136
- <div id="info-cover-frame" class="info-art-frame">
162
+ <button id="info-cover-frame" class="info-art-frame" type="button" disabled title="See it full size">
137
163
  <img id="info-cover-img" alt="This game's cover art">
138
164
  <span id="info-cover-empty" class="empty">No cover art yet.<code>bitmagic cover</code></span>
139
- </div>
165
+ </button>
140
166
  <p id="info-cover-caption" class="caption"></p>
141
167
  </div>
142
168
  </div>
@@ -145,5 +171,9 @@ export const GAME_INFO_PANEL_HTML = ` <div id="info-overlay" class="bm-overlay"
145
171
  </div>
146
172
  </div>
147
173
  </div>
174
+ <div id="info-zoom" class="bm-overlay">
175
+ <img id="info-zoom-img" alt="">
176
+ <span class="hint">Click anywhere to go back.</span>
177
+ </div>
148
178
  `;
149
179
  //# sourceMappingURL=game-info-panel.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"game-info-panel.js","sourceRoot":"","sources":["../../src/editor/game-info-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+ElC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoCnC,CAAC"}
1
+ {"version":3,"file":"game-info-panel.js","sourceRoot":"","sources":["../../src/editor/game-info-panel.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,mBAAmB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGlC,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCnC,CAAC"}
@@ -379,6 +379,10 @@ ${ASSET_DETAIL_HTML}
379
379
  var infoCoverFrame = document.getElementById('info-cover-frame');
380
380
  var infoCoverImg = document.getElementById('info-cover-img');
381
381
  var infoCoverCaption = document.getElementById('info-cover-caption');
382
+ var infoZoom = document.getElementById('info-zoom');
383
+ var infoZoomImg = document.getElementById('info-zoom-img');
384
+ /** Which thumbnail opened the full-size layer, so closing it gives focus back. */
385
+ var zoomOrigin = null;
382
386
  /** The last publish the poll saw, or null until this project has had one. */
383
387
  var lastPublish = null;
384
388
  var recordToggle = document.getElementById('record');
@@ -1283,6 +1287,9 @@ ${ASSET_DETAIL_HTML}
1283
1287
  caption.textContent = '';
1284
1288
  img.removeAttribute('src');
1285
1289
  frame.classList.remove('has-image');
1290
+ // Dead exactly while there is nothing to open. Re-disabled on every fill, because the picture
1291
+ // that was there last time may have been cleared since.
1292
+ frame.disabled = true;
1286
1293
  if (!image) return;
1287
1294
  // Prefix tests rather than a regex: a slash inside one would have to be double-escaped to
1288
1295
  // survive this literal, and that is the exact trap shell-page.test.ts exists to catch.
@@ -1305,9 +1312,13 @@ ${ASSET_DETAIL_HTML}
1305
1312
  return;
1306
1313
  }
1307
1314
  frame.classList.remove('has-image');
1315
+ // The card is back to its "not yet" copy, so the control that opens a picture must go
1316
+ // dead with it rather than open an empty layer.
1317
+ frame.disabled = true;
1308
1318
  };
1309
1319
  img.src = src;
1310
1320
  frame.classList.add('has-image');
1321
+ frame.disabled = false;
1311
1322
  }
1312
1323
  var label = image.candidateId || (kind === 'cover' ? 'Cover image' : 'Reference image');
1313
1324
  if (remote) {
@@ -1339,6 +1350,33 @@ ${ASSET_DETAIL_HTML}
1339
1350
  infoToggle.focus();
1340
1351
  }
1341
1352
 
1353
+ /**
1354
+ * The picture at full size, on a layer above the dialog.
1355
+ *
1356
+ * The thumbnail is a couple of hundred pixels of an image generated at 1024, which is not enough
1357
+ * to judge the look it exists to fix. A layer rather than a new tab: the dev view is the window
1358
+ * the creator is already in, and the dialog is still underneath when they come back.
1359
+ *
1360
+ * Reuses the thumbnail's own resolved src, so whichever source that one settled on (the local
1361
+ * bytes, or the recorded URL after the fallback) is the one that opens \u2014 the browser has it
1362
+ * decoded already, and this cannot fail where the thumbnail succeeded.
1363
+ */
1364
+ function openZoom(frame, img) {
1365
+ if (!img.getAttribute('src')) return;
1366
+ zoomOrigin = frame;
1367
+ infoZoomImg.src = img.src;
1368
+ infoZoomImg.alt = img.alt;
1369
+ infoZoom.classList.add('show');
1370
+ }
1371
+
1372
+ function closeZoom() {
1373
+ infoZoom.classList.remove('show');
1374
+ infoZoomImg.removeAttribute('src');
1375
+ // Back to the thumbnail that opened it, which is where the eye already is.
1376
+ if (zoomOrigin && infoOverlay.classList.contains('show')) zoomOrigin.focus();
1377
+ zoomOrigin = null;
1378
+ }
1379
+
1342
1380
  /**
1343
1381
  * The Record panel — the Creator's Dev Tools recording settings, worn by this lane.
1344
1382
  *
@@ -2385,6 +2423,7 @@ ${ASSET_DETAIL_HTML}
2385
2423
  if (closeRecordingPanel()) return;
2386
2424
  if (closeMotionPanel()) return;
2387
2425
  if (publishOverlay.classList.contains('show')) { closePublish(); return; }
2426
+ if (infoZoom.classList.contains('show')) { closeZoom(); return; }
2388
2427
  if (infoOverlay.classList.contains('show')) { closeInfo(); return; }
2389
2428
  hqOverlay.classList.remove('show');
2390
2429
  });
@@ -2719,6 +2758,10 @@ ${ASSET_DETAIL_HTML}
2719
2758
  });
2720
2759
 
2721
2760
  infoToggle.addEventListener('click', function () { void openInfo(); });
2761
+ infoReferenceFrame.addEventListener('click', function () { openZoom(infoReferenceFrame, infoReferenceImg); });
2762
+ infoCoverFrame.addEventListener('click', function () { openZoom(infoCoverFrame, infoCoverImg); });
2763
+ // Anywhere: the whole layer is the picture and the way back out of it.
2764
+ infoZoom.addEventListener('click', closeZoom);
2722
2765
  infoClose.addEventListener('click', closeInfo);
2723
2766
  infoOverlay.addEventListener('click', function (event) {
2724
2767
  if (event.target === infoOverlay) closeInfo();
@@ -1 +1 @@
1
- {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"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EAAE;;;;;;;;0BAQF,IAAI,CAAC,SAAS,CAAC,qBAAqB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBAs/BvC,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAq1C1C,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmChB,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,OAAO,EACL,qBAAqB,EAAE,kBAAkB,GAC1C,MAAM,qDAAqD,CAAC;AAC7D,OAAO,EAAE,iBAAiB,EAAE,MAAM,+CAA+C,CAAC;AAClF,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC9E,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AACjF,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AACxG,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzF,OAAO,EAAE,eAAe,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AACrE,OAAO,EAAE,qBAAqB,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAE7F,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;GAMG;AACH,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;;;;;;;GAYG;AACH,SAAS,sBAAsB;IAC7B,OAAO;QACL,6BAA6B,IAAI,CAAC,SAAS,CAAC,iBAAiB,CAAC,GAAG;QACjE,iCAAiC,qBAAqB,CAAC,QAAQ,EAAE,GAAG;QACpE,8BAA8B,kBAAkB,CAAC,QAAQ,EAAE,GAAG;KAC/D,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;AACjB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAEnC,kGAAkG;AAClG,SAAS,UAAU,CAAC,KAAa;IAC/B,OAAO,KAAK;SACT,OAAO,CAAC,IAAI,EAAE,OAAO,CAAC;SACtB,OAAO,CAAC,IAAI,EAAE,QAAQ,CAAC;SACvB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC;SACrB,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;AAC3B,CAAC;AAmBD,oGAAoG;AACpG,MAAM,oBAAoB,GAAG,cAAc,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAElE,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,KAAK,KAAK,CAAC;IAC1C,MAAM,SAAS,GAAG,OAAO;QACvB,CAAC,CAAC,YAAY,OAAO,CAAC,UAAU,uDAAuD;QACvF,CAAC,CAAC,YAAY,OAAO,CAAC,UAAU,qCAAqC,CAAC;IACxE,MAAM,KAAK,GAAG,GAAG,OAAO,CAAC,QAAQ,kBAAkB,OAAO,CAAC,CAAC,CAAC,cAAc,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC;IACnF,OAAO;;;;SAIA,UAAU,CAAC,KAAK,CAAC;EACxB,kBAAkB;;EAElB,mBAAmB;EACnB,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA+HrB,gBAAgB;EAChB,mBAAmB;EACnB,iBAAiB;EACjB,mBAAmB;EACnB,gBAAgB;EAChB,eAAe;;;;;;yCAMwB,OAAO,CAAC,CAAC,CAAC,oBAAoB,CAAC,CAAC,CAAC,EAAE,YAAY,UAAU,CAAC,SAAS,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,WAAW,CAAC,CAAC,CAAC,SAAS;mJACA,UAAU,CAAC,OAAO,CAAC,QAAQ,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA4C7K,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;EAyBhB,oBAAoB;EACpB,kBAAkB;EAClB,oBAAoB;EACpB,iBAAiB;EACjB,iBAAiB;;;;mBAIA,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;2BACnB,gBAAgB;;IAEvC,sBAAsB,EAAE;;;;;;;;0BAQF,IAAI,CAAC,SAAS,CAAC,qBAAqB,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wBA4hCvC,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EA01C1C,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmChB,CAAC;AACF,CAAC"}
@@ -16,6 +16,13 @@
16
16
  * and everything else goes to `bitmagic forge`. The CLI cannot tell which model drives it, so the
17
17
  * gate is the agent's own call — stated once, plainly, where the planning skill sends it.
18
18
  *
19
+ * Steps 4-9 are shaped around two loops, because the first creator to use this took the whole
20
+ * chain — build, upload, verify, judge — round on every edit, and the costs are nothing alike: a
21
+ * Blender build, render and ray audit are seconds and local, while `assets add` uploads, `verify`
22
+ * type-checks and drives real Chrome, and `judge` spends sparks. So the geometry loop is named as
23
+ * the inner one, the engine handoff is gated on it being clean, and `review.py --lighting game`
24
+ * exists so even the lighting pass can stay inside it.
25
+ *
19
26
  * Backticks and `${` are escaped for the template literal; `project-files.test.ts` checks the
20
27
  * rendered frontmatter and that no escape leaks into the output.
21
28
  */
@@ -16,6 +16,13 @@
16
16
  * and everything else goes to `bitmagic forge`. The CLI cannot tell which model drives it, so the
17
17
  * gate is the agent's own call — stated once, plainly, where the planning skill sends it.
18
18
  *
19
+ * Steps 4-9 are shaped around two loops, because the first creator to use this took the whole
20
+ * chain — build, upload, verify, judge — round on every edit, and the costs are nothing alike: a
21
+ * Blender build, render and ray audit are seconds and local, while `assets add` uploads, `verify`
22
+ * type-checks and drives real Chrome, and `judge` spends sparks. So the geometry loop is named as
23
+ * the inner one, the engine handoff is gated on it being clean, and `review.py --lighting game`
24
+ * exists so even the lighting pass can stay inside it.
25
+ *
19
26
  * Backticks and `${` are escaped for the template literal; `project-files.test.ts` checks the
20
27
  * rendered frontmatter and that no escape leaks into the output.
21
28
  */
@@ -78,6 +85,10 @@ grep -qx 'build/' .gitignore || echo 'build/' >> .gitignore
78
85
  \`tools/blender-level/README.md\` lists what each file does. Level scripts go in \`tools/level/\`,
79
86
  outputs in \`build/level/\`.
80
87
 
88
+ Run that install line again on an existing project too. It overwrites, and \`bitmagic upgrade\`
89
+ re-renders this skill but NOT the scripts — so a flag named below that your copy does not
90
+ recognise means the copy is old, not that the skill is wrong.
91
+
81
92
  ## 2. Plan the place in words, then as a table
82
93
 
83
94
  Write the \`## Art direction\` of \`GAME-DESIGN.md\` first — \`bitmagic judge\` scores against it, and a
@@ -128,7 +139,34 @@ Rules that are not style:
128
139
  - **Doors are records, not meshes.** \`door()\` writes a \`DoorDefinition\`; the engine builds and
129
140
  animates the leaf. \`jamb=\` adds solid jambs and a header so the wall reads as a doorway.
130
141
 
131
- ## 4. Build
142
+ ## The two loops
143
+
144
+ Everything below runs in one of two loops, and knowing which one you are in is the difference
145
+ between building a level in a morning and building it in a day.
146
+
147
+ **The Blender loop** — edit \`tools/level/<name>.py\` → build → look → audit. Seconds each, all
148
+ local: nothing uploaded, no browser, no sparks. This is where the level is actually made, and you
149
+ should go round it tens of times.
150
+
151
+ **The engine handoff** — register the GLB → sync world.json → \`check\` → \`verify\` → \`judge\` →
152
+ walk it in \`bitmagic dev\`. \`assets add\` uploads to the CDN, \`verify\` type-checks the project and
153
+ drives a real Chrome through a settle loop, \`judge\` spends sparks and must not be run twice on
154
+ one milestone. Run it at the END of a pass — never after an edit.
155
+
156
+ **The gate: do not hand the level to the engine until the contact sheet has nothing wrong on it
157
+ and both audit modes exit 0.** Every build writes \`build/level/<name>.glb\` already; that is free.
158
+ Publishing it is what costs. A round trip through the engine to discover a hole in a floor is a
159
+ round trip the audit would have made for nothing, and the station lost a day to exactly that.
160
+
161
+ | The level is wrong like this | Found in |
162
+ |---|---|
163
+ | a hole in a floor, a doorway you cannot walk through, a route blocked by a prop, a room that is not sealed | the Blender loop — \`audit.py\`, both modes |
164
+ | z-fighting, a blank wall, a sign that reads backwards, a missing ceiling, stairs ending in air, a wedge of sky beside a junction, the wrong footprint | the Blender loop — the contact sheet |
165
+ | the level washed out or black, an empty published game, doors animating inside walls, no NPCs, the framerate | the engine handoff — nothing in Blender can see these |
166
+
167
+ ## The Blender loop
168
+
169
+ ### 4. Build
132
170
 
133
171
  \`\`\`
134
172
  "$BLENDER" -b --python tools/level/<name>.py
@@ -140,7 +178,7 @@ line to keep for step 7. Outputs: \`build/level/<name>.glb\` (one draw call per
140
178
  or geometry error prints a Python traceback naming the helper and the object — fix and rebuild;
141
179
  builds take seconds. \`python3 tools/level/<name>.py --dry-run\` gives the counts without Blender.
142
180
 
143
- ## 5. Render and look
181
+ ### 5. Render and look
144
182
 
145
183
  \`\`\`
146
184
  "$BLENDER" -b --python tools/blender-level/review.py -- build/level/<name>.blend build/level/<name>.mesh-level.json build/level/review/
@@ -152,7 +190,23 @@ is missing, stairs that end in the air, a sign that reads backwards, two surface
152
190
  one another. Fix the script, rebuild, re-render. Do not go on to the audit while the sheet is
153
191
  wrong; the audit finds holes, not ugliness.
154
192
 
155
- ## 6. Audit
193
+ The fixed set is what you read first. After that the flags turn it into a camera you can aim, so
194
+ a second look costs one render rather than a trip through the engine:
195
+
196
+ \`\`\`
197
+ … review.py -- <blend> <level json> <out dir> --shot 'landmark:spawn -> volume:hall' --only # just this frame
198
+ … review.py -- <blend> <level json> <out dir> --shot 'door:north door -> landmark:altar' --fov 50
199
+ … review.py -- <blend> <level json> <out dir> --lighting game # the lamps and spots you declared
200
+ \`\`\`
201
+
202
+ \`--shot 'FROM -> TO'\` takes \`landmark:<name>\`, \`volume:<name>\`, \`door:<id>\` or a raw \`x,y,z\`, and
203
+ repeats; \`--only\` renders those instead of the standard set. \`--lighting game\` swaps the review
204
+ fill for the engine's \`interior\` preset and the JSON's own \`lamp()\` / \`spot()\` records, which is
205
+ how you judge the lighting pass without a verify — dark under \`--lighting game\` is dark in the
206
+ game. Keep the default \`--lighting review\` while you are judging geometry: it is lit so that
207
+ nothing can hide.
208
+
209
+ ### 6. Audit
156
210
 
157
211
  \`\`\`
158
212
  "$BLENDER" -b --python tools/blender-level/audit.py -- build/level/<name>.mesh-level.json # doors open: floors, doorways, routes
@@ -166,7 +220,16 @@ on the line between two rooms, a step that ends short); a failed \`sealed_envelo
166
220
  roof or wall. \`coplanar_overlap\` is a warning: two faces on one plane, which flicker. Never
167
221
  answer a failure by removing the volume or the link that found it.
168
222
 
169
- ## 7. Register the GLB, sync world.json, write the game code
223
+ A failure sends you back to step 4, not on to step 7. Both modes exiting 0 with a clean contact
224
+ sheet is the whole gate; nothing below can be trusted until you have it.
225
+
226
+ ## The engine handoff — once per pass
227
+
228
+ Steps 7 and 8 are the expensive half: an upload, a type-check, a real browser. Do them when the
229
+ Blender loop has nothing left to say, and then do all of them — a partial handoff is what makes
230
+ \`verify\` pass on stale files.
231
+
232
+ ### 7. Register the GLB, sync world.json, write the game code
170
233
 
171
234
  \`\`\`
172
235
  bitmagic assets add build/level/<name>.glb --name <name> --keep-glb --asset-id level-<name> --height <native height> # the REGISTER line from step 4
@@ -197,7 +260,7 @@ this.playerController.configureFallRescue({ ...DEFAULT_FALL_RESCUE_OPTIONS, kill
197
260
  (\`npc.spawn(x, z, y)\` — pass the storey's Y for interiors), \`volumesAt(position)\` for room logic,
198
261
  the lighting modes, and \`samples/mesh-level-setup.ts\`.
199
262
 
200
- ## 8. Check, verify, walk it
263
+ ### 8. Check, verify, walk it
201
264
 
202
265
  \`bitmagic check\`, then \`bitmagic verify\`: the console must show one \`[MeshLevel] loaded …\` line
203
266
  with the collider and light counts you expect, and no \`404\` for the GLB. Then walk it in the
@@ -205,16 +268,24 @@ with the collider and light counts you expect, and no \`404\` for the GLB. Then
205
268
  proves geometry, not that the place is good to be in. \`bitmagic verify --platform mobile\` too if
206
269
  phones matter.
207
270
 
271
+ All three of step 7 again on every handoff — register with the SAME \`--asset-id\` (the entry is
272
+ replaced), re-run \`sync_world.py\`, re-copy the JSON. Skip one and \`verify\` grades the previous
273
+ build: the collider count in \`[MeshLevel] loaded …\` is how you catch it.
274
+
208
275
  ## 9. Judge, then iterate in passes
209
276
 
210
277
  \`bitmagic judge\` scores the verify screenshot against your art direction. Improve the level in
211
- passes, each one a full loop (edit script → build → review → audit → sync → verify):
278
+ passes — and a pass is **many turns of the Blender loop followed by one handoff**, not a handoff
279
+ per edit:
212
280
 
213
- 1. **Structure** — rooms, doors, routes, stairs, the spawn. Audit clean, walkable.
281
+ 1. **Structure** — rooms, doors, routes, stairs, the spawn. Audit clean in both modes; the one
282
+ verify is for walking it in \`bitmagic dev\`, which is the only thing Blender cannot tell you.
214
283
  2. **Detail** — cladding, casings, coffers, deck plates, signage, props. All \`decor()\` unless the
215
- player should bump into it; keep every travel clearance.
284
+ player should bump into it; keep every travel clearance. The sheet and the audit carry this
285
+ pass almost alone; the one verify is to confirm nothing regressed.
216
286
  3. **Surfaces and light** — materials, emissive strips, warm and cool lamps, a spot or two with
217
- shadows. Look at it in the real game, not only in the render.
287
+ shadows. Render with \`--lighting game\` (step 5) until the rooms read, then verify and judge
288
+ once against the real thing.
218
289
 
219
290
  \`references/level-design-passes.md\` says what each pass contains and when to stop.
220
291
 
@@ -328,6 +399,10 @@ the art, because decoration is exactly what must not be solid. The renders show
328
399
  .blend**, so a level can pass the audit and look wrong, or look right and fail. Do both, in that
329
400
  order: look first (cheap, catches design errors), audit second (catches the errors you cannot see).
330
401
 
402
+ Together they are the Blender loop, and they are the whole reason the engine handoff can wait:
403
+ between them they answer every geometric question about the level for the cost of a few seconds
404
+ and no upload. Go round them until neither has anything to say, and only then register the GLB.
405
+
331
406
  ## The checks
332
407
 
333
408
  | Check | Mode | What it fires | Failure means | Usual fix |
@@ -368,6 +443,28 @@ no volumes passes every check and holds no claim.
368
443
  \`volume-<name>\` (inside every room, from one end looking along it). Then \`contact-sheet.png\`,
369
444
  four per row in the order the log prints.
370
445
 
446
+ ## Aiming the camera, and the two lightings
447
+
448
+ The standard set is what you read first. After that, ask for what you want to see:
449
+
450
+ | Flag | Does |
451
+ |---|---|
452
+ | \`--shot 'FROM -> TO'\` | one extra frame. Both ends are \`landmark:<name>\`, \`volume:<name>\`, \`door:<id>\` or a raw \`x,y,z\` (game coordinates). Repeats; each frame is named \`shot-1\`, \`shot-2\`, … |
453
+ | \`--shot 'FROM'\` | from that point, looking at the level's centre — enough for "what does it look like from the spawn" |
454
+ | \`--only\` | render just the \`--shot\`s and skip the standard set and the sheet: one render, one look |
455
+ | \`--fov <deg>\` | horizontal field of view for the perspective shots; default 24 mm ≈ 74° |
456
+ | \`--lighting review\` | default. A camera-mounted fill and a sun, so nothing can hide in shadow |
457
+ | \`--lighting game\` | the engine's \`interior\` preset — the sun at 8%, the hemisphere at 0.65, your \`lamp()\`s and \`spot()\`s as real lights, emissive materials glowing at their \`glow\` |
458
+
459
+ Judge geometry under \`--lighting review\`; judge the lighting pass under \`--lighting game\`. The
460
+ second is close enough to the engine that a room which is black there is black in the game, so
461
+ pass 3 no longer needs a verify to find an unlit corner — only to confirm the fixed one.
462
+
463
+ Where \`--lighting game\` and the engine still differ: EEVEE is not three.js, so exposure and
464
+ falloff are approximate, and the engine pools point lights (a room with eight lamps gets four of
465
+ them in game, all eight in the render). Treat "too dark" and "too bright" as real; treat an exact
466
+ colour match as a job for the verify screenshot.
467
+
371
468
  What to look for, frame by frame:
372
469
 
373
470
  - **overview** — the footprint matches the plan; nothing floats outside the walls; corridors meet
@@ -378,24 +475,24 @@ What to look for, frame by frame:
378
475
  - **stairs** — treads reach the landing; the rail follows the slope; nothing pokes through a step.
379
476
  - **volumes** — a ceiling; walls meeting at the corners; props sitting on the floor, not in it.
380
477
 
381
- The renderer adds its own fill light so interiors are legible; it shows geometry, not the game's
382
- lighting. Judge the lighting in \`bitmagic verify\`'s screenshot and in the dev browser.
383
-
384
478
  ## When a render is right and the game is wrong
385
479
 
386
- The two are the same geometry only if the GLB you registered is the one you just built. After
387
- every rebuild: register again (same \`--asset-id\`, the entry is replaced), re-run \`sync_world.py\`,
388
- copy the JSON into \`src/work/\`, and check \`[MeshLevel] loaded\` in the verify console reports the
389
- new collider count. A verify that passes on stale files is the trap the station fell into three
390
- times.
480
+ The two are the same geometry only if the GLB you registered is the one you just built. That is
481
+ the whole content of the engine handoff, and it is all-or-nothing: register again (same
482
+ \`--asset-id\`, the entry is replaced), re-run \`sync_world.py\`, copy the JSON into \`src/work/\`, and
483
+ check \`[MeshLevel] loaded\` in the verify console reports the new collider count. A verify that
484
+ passes on stale files is the trap the station fell into three times — and the reason to hand off
485
+ rarely and completely rather than often and in pieces.
391
486
  `;
392
487
  }
393
488
  export function renderBlenderPassesReference() {
394
489
  return `# Level design in passes
395
490
 
396
491
  A level that is only walls and floors is walkable and dead. The station that taught this skill
397
- went from boxes to a place people wanted to screenshot in four passes, each a full loop of edit →
398
- build → review → audit → sync → verify. The passes, what goes in each, and when to stop.
492
+ went from boxes to a place people wanted to screenshot in four passes. A pass is many turns of the
493
+ Blender loop — edit → build → review → audit — ended by one engine handoff; each pass's "Stop
494
+ when" below is the condition for that handoff, not a checkpoint you take on the way. The passes,
495
+ what goes in each, and when to stop.
399
496
 
400
497
  ## Pass 1 — structure
401
498
 
@@ -441,8 +538,13 @@ clean (detail is the pass that breaks clearances — a casing 20 cm into a doorw
441
538
  - **The engine's interior preset dims the sun for you**; a level that looks washed out in the game
442
539
  after looking fine in the render has an \`exterior\` mode set, or its own sun in \`lightingConfig\`.
443
540
 
444
- Stop when: \`bitmagic judge\` no longer names lighting or flat surfaces, and the level reads the same
445
- in the verify screenshot as in the reference image.
541
+ This is the pass where the default review render lies to you — its fill light makes every room
542
+ legible whatever your lamps do. Iterate under \`review.py --lighting game\`, which runs that same
543
+ interior preset and your own \`lamp()\`s and \`spot()\`s, and a dark room shows up dark.
544
+
545
+ Stop when: nothing is unlit or blown out under \`--lighting game\`, then hand off — \`bitmagic judge\`
546
+ no longer names lighting or flat surfaces, and the level reads the same in the verify screenshot
547
+ as in the reference image.
446
548
 
447
549
  ## Pass 4 — the reference image
448
550
 
@@ -466,6 +568,12 @@ The engine side is \`MeshLevel\` — \`engine/agent-docs/mesh-level.md\` is the
466
568
  \`engine/agent-docs/samples/mesh-level-setup.ts\` the compile-checked sample. This page is the
467
569
  seam: what the build wrote, what each command does with it, and the four ways it goes wrong.
468
570
 
571
+ **This is the outer loop — run it once per pass, and run all of it.** An upload, a type-check and
572
+ a real browser, against a level the contact sheet and both audit modes have already cleared. Every
573
+ question you can answer in Blender, answer in Blender; what is left here is what only the running
574
+ game knows: how it feels to walk, how it is lit on a phone, and whether the published bundle can
575
+ fetch what it needs.
576
+
469
577
  ## The files the build wrote
470
578
 
471
579
  | File | Goes | Carries |
@@ -1 +1 @@
1
- {"version":3,"file":"blender-level-skill-files.js","sourceRoot":"","sources":["../../src/scaffold/blender-level-skill-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,MAAM,UAAU,wBAAwB;IACtC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8MR,CAAC;AACF,CAAC;AAED,MAAM,UAAU,iCAAiC;IAC/C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyFR,CAAC;AACF,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmER,CAAC;AACF,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkER,CAAC;AACF,CAAC;AAED,MAAM,UAAU,mCAAmC;IACjD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGR,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"blender-level-skill-files.js","sourceRoot":"","sources":["../../src/scaffold/blender-level-skill-files.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AAEH,MAAM,UAAU,wBAAwB;IACtC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8QR,CAAC;AACF,CAAC;AAED,MAAM,UAAU,iCAAiC;IAC/C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyFR,CAAC;AACF,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2FR,CAAC;AACF,CAAC;AAED,MAAM,UAAU,4BAA4B;IAC1C,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyER,CAAC;AACF,CAAC;AAED,MAAM,UAAU,mCAAmC;IACjD,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+GR,CAAC;AACF,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitmagic/cli",
3
- "version": "0.1.64-dev.14",
3
+ "version": "0.1.64-dev.16",
4
4
  "description": "Build Bitmagic games from your own agent tool",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "Bitmagic Oy",
@@ -25,9 +25,9 @@
25
25
  "playwright-core": "^1.59.1",
26
26
  "tar": "^7.4.3",
27
27
  "ws": "^8.21.3",
28
- "@bitmagic/animation-forger": "0.1.19-dev.14",
29
- "@bitmagic/asset-core": "0.2.19-dev.14",
30
- "@bitmagic/world-forger": "0.1.24-dev.14"
28
+ "@bitmagic/asset-core": "0.2.19-dev.16",
29
+ "@bitmagic/world-forger": "0.1.24-dev.16",
30
+ "@bitmagic/animation-forger": "0.1.19-dev.16"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@eslint/js": "^9.38.0",