@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.
- package/README.md +58 -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 +389 -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 +23 -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,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
|
|
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
|
|
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
|
|
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 + '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
787
|
+
stopLoop();
|
|
481
788
|
var gen = ++generation;
|
|
482
|
-
boot(gen).then(function () {
|
|
789
|
+
boot(gen).then(function () {
|
|
790
|
+
if (gen === generation) runLoop();
|
|
791
|
+
});
|
|
483
792
|
}
|
|
484
793
|
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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"}
|