@bitmagic/cli 0.1.22 → 0.1.25

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 +58 -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 +389 -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 +23 -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,30 @@ 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
+ button:disabled { opacity: .5; cursor: default; background: #26262e; }
104
+ #tabs .tab:hover { background: #22222a; }
105
+ #tabs .tab.active:hover { background: #3352c4; }
68
106
  .banner { display: none; align-items: center; gap: 12px; flex: none; padding: 8px 12px;
69
107
  background: #3a2c12; border-bottom: 1px solid #574018; color: #f0d9a8; }
70
108
  .banner.show { display: flex; }
71
- #terrain-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
109
+ #terrain-banner, #build-banner { background: #3d1f1c; border-bottom-color: #6b2f28; color: #f3c3bd; }
110
+ #build-banner code { font-family: ui-monospace, monospace; }
72
111
  #frame { display: block; flex: 1; width: 100%; border: 0; min-height: 0; }
73
112
  #hq-overlay { display: none; position: fixed; inset: 0; background: rgba(8,8,12,.72);
74
113
  align-items: center; justify-content: center; z-index: 10; }
@@ -89,19 +128,31 @@ export function renderEditorShell(options) {
89
128
  </head>
90
129
  <body>
91
130
  <div id="bar">
92
- <strong>bitmagic edit</strong>
131
+ <strong>bitmagic dev</strong>
93
132
  <span class="id">${escapeAttr(options.gameId)}</span>
133
+ <div id="tabs">
134
+ <button class="tab active" type="button" data-tab="game">Game</button>
135
+ <button class="tab" type="button" data-tab="editor">Editor</button>
136
+ </div>
94
137
  <span class="spacer"></span>
138
+ <label id="auto" title="Reload the game when the project changes on disk">
139
+ <input id="auto-toggle" type="checkbox" checked> Auto-reload
140
+ </label>
95
141
  <span id="status"><span id="dot"></span><span id="status-text">Loading…</span></span>
142
+ <button id="shot" type="button" title="Copy the current frame to the clipboard">Screenshot</button>
96
143
  <button id="reload" type="button">Reload</button>
97
144
  </div>
145
+ <div id="build-banner" class="banner">
146
+ <span id="build-message"></span>
147
+ <span>The game still shows the last build that compiled.</span>
148
+ </div>
98
149
  <div id="banner" class="banner">
99
150
  <span>src/work/world.json changed on disk — this scene is out of date.</span>
100
151
  <button id="banner-reload" type="button">Reload scene</button>
101
152
  </div>
102
153
  <div id="terrain-banner" class="banner">
103
154
  <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
155
+ Sculpting the ground needs an asset upload that <code>bitmagic dev</code> does not do — these
105
156
  voxel changes will be lost. Discard them, or press Cancel in the terrain toolbar.</span>
106
157
  <button id="terrain-discard" type="button">Discard terrain edits</button>
107
158
  </div>
@@ -127,23 +178,51 @@ export function renderEditorShell(options) {
127
178
  'use strict';
128
179
  var GAME_URL = ${JSON.stringify(gameUrl)};
129
180
  var POLL_MS = ${POLL_INTERVAL_MS};
181
+ var ACTIVE_EDIT_MS = ${ACTIVE_EDIT_WINDOW_MS};
182
+ var TAB_KEY = 'bitmagic.dev.tab';
183
+ var AUTO_KEY = 'bitmagic.dev.autoReload';
130
184
 
131
185
  var frame = document.getElementById('frame');
132
186
  var dot = document.getElementById('dot');
133
187
  var statusText = document.getElementById('status-text');
134
188
  var banner = document.getElementById('banner');
135
189
  var terrainBanner = document.getElementById('terrain-banner');
190
+ var buildBanner = document.getElementById('build-banner');
191
+ var autoToggle = document.getElementById('auto-toggle');
136
192
 
137
193
  var loaded = false;
138
194
  var saving = false;
139
195
  var markersDirty = false;
140
196
  var lastKnownMtime = 0;
141
197
  var pollTimer = null;
198
+ // Retires the running poll on its own schedule; see stopLoop(). Distinct from 'generation',
199
+ // which retires a boot — a tab switch changes what the loop does without reloading anything.
200
+ var loopToken = 0;
142
201
  var waiters = [];
143
- // Bumped by reload(). Every async step checks it, so a reload triggered mid-boot cannot leave
202
+ // Bumped by start(). Every async step checks it, so a reload triggered mid-boot cannot leave
144
203
  // two polling loops running against one iframe.
145
204
  var generation = 0;
146
205
 
206
+ // The engine's own view of itself, from GAME_STATE_CHANGED. The Game tab needs it to tell
207
+ // "never started" (START_GAME) from "paused for the editor" (unpause) — see applyTab.
208
+ var gameState = '';
209
+ var activeTab = 'game';
210
+ var autoReload = true;
211
+ // When a scene save last landed. See ACTIVE_EDIT_WINDOW_MS.
212
+ var lastLocalEditAt = 0;
213
+
214
+ // localStorage is unavailable in some privacy modes, and losing a preference must not lose the
215
+ // page. Defaults below are the ones a first-time creator gets anyway.
216
+ function readPref(key, fallback) {
217
+ try {
218
+ var stored = window.localStorage.getItem(key);
219
+ return stored === null ? fallback : stored;
220
+ } catch (error) { return fallback; }
221
+ }
222
+ function writePref(key, value) {
223
+ try { window.localStorage.setItem(key, value); } catch (error) { /* see readPref */ }
224
+ }
225
+
147
226
  function setStatus(kind, text) {
148
227
  dot.className = kind || '';
149
228
  statusText.textContent = text;
@@ -184,6 +263,21 @@ export function renderEditorShell(options) {
184
263
  }
185
264
 
186
265
  switch (message.type) {
266
+ case 'GAME_STATE_CHANGED':
267
+ gameState = message.state || '';
268
+ if (activeTab === 'game' && loaded) {
269
+ setStatus(gameState === 'playing' ? 'saved' : '', gameState === 'playing' ? 'Playing' : 'Ready');
270
+ }
271
+ break;
272
+
273
+ case 'TAB_SWITCH_BLOCKED':
274
+ // The engine refuses to leave a tab with unsaved terrain changes
275
+ // (GameRuntimeController.canSwitchToEditorTab). applyTab checks for that first, so this is
276
+ // the backstop for a race — put the creator back where the engine actually is.
277
+ selectTab('editor', { fromEngine: true });
278
+ terrainBanner.classList.add('show');
279
+ break;
280
+
187
281
  case 'REQUEST_ASSETS':
188
282
  // Re-read rather than serving the copy captured at boot: an agent may have generated an
189
283
  // asset since this page loaded, and a stale list is an empty asset palette with no
@@ -332,10 +426,12 @@ export function renderEditorShell(options) {
332
426
  setStatus('error', 'Generation failed');
333
427
  return;
334
428
  }
335
- showHqNote(job.message + ' Reload to see it in the scene.', '');
429
+ showHqNote(job.message + ' Reloading to show it.', '');
336
430
  setStatus('saved', 'Generated');
337
431
  // The asset changed under the running engine, so the open scene is showing the old mesh.
338
- banner.classList.add('show');
432
+ // The generation wrote world.json through the sidecar, which suppresses its own writes from
433
+ // the watcher, so this is the only thing that will bring the new mesh in.
434
+ if (autoReload) start(); else banner.classList.add('show');
339
435
  };
340
436
  void tick();
341
437
  }
@@ -345,9 +441,82 @@ export function renderEditorShell(options) {
345
441
  void confirmHq();
346
442
  });
347
443
 
444
+ /**
445
+ * Put the engine into the mode the given tab means.
446
+ *
447
+ * Order is not cosmetic in either direction — see the "One iframe, two tabs" note in this file's
448
+ * header for what each message is actually undoing.
449
+ */
450
+ function applyTab(tab) {
451
+ if (tab === 'editor') {
452
+ // Mirrors the Creator's enterEditorMode(): pause, turn the editor on (this is what unpacks
453
+ // InstancedMeshes so clicks can hit an individual instance), then configure the free camera
454
+ // and hide the HUD.
455
+ post('SET_PAUSE', { paused: true });
456
+ post('SET_EDITOR_MODE', { enabled: true });
457
+ post('SET_EDITOR_TAB', { tab: 'scene' });
458
+ setStatus('saved', 'Ready');
459
+ return;
460
+ }
461
+
462
+ terrainBanner.classList.remove('show');
463
+ var resumable = gameState === 'playing' || gameState === 'paused';
464
+ if (resumable) {
465
+ // Unpause BEFORE the tab message: SET_EDITOR_TAB 'prompt' enables player controls only when
466
+ // the engine already considers itself playing, so the other order leaves a running game the
467
+ // creator cannot steer.
468
+ post('SET_PAUSE', { paused: false });
469
+ post('SET_EDITOR_MODE', { enabled: false });
470
+ post('SET_EDITOR_TAB', { tab: 'prompt' });
471
+ } else {
472
+ // Never started: LOAD_GAME with skipMenu leaves the engine in READY, so there is nothing to
473
+ // unpause and the Play button is what we are standing in for.
474
+ post('SET_EDITOR_MODE', { enabled: false });
475
+ post('SET_EDITOR_TAB', { tab: 'prompt' });
476
+ post('START_GAME');
477
+ }
478
+ setStatus('saved', 'Playing');
479
+ // Without this the first keypress goes to the shell, not the game.
480
+ frame.focus();
481
+ }
482
+
483
+ /**
484
+ * Switch tabs, saving anything the editor was holding first.
485
+ *
486
+ * The terrain check is a precondition rather than a reaction: the engine refuses to leave a tab
487
+ * with unsaved voxel edits, and finding that out from TAB_SWITCH_BLOCKED means having already
488
+ * sent SET_EDITOR_MODE — leaving the editor half off, with no HUD and no gizmo.
489
+ */
490
+ async function selectTab(tab, options) {
491
+ var fromEngine = options && options.fromEngine === true;
492
+ if (tab === activeTab && !fromEngine) return;
493
+
494
+ if (activeTab === 'editor' && tab === 'game' && loaded && !fromEngine) {
495
+ await flush();
496
+ post('CHECK_TERRAIN_CHANGES');
497
+ var terrain = await await_('TERRAIN_HAS_CHANGES', 2000);
498
+ if (terrain && terrain.hasChanges === true) {
499
+ terrainBanner.classList.add('show');
500
+ return;
501
+ }
502
+ }
503
+
504
+ activeTab = tab;
505
+ writePref(TAB_KEY, tab);
506
+ var buttons = document.querySelectorAll('.tab');
507
+ for (var i = 0; i < buttons.length; i++) {
508
+ buttons[i].classList.toggle('active', buttons[i].getAttribute('data-tab') === tab);
509
+ }
510
+ if (!loaded) return;
511
+ applyTab(tab);
512
+ // One loop for both tabs; the work inside it is what differs (see runLoop()).
513
+ runLoop();
514
+ }
515
+
348
516
  async function boot(gen) {
349
517
  loaded = false;
350
518
  markersDirty = false;
519
+ gameState = '';
351
520
  banner.classList.remove('show');
352
521
  terrainBanner.classList.remove('show');
353
522
  setStatus('', 'Loading…');
@@ -372,7 +541,15 @@ export function renderEditorShell(options) {
372
541
  setStatus('error', 'The game never finished booting — try "bitmagic verify"');
373
542
  return;
374
543
  }
375
- post('LOAD_GAME', { gameId: project.gameId, gameData: project.gameData, skipMenu: true });
544
+ // currentTab is honoured by the engine's own LOAD_GAME handler, which sets the camera and
545
+ // player controls for it — so booting straight into the right mode rather than correcting it
546
+ // afterwards.
547
+ post('LOAD_GAME', {
548
+ gameId: project.gameId,
549
+ gameData: project.gameData,
550
+ skipMenu: true,
551
+ currentTab: activeTab === 'editor' ? 'scene' : 'prompt'
552
+ });
376
553
  var started = await gameLoaded;
377
554
  if (gen !== generation) return;
378
555
  if (!started) {
@@ -380,15 +557,8 @@ export function renderEditorShell(options) {
380
557
  return;
381
558
  }
382
559
 
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
560
  loaded = true;
391
- setStatus('saved', 'Ready');
561
+ applyTab(activeTab);
392
562
  }
393
563
 
394
564
  async function flush() {
@@ -418,6 +588,7 @@ export function renderEditorShell(options) {
418
588
  }
419
589
  lastKnownMtime = result.worldMtimeMs || lastKnownMtime;
420
590
  markersDirty = false;
591
+ if (result.applied > 0) lastLocalEditAt = Date.now();
421
592
  // Only once the write landed: clearing earlier would drop the edit on a failed save.
422
593
  post('CLEAR_SCENE_CHANGES');
423
594
  setStatus('saved', result.applied > 0 ? 'Saved to world.json' : 'Ready');
@@ -448,16 +619,21 @@ export function renderEditorShell(options) {
448
619
  terrainBanner.classList.toggle('show', terrain.hasChanges === true);
449
620
  }
450
621
 
622
+ /**
623
+ * The pre-SSE way of noticing an agent's edit, kept as the fallback for a dropped stream.
624
+ *
625
+ * EventSource reconnects on its own, but a reload signal sent while it was down is simply gone —
626
+ * there is no replay. This poll is what closes that hole, and it costs one stat() per tick.
627
+ */
451
628
  async function checkExternalEdits() {
452
629
  if (!loaded || saving) return;
453
630
  try {
454
631
  var state = await (await fetch('/api/state')).json();
455
632
  // 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.
633
+ // likely the creator's agent.
458
634
  if (state.worldMtimeMs && state.worldMtimeMs !== lastKnownMtime
459
635
  && state.worldMtimeMs !== state.lastWrittenMtimeMs) {
460
- banner.classList.add('show');
636
+ onChangeSignal('world.json');
461
637
  }
462
638
  } catch (error) {
463
639
  // The sidecar is this page's own server; if it is gone the reload button is the only
@@ -465,30 +641,207 @@ export function renderEditorShell(options) {
465
641
  }
466
642
  }
467
643
 
644
+ /**
645
+ * Something changed on disk. Reload, or explain why we did not.
646
+ *
647
+ * The two refusals are the same judgement the banner has always encoded: an unprompted reload
648
+ * that discards work the creator can see themselves doing is worse than a stale frame with a
649
+ * button on it. What changed is who is usually writing — an agent, not the human — so the
650
+ * default flipped from "always ask" to "ask only when the human is in the middle of something".
651
+ */
652
+ function onChangeSignal(reason) {
653
+ // Logged because "why did my game just reload?" is otherwise unanswerable from the browser.
654
+ console.log('[bitmagic dev] change signalled:', reason);
655
+ if (!autoReload) { banner.classList.add('show'); return; }
656
+ if (activeTab === 'editor' && (saving || Date.now() - lastLocalEditAt < ACTIVE_EDIT_MS)) {
657
+ banner.classList.add('show');
658
+ return;
659
+ }
660
+ start();
661
+ }
662
+
663
+ /**
664
+ * Screenshot → clipboard.
665
+ *
666
+ * The shell cannot read the game's pixels itself: the iframe is a different origin, so its
667
+ * canvas is off limits. The engine has to take the shot, and it already can — 'CAPTURE_SCREENSHOT'
668
+ * answers with 'SCREENSHOT_RESPONSE {screenshot}', handling preserveDrawingBuffer and the 2D
669
+ * genres' separate overlay canvas ('GameTemplate.captureScreenshot'). This is the Creator's own
670
+ * capture path, borrowed whole.
671
+ *
672
+ * Two things stand between that data URL and the clipboard:
673
+ *
674
+ * 1. **The engine hands back JPEG; the clipboard only takes PNG.** Chrome rejects
675
+ * 'image/jpeg' from 'clipboard.write' outright, so the frame is decoded and re-encoded here.
676
+ * 2. **The write has to start inside the click.** Clipboard writes need transient user
677
+ * activation, and the round-trip to the engine outlives it. 'ClipboardItem' accepts a
678
+ * *promise* for exactly this case, so the write is issued synchronously and the blob lands
679
+ * later — which is why 'captureGamePng()' is called before 'clipboard.write', not awaited
680
+ * into it.
681
+ */
682
+ var capturing = false;
683
+ var shotButton = document.getElementById('shot');
684
+
685
+ async function captureGamePng() {
686
+ post('CAPTURE_SCREENSHOT', { mode: 'immediate' });
687
+ var response = await await_('SCREENSHOT_RESPONSE', 15000);
688
+ if (!response) throw new Error('the game did not answer');
689
+ // The engine reports its own failures as a null screenshot rather than staying silent.
690
+ if (!response.screenshot) throw new Error('the engine could not capture a frame');
691
+
692
+ var image = new Image();
693
+ await new Promise(function (resolve, reject) {
694
+ image.onload = resolve;
695
+ image.onerror = function () { reject(new Error('the captured frame could not be decoded')); };
696
+ image.src = response.screenshot;
697
+ });
698
+
699
+ var canvas = document.createElement('canvas');
700
+ canvas.width = image.naturalWidth;
701
+ canvas.height = image.naturalHeight;
702
+ canvas.getContext('2d').drawImage(image, 0, 0);
703
+ return await new Promise(function (resolve, reject) {
704
+ canvas.toBlob(function (blob) {
705
+ if (blob) resolve(blob);
706
+ else reject(new Error('the frame could not be re-encoded as PNG'));
707
+ }, 'image/png');
708
+ });
709
+ }
710
+
711
+ /** The fallback when the clipboard refuses. A file beats a button that did nothing. */
712
+ function downloadPng(blob) {
713
+ var url = URL.createObjectURL(blob);
714
+ var link = document.createElement('a');
715
+ link.href = url;
716
+ link.download = 'bitmagic-' + new Date().toISOString().replace(/[:.]/g, '-') + '.png';
717
+ link.click();
718
+ setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
719
+ }
720
+
721
+ function finishShot(kind, text) {
722
+ capturing = false;
723
+ shotButton.disabled = false;
724
+ setStatus(kind, text);
725
+ }
726
+
727
+ function takeScreenshot() {
728
+ if (!loaded) { setStatus('error', 'Nothing to capture yet'); return; }
729
+ if (capturing) return;
730
+ capturing = true;
731
+ shotButton.disabled = true;
732
+ setStatus('saving', 'Capturing…');
733
+
734
+ var png = captureGamePng();
735
+ // Not awaited before the write — see note 2 above.
736
+ var canWrite = typeof ClipboardItem !== 'undefined' && navigator.clipboard && navigator.clipboard.write;
737
+ var written = canWrite
738
+ ? navigator.clipboard.write([new ClipboardItem({ 'image/png': png })])
739
+ : Promise.reject(new Error('this browser cannot put images on the clipboard'));
740
+
741
+ written.then(function () {
742
+ finishShot('saved', 'Screenshot copied');
743
+ }).catch(function (error) {
744
+ // Firefox has no promise-valued ClipboardItem, and any browser may refuse the write.
745
+ png.then(downloadPng).then(function () {
746
+ finishShot('saved', 'Clipboard refused it — saved to your downloads');
747
+ }).catch(function (captureError) {
748
+ // The capture itself failed, so there was never an image; report that, not the clipboard.
749
+ finishShot('error', 'Screenshot failed: ' + (captureError.message || error.message));
750
+ });
751
+ });
752
+ }
753
+
754
+ /**
755
+ * Retire the running poll.
756
+ *
757
+ * The token, rather than clearTimeout alone: a tick that is mid-await ignores a cleared timer and
758
+ * schedules the next one anyway, so a reload or a tab switch during one would leave two loops
759
+ * against a single iframe — each saving the same scene changes the other just cleared.
760
+ */
761
+ function stopLoop() {
762
+ loopToken += 1;
763
+ if (pollTimer) clearTimeout(pollTimer);
764
+ pollTimer = null;
765
+ }
766
+
468
767
  // One serial loop rather than two timers: a flush and an mtime check must never overlap, or the
469
768
  // 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);
769
+ function runLoop() {
770
+ stopLoop();
771
+ var token = loopToken;
772
+ void (async function tick() {
773
+ if (token !== loopToken) return;
774
+ // The Game tab has nothing to save and no gizmo to warn about; only the disk check applies
775
+ // there, and only as the SSE fallback.
776
+ if (activeTab === 'editor') {
777
+ await flush();
778
+ await checkTerrainEdits();
779
+ }
780
+ await checkExternalEdits();
781
+ if (token !== loopToken) return;
782
+ pollTimer = setTimeout(tick, POLL_MS);
783
+ })();
477
784
  }
478
785
 
479
786
  function start() {
480
- if (pollTimer) clearTimeout(pollTimer);
787
+ stopLoop();
481
788
  var gen = ++generation;
482
- boot(gen).then(function () { loop(gen); });
789
+ boot(gen).then(function () {
790
+ if (gen === generation) runLoop();
791
+ });
483
792
  }
484
793
 
485
- document.getElementById('reload').addEventListener('click', start);
486
- document.getElementById('banner-reload').addEventListener('click', start);
794
+ /**
795
+ * The push channel (editor/reload-bus.ts). EventSource reconnects by itself, so there is no retry
796
+ * loop here — a dropped stream degrades to the mtime poll above until the browser reopens it.
797
+ */
798
+ function listenForEvents() {
799
+ var events = new EventSource('/api/events/stream');
800
+ events.addEventListener('reload', function (event) {
801
+ var payload = {};
802
+ try { payload = JSON.parse(event.data); } catch (error) { /* reason is a label */ }
803
+ onChangeSignal(payload.reason || 'change');
804
+ });
805
+ events.addEventListener('build', function (event) {
806
+ var payload = {};
807
+ try { payload = JSON.parse(event.data); } catch (error) { /* see above */ }
808
+ if (payload.status === 'failed') {
809
+ document.getElementById('build-message').textContent = payload.message || 'The build failed.';
810
+ buildBanner.classList.add('show');
811
+ setStatus('error', 'Build failed');
812
+ return;
813
+ }
814
+ buildBanner.classList.remove('show');
815
+ if (payload.status === 'building') setStatus('saving', 'Building…');
816
+ });
817
+ }
818
+
819
+ shotButton.addEventListener('click', takeScreenshot);
820
+ document.getElementById('reload').addEventListener('click', function () { start(); });
821
+ document.getElementById('banner-reload').addEventListener('click', function () { start(); });
487
822
  document.getElementById('terrain-discard').addEventListener('click', function () {
488
823
  post('REVERT_TERRAIN_CHANGES');
489
824
  terrainBanner.classList.remove('show');
490
825
  });
826
+ var tabButtons = document.querySelectorAll('.tab');
827
+ for (var t = 0; t < tabButtons.length; t++) {
828
+ tabButtons[t].addEventListener('click', function (event) {
829
+ void selectTab(event.currentTarget.getAttribute('data-tab'), {});
830
+ });
831
+ }
832
+ autoToggle.addEventListener('change', function () {
833
+ autoReload = autoToggle.checked;
834
+ writePref(AUTO_KEY, autoReload ? '1' : '0');
835
+ if (autoReload) banner.classList.remove('show');
836
+ });
491
837
 
838
+ autoReload = readPref(AUTO_KEY, '1') !== '0';
839
+ autoToggle.checked = autoReload;
840
+ // Applied through selectTab so the button states and the stored preference cannot disagree with
841
+ // the variable. 'loaded' is false here, so it only sets up the UI — boot() applies the mode.
842
+ activeTab = readPref(TAB_KEY, 'game') === 'editor' ? 'editor' : 'game';
843
+ void selectTab(activeTab, { fromEngine: true });
844
+ listenForEvents();
492
845
  start();
493
846
  })();
494
847
  </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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;uBAyD3B,UAAU,CAAC,OAAO,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBA+C9B,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC;kBACxB,gBAAgB;yBACT,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6pB7C,CAAC;AACF,CAAC"}