@bitmagic/cli 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +53 -19
  2. package/dist/cli.d.ts +10 -6
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dev.d.ts +5 -1
  6. package/dist/commands/dev.js +82 -13
  7. package/dist/commands/dev.js.map +1 -1
  8. package/dist/commands/generate.js +3 -3
  9. package/dist/commands/generate.js.map +1 -1
  10. package/dist/commands/reload.d.ts +10 -0
  11. package/dist/commands/reload.js +82 -0
  12. package/dist/commands/reload.js.map +1 -0
  13. package/dist/commands/upgrade.js +6 -0
  14. package/dist/commands/upgrade.js.map +1 -1
  15. package/dist/editor/journal.js +2 -2
  16. package/dist/editor/reload-bus.d.ts +38 -0
  17. package/dist/editor/reload-bus.js +66 -0
  18. package/dist/editor/reload-bus.js.map +1 -0
  19. package/dist/editor/server.d.ts +7 -0
  20. package/dist/editor/server.js +51 -9
  21. package/dist/editor/server.js.map +1 -1
  22. package/dist/editor/shell-page.d.ts +24 -5
  23. package/dist/editor/shell-page.js +295 -36
  24. package/dist/editor/shell-page.js.map +1 -1
  25. package/dist/editor/watch.d.ts +101 -0
  26. package/dist/editor/watch.js +228 -0
  27. package/dist/editor/watch.js.map +1 -0
  28. package/dist/local-port.d.ts +10 -0
  29. package/dist/local-port.js +23 -0
  30. package/dist/local-port.js.map +1 -1
  31. package/dist/project/dev-handle.d.ts +14 -0
  32. package/dist/project/dev-handle.js +53 -0
  33. package/dist/project/dev-handle.js.map +1 -0
  34. package/dist/scaffold/claude-settings.d.ts +41 -0
  35. package/dist/scaffold/claude-settings.js +98 -0
  36. package/dist/scaffold/claude-settings.js.map +1 -0
  37. package/dist/scaffold/project-files.js +20 -8
  38. package/dist/scaffold/project-files.js.map +1 -1
  39. package/dist/scaffold/project.js +5 -0
  40. package/dist/scaffold/project.js.map +1 -1
  41. package/dist/scaffold/upgrade-project.d.ts +6 -0
  42. package/dist/scaffold/upgrade-project.js +19 -1
  43. package/dist/scaffold/upgrade-project.js.map +1 -1
  44. package/dist/verify/browser.js +3 -1
  45. package/dist/verify/browser.js.map +1 -1
  46. package/package.json +1 -1
  47. package/dist/commands/edit.d.ts +0 -10
  48. package/dist/commands/edit.js +0 -148
  49. package/dist/commands/edit.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The editor shell: the page that plays the Creator's role for a CLI-lane project.
2
+ * The dev view: the page that plays the Creator's role for a CLI-lane project.
3
3
  *
4
4
  * The whole feature rests on one fact — the visual editor is ALREADY in every scaffolded project.
5
5
  * `VENDORED_DIRS` in `scaffold/project.ts` ships `engine/editor/` and `engine/debug/`, and
@@ -12,6 +12,25 @@
12
12
  * plain `tsc`, which copies no static files. `smoke/harness.ts` and `scaffold/project-files.ts`
13
13
  * both do the same.
14
14
  *
15
+ * ── One iframe, two tabs ─────────────────────────────────────────────────────────────────────
16
+ *
17
+ * **Game** and **Editor** are the same loaded world in two engine modes, not two pages. A second
18
+ * iframe would mean a second WebGL context and a second copy of the world on a creator's laptop,
19
+ * and switching would cost a full reload each way — which defeats the point of having both in one
20
+ * window. So a tab switch is three postMessages, and play state survives a trip to the editor.
21
+ *
22
+ * The exact sequences are in `applyTab()`, and each line of them is load-bearing:
23
+ * - `SET_EDITOR_MODE {enabled:false}` alone does NOT give you the game back. It restores the
24
+ * camera and player controls (`EditorManager.disableEditorMode`) but leaves the HUD hidden —
25
+ * hiding it is `EditorTabHandler`'s job, keyed on the *tab*, so undoing it needs
26
+ * `SET_EDITOR_TAB {tab:'prompt'}`.
27
+ * - `LOAD_GAME {skipMenu:true}` leaves the engine in READY, not PLAYING (`showEditorPreview`).
28
+ * The Game tab therefore has to `START_GAME` the first time; only afterwards is unpausing the
29
+ * right move.
30
+ * - Pointer lock cannot be taken from here — a parent-frame message grants the iframe no user
31
+ * activation. `enterPlayingMode` knows that and shows its own overlay, so the creator's first
32
+ * click inside the game captures the mouse. Same as the web Creator.
33
+ *
15
34
  * ── Four things that fail silently if changed ────────────────────────────────────────────────
