@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.
- package/README.md +53 -19
- package/dist/cli.d.ts +10 -6
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.d.ts +5 -1
- package/dist/commands/dev.js +82 -13
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/generate.js +3 -3
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/reload.d.ts +10 -0
- package/dist/commands/reload.js +82 -0
- package/dist/commands/reload.js.map +1 -0
- package/dist/commands/upgrade.js +6 -0
- package/dist/commands/upgrade.js.map +1 -1
- package/dist/editor/journal.js +2 -2
- package/dist/editor/reload-bus.d.ts +38 -0
- package/dist/editor/reload-bus.js +66 -0
- package/dist/editor/reload-bus.js.map +1 -0
- package/dist/editor/server.d.ts +7 -0
- package/dist/editor/server.js +51 -9
- package/dist/editor/server.js.map +1 -1
- package/dist/editor/shell-page.d.ts +24 -5
- package/dist/editor/shell-page.js +295 -36
- package/dist/editor/shell-page.js.map +1 -1
- package/dist/editor/watch.d.ts +101 -0
- package/dist/editor/watch.js +228 -0
- package/dist/editor/watch.js.map +1 -0
- package/dist/local-port.d.ts +10 -0
- package/dist/local-port.js +23 -0
- package/dist/local-port.js.map +1 -1
- package/dist/project/dev-handle.d.ts +14 -0
- package/dist/project/dev-handle.js +53 -0
- package/dist/project/dev-handle.js.map +1 -0
- package/dist/scaffold/claude-settings.d.ts +41 -0
- package/dist/scaffold/claude-settings.js +98 -0
- package/dist/scaffold/claude-settings.js.map +1 -0
- package/dist/scaffold/project-files.js +20 -8
- package/dist/scaffold/project-files.js.map +1 -1
- package/dist/scaffold/project.js +5 -0
- package/dist/scaffold/project.js.map +1 -1
- package/dist/scaffold/upgrade-project.d.ts +6 -0
- package/dist/scaffold/upgrade-project.js +19 -1
- package/dist/scaffold/upgrade-project.js.map +1 -1
- package/dist/verify/browser.js +3 -1
- package/dist/verify/browser.js.map +1 -1
- package/package.json +1 -1
- package/dist/commands/edit.d.ts +0 -10
- package/dist/commands/edit.js +0 -148
- package/dist/commands/edit.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
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
|
|
24
|
-
* (`{ type, assets }`), not the `{ type, data }` one the rest of the protocol uses.
|
|
25
|
-
* the Creator's existing shape (`useIframeMessages.ts:1149`, `:332`) and the engine
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 + '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
694
|
+
stopLoop();
|
|
481
695
|
var gen = ++generation;
|
|
482
|
-
boot(gen).then(function () {
|
|
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
|
|
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;
|