16
35
  *
17
36
  * 1. `?source=creator` on the iframe URL. Without it `isCreatorMode` is false
@@ -20,10 +39,10 @@
20
39
  * 2. `GAME_TEMPLATE_READY` must arrive before `LOAD_GAME` is posted. The engine registers its
21
40
  * listener only after `await initI18n()`, and a `LOAD_GAME` landing before that is DROPPED,
22
41
  * not queued — the symptom is a game that never loads, pointing at the wrong culprit.
23
- * 3. `REQUEST_ASSETS` / `ADD_OBJECT` / `MARK_OBJECT_MODIFIED` use a FLAT envelope
24
- * (`{ type, assets }`), not the `{ type, data }` one the rest of the protocol uses. That is
25
- * the Creator's existing shape (`useIframeMessages.ts:1149`, `:332`) and the engine reads the
26
- * fields off the message directly.
42
+ * 3. `REQUEST_ASSETS` / `ADD_OBJECT` / `MARK_OBJECT_MODIFIED` / `GAME_STATE_CHANGED` use a FLAT
43
+ * envelope (`{ type, assets }`), not the `{ type, data }` one the rest of the protocol uses.
44
+ * That is the Creator's existing shape (`useIframeMessages.ts:1149`, `:332`) and the engine
45
+ * reads the fields off the message directly.
27
46
  * 4. The autosave poll, rather than an event. `TransformControlsManager`'s mouseUp reaches
28
47
  * `EditorManager.commitTransformChange()`, which only mutates a `Set` — the engine posts
29
48
  * nothing. Polling `CHECK_SCENE_CHANGES` is what the Creator does too, just on tab switch
@@ -31,6 +50,14 @@
31
50
  */
32
51
  /** How often the shell asks the engine whether anything changed. A `Set` read; effectively free. */
33
52
  const POLL_INTERVAL_MS = 500;
53
+ /**
54
+ * How recently a scene save must have landed for the creator to count as "still working".
55
+ *
56
+ * An auto-reload during a gizmo drag throws the drag away, and the shell cannot see a drag: the
57
+ * iframe is cross-origin, so there is no mousedown to watch. The last save is the closest visible
58
+ * proxy for a hand on the mouse, and the cost of being wrong is only a banner instead of a reload.
59
+ */
60
+ const ACTIVE_EDIT_WINDOW_MS = 3000;
34
61
  /** Attribute-safe. `gameId` comes from `bitmagic.json`, but it lands inside an HTML attribute. */
35
62
  function escapeAttr(value) {
36
63
  return value
@@ -45,11 +72,11 @@ export function renderEditorShell(options) {
45
72
  <html lang="en">
46
73
  <head>
47
74
  <meta charset="utf-8">
48
- <title>bitmagic edit — ${escapeAttr(options.gameId)}</title>
75
+ <title>bitmagic dev — ${escapeAttr(options.gameId)}</title>
49
76
  <style>
50
77
  :root { color-scheme: dark; }
51
78
  * { box-sizing: border-box; }
52
- /* Flex column rather than calc() heights: the banners come and go, and two of them at once
79
+ /* Flex column rather than calc() heights: the banners come and go, and three of them at once
53
80
  must not push the viewport into a scrollbar. */
54
81
  body { margin: 0; height: 100vh; display: flex; flex-direction: column;
55
82
  background: #101014; color: #e6e6ea; font: 13px/1.45 ui-sans-serif, system-ui, sans-serif; }
@@ -57,18 +84,29 @@ export function renderEditorShell(options) {
57
84
  background: #17171d; border-bottom: 1px solid #26262e; }
58
85
  #bar .id { color: #8a8a99; font-family: ui-monospace, monospace; }
59
86
  #bar .spacer { flex: 1; }
87
+ #tabs { display: flex; gap: 2px; background: #101014; border: 1px solid #26262e; border-radius: 6px;
88
+ padding: 2px; }
89
+ .tab { background: transparent; border: 0; border-radius: 4px; color: #9a9aab; padding: 3px 14px;
90
+ font: inherit; cursor: pointer; }
91
+ .tab:hover { color: #e6e6ea; background: #22222a; }
92
+ .tab.active { background: #3352c4; color: #fff; }
60
93
  #status { display: flex; align-items: center; gap: 6px; }
61
94
  #dot { width: 8px; height: 8px; border-radius: 50%; background: #4a4a57; }
62
95
  #dot.saving { background: #d9a441; }
63
96
  #dot.saved { background: #4caf72; }
64
97
  #dot.error { background: #e0564f; }
98
+ #auto { display: flex; align-items: center; gap: 5px; color: #8a8a99; cursor: pointer; }
99
+ #auto input { margin: 0; }
65
100
  button { background: #26262e; color: #e6e6ea; border: 1px solid #34343f; border-radius: 5px;
66
101
  padding: 4px 10px; font: inherit; cursor: pointer; }
67
102
  button:hover { background: #30303a; }
103
+ #tabs .tab:hover { background: #22222a; }
104
+ #tabs .tab.active:hover { background: #3352c4; }
68
105
  .banner { display: none; align-items: center; gap: 12px; flex: none; padding: 8px 12px;
69
106
  background: #3a2c12; border-bottom: 1px solid #574018; color: #f0d9a8; }
70
107
  .banner.show { display: flex; }
71
- #terrain-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
108
+ #terrain-banner, #build-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
109
+ #build-banner code { font-family: ui-monospace, monospace; }
72
110
  #frame { display: block; flex: 1; width: 100%; border: 0; min-height: 0; }
73
111
  #hq-overlay { display: none; position: fixed; inset: 0; background: rgba(8,8,12,.72);
74
112
  align-items: center; justify-content: center; z-index: 10; }
@@ -89,19 +127,30 @@ export function renderEditorShell(options) {
89
127
  </head>
90
128
  <body>
91
129
  <div id="bar">
92
- <strong>bitmagic edit</strong>
130
+ <strong>bitmagic dev</strong>
93
131
  <span class="id">${escapeAttr(options.gameId)}</span>
132
+ <div id="tabs">
133
+ <button class="tab active" type="button" data-tab="game">Game</button>
134
+ <button class="tab" type="button" data-tab="editor">Editor</button>
135
+ </div>
94
136
  <span class="spacer"></span>
137
+ <label id="auto" title="Reload the game when the project changes on disk">
138
+ <input id="auto-toggle" type="checkbox" checked> Auto-reload
139
+ </label>
95
140
  <span id="status"><span id="dot"></span><span id="status-text">Loading…</span></span>
96
141
  <button id="reload" type="button">Reload</button>
97
142
  </div>
143
+ <div id="build-banner" class="banner">
144
+ <span id="build-message"></span>
145
+ <span>The game still shows the last build that compiled.</span>
146
+ </div>
98
147
  <div id="banner" class="banner">
99
148
  <span>src/work/world.json changed on disk — this scene is out of date.</span>
100
149
  <button id="banner-reload" type="button">Reload scene</button>
101
150
  </div>
102
151
  <div id="terrain-banner" class="banner">
103
152
  <span><strong>Terrain edits are not saved yet.</strong>
104
- Sculpting the ground needs an asset upload that <code>bitmagic edit</code> does not do — these
153
+ Sculpting the ground needs an asset upload that <code>bitmagic dev</code> does not do — these
105
154
  voxel changes will be lost. Discard them, or press Cancel in the terrain toolbar.</span>
106
155
  <button id="terrain-discard" type="button">Discard terrain edits</button>
107
156
  </div>
@@ -127,23 +176,51 @@ export function renderEditorShell(options) {
127
176
  'use strict';
128
177
  var GAME_URL = ${JSON.stringify(gameUrl)};
129
178
  var POLL_MS = ${POLL_INTERVAL_MS};
179
+ var ACTIVE_EDIT_MS = ${ACTIVE_EDIT_WINDOW_MS};
180
+ var TAB_KEY = 'bitmagic.dev.tab';
181
+ var AUTO_KEY = 'bitmagic.dev.autoReload';
130
182
 
131
183
  var frame = document.getElementById('frame');
132
184
  var dot = document.getElementById('dot');
133
185
  var statusText = document.getElementById('status-text');
134
186
  var banner = document.getElementById('banner');
135
187
  var terrainBanner = document.getElementById('terrain-banner');
188
+ var buildBanner = document.getElementById('build-banner');
189
+ var autoToggle = document.getElementById('auto-toggle');
136
190
 
137
191
  var loaded = false;
138
192
  var saving = false;
139
193
  var markersDirty = false;
140
194
  var lastKnownMtime = 0;
141
195
  var pollTimer = null;
196
+ // Retires the running poll on its own schedule; see stopLoop(). Distinct from 'generation',
197
+ // which retires a boot — a tab switch changes what the loop does without reloading anything.
198
+ var loopToken = 0;
142
199
  var waiters = [];
143
- // Bumped by reload(). Every async step checks it, so a reload triggered mid-boot cannot leave
200
+ // Bumped by start(). Every async step checks it, so a reload triggered mid-boot cannot leave
144
201
  // two polling loops running against one iframe.
145
202
  var generation = 0;
146
203
 
204
+ // The engine's own view of itself, from GAME_STATE_CHANGED. The Game tab needs it to tell
205
+ // "never started" (START_GAME) from "paused for the editor" (unpause) — see applyTab.
206
+ var gameState = '';
207
+ var activeTab = 'game';
208
+ var autoReload = true;
209
+ // When a scene save last landed. See ACTIVE_EDIT_WINDOW_MS.
210
+ var lastLocalEditAt = 0;
211
+
212
+ // localStorage is unavailable in some privacy modes, and losing a preference must not lose the
213
+ // page. Defaults below are the ones a first-time creator gets anyway.
214
+ function readPref(key, fallback) {
215
+ try {
216
+ var stored = window.localStorage.getItem(key);
217
+ return stored === null ? fallback : stored;
218
+ } catch (error) { return fallback; }
219
+ }
220
+ function writePref(key, value) {
221
+ try { window.localStorage.setItem(key, value); } catch (error) { /* see readPref */ }
222
+ }
223
+
147
224
  function setStatus(kind, text) {
148
225
  dot.className = kind || '';
149
226
  statusText.textContent = text;
@@ -184,6 +261,21 @@ export function renderEditorShell(options) {
184
261
  }
185
262
 
186
263
  switch (message.type) {
264
+ case 'GAME_STATE_CHANGED':
265
+ gameState = message.state || '';
266
+ if (activeTab === 'game' && loaded) {
267
+ setStatus(gameState === 'playing' ? 'saved' : '', gameState === 'playing' ? 'Playing' : 'Ready');
268
+ }
269
+ break;
270
+
271
+ case 'TAB_SWITCH_BLOCKED':
272
+ // The engine refuses to leave a tab with unsaved terrain changes
273
+ // (GameRuntimeController.canSwitchToEditorTab). applyTab checks for that first, so this is
274
+ // the backstop for a race — put the creator back where the engine actually is.
275
+ selectTab('editor', { fromEngine: true });
276
+ terrainBanner.classList.add('show');
277
+ break;
278
+
187
279
  case 'REQUEST_ASSETS':
188
280
  // Re-read rather than serving the copy captured at boot: an agent may have generated an
189
281
  // asset since this page loaded, and a stale list is an empty asset palette with no
@@ -332,10 +424,12 @@ export function renderEditorShell(options) {
332
424
  setStatus('error', 'Generation failed');
333
425
  return;
334
426
  }
335
- showHqNote(job.message + ' Reload to see it in the scene.', '');
427
+ showHqNote(job.message + ' Reloading to show it.', '');
336
428
  setStatus('saved', 'Generated');
337
429
  // The asset changed under the running engine, so the open scene is showing the old mesh.
338
- banner.classList.add('show');
430
+ // The generation wrote world.json through the sidecar, which suppresses its own writes from
431
+ // the watcher, so this is the only thing that will bring the new mesh in.
432
+ if (autoReload) start(); else banner.classList.add('show');
339
433
  };
340
434
  void tick();
341
435
  }
@@ -345,9 +439,82 @@ export function renderEditorShell(options) {
345
439
  void confirmHq();
346
440
  });
347
441
 
442
+ /**
443
+ * Put the engine into the mode the given tab means.
444
+ *
445
+ * Order is not cosmetic in either direction — see the "One iframe, two tabs" note in this file's
446
+ * header for what each message is actually undoing.
447
+ */
448
+ function applyTab(tab) {
449
+ if (tab === 'editor') {
450
+ // Mirrors the Creator's enterEditorMode(): pause, turn the editor on (this is what unpacks
451
+ // InstancedMeshes so clicks can hit an individual instance), then configure the free camera
452
+ // and hide the HUD.
453
+ post('SET_PAUSE', { paused: true });
454
+ post('SET_EDITOR_MODE', { enabled: true });
455
+ post('SET_EDITOR_TAB', { tab: 'scene' });
456
+ setStatus('saved', 'Ready');
457
+ return;
458
+ }
459
+
460
+ terrainBanner.classList.remove('show');
461
+ var resumable = gameState === 'playing' || gameState === 'paused';
462
+ if (resumable) {
463
+ // Unpause BEFORE the tab message: SET_EDITOR_TAB 'prompt' enables player controls only when
464
+ // the engine already considers itself playing, so the other order leaves a running game the
465
+ // creator cannot steer.
466
+ post('SET_PAUSE', { paused: false });
467
+ post('SET_EDITOR_MODE', { enabled: false });
468
+ post('SET_EDITOR_TAB', { tab: 'prompt' });
469
+ } else {
470
+ // Never started: LOAD_GAME with skipMenu leaves the engine in READY, so there is nothing to
471
+ // unpause and the Play button is what we are standing in for.
472
+ post('SET_EDITOR_MODE', { enabled: false });
473
+ post('SET_EDITOR_TAB', { tab: 'prompt' });
474
+ post('START_GAME');
475
+ }
476
+ setStatus('saved', 'Playing');
477
+ // Without this the first keypress goes to the shell, not the game.
478
+ frame.focus();
479
+ }
480
+
481
+ /**
482
+ * Switch tabs, saving anything the editor was holding first.
483
+ *
484
+ * The terrain check is a precondition rather than a reaction: the engine refuses to leave a tab
485
+ * with unsaved voxel edits, and finding that out from TAB_SWITCH_BLOCKED means having already
486
+ * sent SET_EDITOR_MODE — leaving the editor half off, with no HUD and no gizmo.
487
+ */
488
+ async function selectTab(tab, options) {
489
+ var fromEngine = options && options.fromEngine === true;
490
+ if (tab === activeTab && !fromEngine) return;
491
+
492
+ if (activeTab === 'editor' && tab === 'game' && loaded && !fromEngine) {
493
+ await flush();
494
+ post('CHECK_TERRAIN_CHANGES');
495
+ var terrain = await await_('TERRAIN_HAS_CHANGES', 2000);
496
+ if (terrain && terrain.hasChanges === true) {
497
+ terrainBanner.classList.add('show');
498
+ return;
499
+ }
500
+ }
501
+
502
+ activeTab = tab;
503
+ writePref(TAB_KEY, tab);
504
+ var buttons = document.querySelectorAll('.tab');
505
+ for (var i = 0; i < buttons.length; i++) {
506
+ buttons[i].classList.toggle('active', buttons[i].getAttribute('data-tab') === tab);
507
+ }
508
+ if (!loaded) return;
509
+ applyTab(tab);
510
+ // One loop for both tabs; the work inside it is what differs (see runLoop()).
511
+ runLoop();
512
+ }
513
+
348
514
  async function boot(gen) {
349
515
  loaded = false;
350
516
  markersDirty = false;
517
+ gameState = '';
351
518
  banner.classList.remove('show');
352
519
  terrainBanner.classList.remove('show');
353
520
  setStatus('', 'Loading…');
@@ -372,7 +539,15 @@ export function renderEditorShell(options) {
372
539
  setStatus('error', 'The game never finished booting — try "bitmagic verify"');
373
540
  return;
374
541
  }
375
- post('LOAD_GAME', { gameId: project.gameId, gameData: project.gameData, skipMenu: true });
542
+ // currentTab is honoured by the engine's own LOAD_GAME handler, which sets the camera and
543
+ // player controls for it — so booting straight into the right mode rather than correcting it
544
+ // afterwards.
545
+ post('LOAD_GAME', {
546
+ gameId: project.gameId,
547
+ gameData: project.gameData,
548
+ skipMenu: true,
549
+ currentTab: activeTab === 'editor' ? 'scene' : 'prompt'
550
+ });
376
551
  var started = await gameLoaded;
377
552
  if (gen !== generation) return;
378
553
  if (!started) {
@@ -380,15 +555,8 @@ export function renderEditorShell(options) {
380
555
  return;
381
556
  }
382
557
 
383
- // Mirrors the Creator's enterEditorMode(): pause, turn the editor on (this is what unpacks
384
- // InstancedMeshes so clicks can hit an individual instance), then configure the free camera
385
- // and hide the HUD.
386
- post('SET_PAUSE', { paused: true });
387
- post('SET_EDITOR_MODE', { enabled: true });
388
- post('SET_EDITOR_TAB', { tab: 'scene' });
389
-
390
558
  loaded = true;
391
- setStatus('saved', 'Ready');
559
+ applyTab(activeTab);
392
560
  }
393
561
 
394
562
  async function flush() {
@@ -418,6 +586,7 @@ export function renderEditorShell(options) {
418
586
  }
419
587
  lastKnownMtime = result.worldMtimeMs || lastKnownMtime;
420
588
  markersDirty = false;
589
+ if (result.applied > 0) lastLocalEditAt = Date.now();
421
590
  // Only once the write landed: clearing earlier would drop the edit on a failed save.
422
591
  post('CLEAR_SCENE_CHANGES');
423
592
  setStatus('saved', result.applied > 0 ? 'Saved to world.json' : 'Ready');
@@ -448,16 +617,21 @@ export function renderEditorShell(options) {
448
617
  terrainBanner.classList.toggle('show', terrain.hasChanges === true);
449
618
  }
450
619
 
620
+ /**
621
+ * The pre-SSE way of noticing an agent's edit, kept as the fallback for a dropped stream.
622
+ *
623
+ * EventSource reconnects on its own, but a reload signal sent while it was down is simply gone —
624
+ * there is no replay. This poll is what closes that hole, and it costs one stat() per tick.
625
+ */
451
626
  async function checkExternalEdits() {
452
627
  if (!loaded || saving) return;
453
628
  try {
454
629
  var state = await (await fetch('/api/state')).json();
455
630
  // Anything other than the mtime our own last write produced is someone else's edit — most
456
- // likely the creator's agent. Offer a reload rather than taking one: an unprompted reload
457
- // would discard a drag in progress.
631
+ // likely the creator's agent.
458
632
  if (state.worldMtimeMs && state.worldMtimeMs !== lastKnownMtime
459
633
  && state.worldMtimeMs !== state.lastWrittenMtimeMs) {
460
- banner.classList.add('show');
634
+ onChangeSignal('world.json');
461
635
  }
462
636
  } catch (error) {
463
637
  // The sidecar is this page's own server; if it is gone the reload button is the only
@@ -465,30 +639,115 @@ export function renderEditorShell(options) {
465
639
  }
466
640
  }
467
641
 
642
+ /**
643
+ * Something changed on disk. Reload, or explain why we did not.
644
+ *
645
+ * The two refusals are the same judgement the banner has always encoded: an unprompted reload
646
+ * that discards work the creator can see themselves doing is worse than a stale frame with a
647
+ * button on it. What changed is who is usually writing — an agent, not the human — so the
648
+ * default flipped from "always ask" to "ask only when the human is in the middle of something".
649
+ */
650
+ function onChangeSignal(reason) {
651
+ // Logged because "why did my game just reload?" is otherwise unanswerable from the browser.
652
+ console.log('[bitmagic dev] change signalled:', reason);
653
+ if (!autoReload) { banner.classList.add('show'); return; }
654
+ if (activeTab === 'editor' && (saving || Date.now() - lastLocalEditAt < ACTIVE_EDIT_MS)) {
655
+ banner.classList.add('show');
656
+ return;
657
+ }
658
+ start();
659
+ }
660
+
661
+ /**
662
+ * Retire the running poll.
663
+ *
664
+ * The token, rather than clearTimeout alone: a tick that is mid-await ignores a cleared timer and
665
+ * schedules the next one anyway, so a reload or a tab switch during one would leave two loops
666
+ * against a single iframe — each saving the same scene changes the other just cleared.
667
+ */
668
+ function stopLoop() {
669
+ loopToken += 1;
670
+ if (pollTimer) clearTimeout(pollTimer);
671
+ pollTimer = null;
672
+ }
673
+
468
674
  // One serial loop rather than two timers: a flush and an mtime check must never overlap, or the
469
675
  // check reads the mtime of a write that has not finished being accounted for.
470
- async function loop(gen) {
471
- if (gen !== generation) return;
472
- await flush();
473
- await checkTerrainEdits();
474
- await checkExternalEdits();
475
- if (gen !== generation) return;
476
- pollTimer = setTimeout(function () { loop(gen); }, POLL_MS);
676
+ function runLoop() {
677
+ stopLoop();
678
+ var token = loopToken;
679
+ void (async function tick() {
680
+ if (token !== loopToken) return;
681
+ // The Game tab has nothing to save and no gizmo to warn about; only the disk check applies
682
+ // there, and only as the SSE fallback.
683
+ if (activeTab === 'editor') {
684
+ await flush();
685
+ await checkTerrainEdits();
686
+ }
687
+ await checkExternalEdits();
688
+ if (token !== loopToken) return;
689
+ pollTimer = setTimeout(tick, POLL_MS);
690
+ })();
477
691
  }
478
692
 
479
693
  function start() {
480
- if (pollTimer) clearTimeout(pollTimer);
694
+ stopLoop();
481
695
  var gen = ++generation;
482
- boot(gen).then(function () { loop(gen); });
696
+ boot(gen).then(function () {
697
+ if (gen === generation) runLoop();
698
+ });
699
+ }
700
+
701
+ /**
702
+ * The push channel (editor/reload-bus.ts). EventSource reconnects by itself, so there is no retry
703
+ * loop here — a dropped stream degrades to the mtime poll above until the browser reopens it.
704
+ */
705
+ function listenForEvents() {
706
+ var events = new EventSource('/api/events/stream');
707
+ events.addEventListener('reload', function (event) {
708
+ var payload = {};
709
+ try { payload = JSON.parse(event.data); } catch (error) { /* reason is a label */ }
710
+ onChangeSignal(payload.reason || 'change');
711
+ });
712
+ events.addEventListener('build', function (event) {
713
+ var payload = {};
714
+ try { payload = JSON.parse(event.data); } catch (error) { /* see above */ }
715
+ if (payload.status === 'failed') {
716
+ document.getElementById('build-message').textContent = payload.message || 'The build failed.';
717
+ buildBanner.classList.add('show');
718
+ setStatus('error', 'Build failed');
719
+ return;
720
+ }
721
+ buildBanner.classList.remove('show');
722
+ if (payload.status === 'building') setStatus('saving', 'Building…');
723
+ });
483
724
  }
484
725
 
485
- document.getElementById('reload').addEventListener('click', start);
486
- document.getElementById('banner-reload').addEventListener('click', start);
726
+ document.getElementById('reload').addEventListener('click', function () { start(); });
727
+ document.getElementById('banner-reload').addEventListener('click', function () { start(); });
487
728
  document.getElementById('terrain-discard').addEventListener('click', function () {
488
729
  post('REVERT_TERRAIN_CHANGES');
489
730
  terrainBanner.classList.remove('show');
490
731
  });
732
+ var tabButtons = document.querySelectorAll('.tab');
733
+ for (var t = 0; t < tabButtons.length; t++) {
734
+ tabButtons[t].addEventListener('click', function (event) {
735
+ void selectTab(event.currentTarget.getAttribute('data-tab'), {});
736
+ });
737
+ }
738
+ autoToggle.addEventListener('change', function () {
739
+ autoReload = autoToggle.checked;
740
+ writePref(AUTO_KEY, autoReload ? '1' : '0');
741
+ if (autoReload) banner.classList.remove('show');
742
+ });
491
743
 
744
+ autoReload = readPref(AUTO_KEY, '1') !== '0';
745
+ autoToggle.checked = autoReload;
746
+ // Applied through selectTab so the button states and the stored preference cannot disagree with
747
+ // the variable. 'loaded' is false here, so it only sets up the UI — boot() applies the mode.
748
+ activeTab = readPref(TAB_KEY, 'game') === 'editor' ? 'editor' : 'game';
749
+ void selectTab(activeTab, { fromEngine: true });
750
+ listenForEvents();
492
751
  start();
493
752
  })();
494
753
  </script>
@@ -1 +1 @@
1
- {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B,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;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;yBAIgB,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBA6C5B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAmC9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgXjC,CAAC;AACF,CAAC"}
1
+ {"version":3,"file":"shell-page.js","sourceRoot":"","sources":["../../src/editor/shell-page.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiDG;AAEH,oGAAoG;AACpG,MAAM,gBAAgB,GAAG,GAAG,CAAC;AAE7B;;;;;;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;AASD,MAAM,UAAU,iBAAiB,CAAC,OAA2B;IAC3D,MAAM,OAAO,GAAG,oBAAoB,OAAO,CAAC,QAAQ,2BAA2B,kBAAkB,CAAC,OAAO,CAAC,MAAM,CAAC,EAAE,CAAC;IACpH,OAAO;;;;wBAIe,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAwD3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBA8C9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAikB7C,CAAC;AACF,CAAC"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Deciding *when* the browser should reload.
3
+ *
4
+ * The hard part is not noticing a change — `fs.watch` does that — it is not reloading at the wrong
5
+ * moment. Three wrong moments, each of which this file exists to avoid:
6
+ *
7
+ * 1. **Mid-burst.** An agent editing five files produces five reloads unless the writes are let
8
+ * go quiet first. Hence the debounce.
9
+ * 2. **Mid-compile.** A `.ts` edit changes `src/` long before `tsc --watch` finishes emitting
10
+ * `dist/`, and every module the project loads resolves into `dist/`. Reloading on the source
11
+ * write shows the creator the *old* build and reads as "my change did nothing". Hence the
12
+ * compile gate: reloads are held while a compile is open and released when it lands clean.
13
+ * 3. **After a failed compile.** `dist/` still holds the last good emit, so a reload would show
14
+ * working code for broken source — the most confusing outcome of the three. The pending reload
15
+ * is dropped and the shell is told the build failed instead.
16
+ *
17
+ * `ReloadCoordinator` holds all of that and touches no I/O, so it is testable with fake timers and
18
+ * no filesystem. `watchProject` and `pipeTscWatch` are the thin I/O shells that feed it.
19
+ */
20
+ import { type ChildProcess } from 'child_process';
21
+ import type { BuildStatus } from './reload-bus.js';
22
+ /**
23
+ * How long the writes must stay quiet before a reload fires.
24
+ *
25
+ * Long enough that `tsc --watch` (which has its own ~250 ms debounce before it reports
26
+ * `File change detected`) has almost always opened its compile by the time this elapses, so the
27
+ * gate below actually gets to hold the reload. Too short and a `.ts` edit produces two reloads —
28
+ * one stale, one correct — instead of one.
29
+ */
30
+ export declare const RELOAD_QUIET_MS = 700;
31
+ /**
32
+ * How long after one of our own writes to ignore file events.
33
+ *
34
+ * `/api/scene/save` and the HQ generation both write `world.json`, and a reload fired at the
35
+ * creator's own gizmo drag would throw away the drag that caused it. `fs.watch` delivers
36
+ * asynchronously but promptly, so this only has to outlast the delivery, not the write.
37
+ */
38
+ export declare const OWN_WRITE_GRACE_MS = 400;
39
+ export interface ReloadCoordinatorOptions {
40
+ onReload: (reason: string) => void;
41
+ onBuild: (status: BuildStatus, message?: string) => void;
42
+ quietMs?: number;
43
+ }
44
+ export declare class ReloadCoordinator {
45
+ private readonly options;
46
+ private timer;
47
+ private pendingReason;
48
+ private compiling;
49
+ private suppressUntil;
50
+ private stopped;
51
+ constructor(options: ReloadCoordinatorOptions);
52
+ /** A file under a watched directory changed. Arms — or re-arms — the quiet window. */
53
+ noteFileChange(reason: string): void;
54
+ /**
55
+ * Reload as soon as it is safe to — now, or the moment the compile in flight lands clean.
56
+ *
57
+ * The wait is the whole point for `bitmagic reload`: an agent's stop hook fires immediately after
58
+ * its last edit, which is precisely when `tsc` is still compiling it. Returns whether the reload
59
+ * was queued behind a compile rather than sent, so the command can say which happened.
60
+ */
61
+ requestNow(reason: string): {
62
+ queued: boolean;
63
+ };
64
+ /** The sidecar wrote `world.json` itself; the resulting file events are not news. */
65
+ noteOwnWrite(): void;
66
+ noteCompileStart(): void;
67
+ noteCompileEnd(errorCount: number): void;
68
+ stop(): void;
69
+ /** Send the pending reload, unless a compile is still open — then it waits for `noteCompileEnd`. */
70
+ private release;
71
+ }
72
+ export interface ProjectWatcher {
73
+ close(): void;
74
+ }
75
+ /**
76
+ * Watch the two directories that decide what the browser shows: `src/` (what the agent and the
77
+ * creator edit — including `work/world.json`) and `dist/` (what the page actually loads).
78
+ *
79
+ * `dist/` is watched as well as `src/` even though the compile gate already covers the normal
80
+ * `tsc --watch` path, because it is what a build run outside this process — a manual `tsc`, an
81
+ * editor's own compiler — lands in, and that should reach the browser too.
82
+ *
83
+ * A directory that cannot be watched is skipped with a note rather than failing the command: a
84
+ * missing `dist/` means "not built yet", and losing auto-reload is a smaller loss than losing
85
+ * `bitmagic dev`.
86
+ */
87
+ export declare function watchProject(root: string, coordinator: ReloadCoordinator, log: (message: string) => void): ProjectWatcher;
88
+ /**
89
+ * Feed one line of `tsc --watch` output to the coordinator. Exported for its test — the markers are
90
+ * tsc's own wording, and a version that changes them would otherwise break auto-reload silently.
91
+ */
92
+ export declare function noteTscLine(line: string, coordinator: ReloadCoordinator): void;
93
+ /**
94
+ * Spawn `tsc --watch` with its output piped through us instead of inherited.
95
+ *
96
+ * Piping is what makes the compile gate possible — the only signal that a build finished, and
97
+ * whether it finished clean, is tsc's own `Found N errors` line. Every line is re-echoed verbatim,
98
+ * so the creator sees exactly what an inherited stdio would have shown, and `--pretty` is forced
99
+ * back on when our own stdout is a terminal (tsc drops colour the moment it is piped).
100
+ */
101
+ export declare function pipeTscWatch(tsc: string, cwd: string, coordinator: ReloadCoordinator): ChildProcess;