@uzuhq/code-cli 0.3.14
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/LICENSE +21 -0
- package/README.md +64 -0
- package/dist/auth/browser.js +22 -0
- package/dist/auth/config.js +48 -0
- package/dist/auth/env.js +42 -0
- package/dist/auth/jwt.js +27 -0
- package/dist/auth/login-flow.js +44 -0
- package/dist/auth/loopback.js +94 -0
- package/dist/auth/pkce.js +11 -0
- package/dist/auth/publish-token.js +74 -0
- package/dist/auth/token-cache.js +70 -0
- package/dist/auth/uzu-auth.js +94 -0
- package/dist/build-server-logic.js +28 -0
- package/dist/cf-images-upload.js +52 -0
- package/dist/cli.js +303 -0
- package/dist/create-2d-game.js +56 -0
- package/dist/dev-server/game-room.js +436 -0
- package/dist/dev-server/game-types.js +11 -0
- package/dist/dev-server/json-patch.js +114 -0
- package/dist/dev-server/load-logic.js +70 -0
- package/dist/dev-server/random.js +35 -0
- package/dist/dev-server/relay-room.js +84 -0
- package/dist/dev-server/server.js +367 -0
- package/dist/dev-server/sync-room.js +268 -0
- package/dist/dev.js +235 -0
- package/dist/harness/admin-client.js +215 -0
- package/dist/harness/client-entry.js +90 -0
- package/dist/harness/dev-button.js +249 -0
- package/dist/harness/mount.js +664 -0
- package/dist/harness/page.js +46 -0
- package/dist/r2-upload.js +93 -0
- package/dist/rest-register.js +50 -0
- package/dist/upload-session.js +71 -0
- package/game-2d-template/index.html.tpl +18 -0
- package/game-2d-template/manifest.json.tpl +6 -0
- package/game-2d-template/package.json.tpl +23 -0
- package/game-2d-template/src/main.ts +49 -0
- package/game-2d-template/src/vite-env.d.ts +1 -0
- package/game-2d-template/tsconfig.json +12 -0
- package/game-2d-template/vite.config.ts +6 -0
- package/package.json +43 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* parent frame の `window.__uzu_dev` を dev-server 側の管理 channel (`/dev/admin`) に
|
|
3
|
+
* 繋ぐ薄いクライアント。 SDK の `attachDevHooks({...})` と同じ shape の hook を
|
|
4
|
+
* expose する。
|
|
5
|
+
*
|
|
6
|
+
* message shape は server.ts の handleAdminConnection と同じ。
|
|
7
|
+
* Client → Server: `{ type: 'call', id, method, args? }`
|
|
8
|
+
* `{ type: 'subscribe', id, kind: 'snapshot' | 'events' }`
|
|
9
|
+
* `{ type: 'unsubscribe', id }`
|
|
10
|
+
* Server → Client: `{ type: 'result', id, value }` / `{ type: 'error', id, error }`
|
|
11
|
+
* `{ type: 'event', id, value }` / `{ type: 'missing', id }`
|
|
12
|
+
*/
|
|
13
|
+
export function attachAdminClient(adminUrl) {
|
|
14
|
+
const pending = new Map();
|
|
15
|
+
const subscribers = new Map();
|
|
16
|
+
let idCounter = 0;
|
|
17
|
+
let ws = null;
|
|
18
|
+
let ready = null;
|
|
19
|
+
let latestSnapshot = null;
|
|
20
|
+
const localSnapshotSubs = new Set();
|
|
21
|
+
const localEventSubs = new Set();
|
|
22
|
+
const connect = () => {
|
|
23
|
+
if (ready)
|
|
24
|
+
return ready;
|
|
25
|
+
ready = new Promise((resolve, reject) => {
|
|
26
|
+
const socket = new WebSocket(adminUrl);
|
|
27
|
+
ws = socket;
|
|
28
|
+
socket.addEventListener('open', () => {
|
|
29
|
+
// 常時 snapshot subscription を張っておき、 waitForSnapshot / subscribeSnapshot
|
|
30
|
+
// の同期パス (getSnapshot() = latestSnapshot) を暖める。
|
|
31
|
+
void call('snapshot_subscribe', 'subscribe', {
|
|
32
|
+
kind: 'snapshot',
|
|
33
|
+
}).catch(() => {
|
|
34
|
+
/* ignore — server が未対応でも subscribeSnapshot は動く */
|
|
35
|
+
});
|
|
36
|
+
subscribers.set('snapshot_subscribe', (value) => {
|
|
37
|
+
latestSnapshot = value;
|
|
38
|
+
localSnapshotSubs.forEach((cb) => {
|
|
39
|
+
try {
|
|
40
|
+
cb(value);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.warn('[__uzu_dev] snapshot listener threw:', err);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
void call('events_subscribe', 'subscribe', {
|
|
48
|
+
kind: 'events',
|
|
49
|
+
}).catch(() => {
|
|
50
|
+
/* ignore */
|
|
51
|
+
});
|
|
52
|
+
subscribers.set('events_subscribe', (value) => {
|
|
53
|
+
localEventSubs.forEach((cb) => {
|
|
54
|
+
try {
|
|
55
|
+
cb(value);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
console.warn('[__uzu_dev] events listener threw:', err);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
resolve();
|
|
63
|
+
});
|
|
64
|
+
socket.addEventListener('message', (ev) => {
|
|
65
|
+
let msg;
|
|
66
|
+
try {
|
|
67
|
+
msg = JSON.parse(ev.data);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const id = msg.id;
|
|
73
|
+
if (msg.type === 'result') {
|
|
74
|
+
const p = pending.get(id);
|
|
75
|
+
if (p) {
|
|
76
|
+
p.resolve(msg.value);
|
|
77
|
+
pending.delete(id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (msg.type === 'error') {
|
|
81
|
+
const p = pending.get(id);
|
|
82
|
+
if (p) {
|
|
83
|
+
p.reject(new Error(msg.error));
|
|
84
|
+
pending.delete(id);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else if (msg.type === 'missing') {
|
|
88
|
+
const p = pending.get(id);
|
|
89
|
+
if (p) {
|
|
90
|
+
p.resolve(undefined);
|
|
91
|
+
pending.delete(id);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (msg.type === 'event') {
|
|
95
|
+
const sub = subscribers.get(id);
|
|
96
|
+
if (sub)
|
|
97
|
+
sub(msg.value);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
socket.addEventListener('error', () => reject(new Error('admin channel error')));
|
|
101
|
+
socket.addEventListener('close', () => {
|
|
102
|
+
ws = null;
|
|
103
|
+
ready = null;
|
|
104
|
+
// 全 pending を reject
|
|
105
|
+
pending.forEach((p) => p.reject(new Error('admin channel closed')));
|
|
106
|
+
pending.clear();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
return ready;
|
|
110
|
+
};
|
|
111
|
+
const nextId = (prefix) => {
|
|
112
|
+
idCounter++;
|
|
113
|
+
return `${prefix}-${idCounter}`;
|
|
114
|
+
};
|
|
115
|
+
const call = (id, type, body) => {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
if (!ws) {
|
|
118
|
+
reject(new Error('admin channel not connected'));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
pending.set(id, { resolve, reject });
|
|
122
|
+
ws.send(JSON.stringify({ type, id, ...body }));
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
const callMethod = async (method, args = []) => {
|
|
126
|
+
await connect();
|
|
127
|
+
const id = nextId('call');
|
|
128
|
+
return call(id, 'call', { method, args });
|
|
129
|
+
};
|
|
130
|
+
const hooks = {
|
|
131
|
+
getSnapshot: () => latestSnapshot,
|
|
132
|
+
getRawState: () => latestSnapshot,
|
|
133
|
+
playerId: () => 'host',
|
|
134
|
+
subscribeSnapshot: (cb) => {
|
|
135
|
+
localSnapshotSubs.add(cb);
|
|
136
|
+
return () => {
|
|
137
|
+
localSnapshotSubs.delete(cb);
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
waitForSnapshot: (predicate, options) => {
|
|
141
|
+
const timeoutMs = options?.timeoutMs ?? 10_000;
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
if (latestSnapshot != null) {
|
|
144
|
+
try {
|
|
145
|
+
if (predicate(latestSnapshot)) {
|
|
146
|
+
resolve(latestSnapshot);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
reject(err);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const timer = setTimeout(() => {
|
|
156
|
+
localSnapshotSubs.delete(listener);
|
|
157
|
+
reject(new Error(`[__uzu_dev] waitForSnapshot timed out after ${timeoutMs}ms`));
|
|
158
|
+
}, timeoutMs);
|
|
159
|
+
const listener = (snap) => {
|
|
160
|
+
try {
|
|
161
|
+
if (predicate(snap)) {
|
|
162
|
+
clearTimeout(timer);
|
|
163
|
+
localSnapshotSubs.delete(listener);
|
|
164
|
+
resolve(snap);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
clearTimeout(timer);
|
|
169
|
+
localSnapshotSubs.delete(listener);
|
|
170
|
+
reject(err);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
localSnapshotSubs.add(listener);
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
setRawState: async (state) => {
|
|
177
|
+
await callMethod('setRawState', [state]);
|
|
178
|
+
},
|
|
179
|
+
mergeRawState: async (patch) => {
|
|
180
|
+
await callMethod('mergeRawState', [patch]);
|
|
181
|
+
},
|
|
182
|
+
patchRawState: async (ops) => {
|
|
183
|
+
await callMethod('patchRawState', [ops]);
|
|
184
|
+
},
|
|
185
|
+
send: async (args) => {
|
|
186
|
+
await callMethod('sendAction', [args]);
|
|
187
|
+
},
|
|
188
|
+
subscribeEvents: (cb) => {
|
|
189
|
+
localEventSubs.add(cb);
|
|
190
|
+
return () => {
|
|
191
|
+
localEventSubs.delete(cb);
|
|
192
|
+
};
|
|
193
|
+
},
|
|
194
|
+
pauseTick: () => {
|
|
195
|
+
void callMethod('pauseTick');
|
|
196
|
+
},
|
|
197
|
+
resumeTick: () => {
|
|
198
|
+
void callMethod('resumeTick');
|
|
199
|
+
},
|
|
200
|
+
stepTick: (n) => {
|
|
201
|
+
void callMethod('stepTick', [n ?? 1]);
|
|
202
|
+
},
|
|
203
|
+
// getSeed / getCurrentTick / isTickPaused は admin channel 越しだと非同期取得になり、
|
|
204
|
+
// 同期シグネチャでは値が届く前に初期値を返す嘘 API になるため remote proxy では公開しない。
|
|
205
|
+
// 状態追跡は subscribeSnapshot / waitForSnapshot を使う。
|
|
206
|
+
reset: (opts) => {
|
|
207
|
+
void callMethod('reset', [opts]);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
// 初回接続を kick する (非同期で完了)。
|
|
211
|
+
void connect().catch((err) => {
|
|
212
|
+
console.warn('[__uzu_dev] admin channel connect failed:', err);
|
|
213
|
+
});
|
|
214
|
+
return hooks;
|
|
215
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* harness client bundle entry。
|
|
3
|
+
*
|
|
4
|
+
* `/_uzu_meta.json` を fetch → iframe grid mount + HUD 表示 + admin channel client を
|
|
5
|
+
* `window.__uzu_dev` に expose する。 esbuild で IIFE bundle 済み文字列として
|
|
6
|
+
* dev-server が `/_uzu_harness.js` から serve する。
|
|
7
|
+
*/
|
|
8
|
+
import { mountIframeGrid, mountSinglePlayer } from './mount.js';
|
|
9
|
+
import { attachAdminClient } from './admin-client.js';
|
|
10
|
+
/**
|
|
11
|
+
* meta の URL は CLI 視点 (localhost) で書かれている。 スマホ実機など
|
|
12
|
+
* localhost 以外の host で harness を開いたときは、 アクセスに使った
|
|
13
|
+
* hostname へ読み替える (port はそのまま)。 harness server 自体は
|
|
14
|
+
* 同一 origin なので location.host を使う。
|
|
15
|
+
*/
|
|
16
|
+
function rewriteHostForViewer(meta) {
|
|
17
|
+
const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
18
|
+
if (isLocal) {
|
|
19
|
+
// PC (localhost) は従来どおり scenario dev server (vite) へ直接アクセスする
|
|
20
|
+
return { ...meta, serverBaseUrl: `ws://${location.host}` };
|
|
21
|
+
}
|
|
22
|
+
// 実機 (LAN IP / mDNS 名) からは harness server の scenario proxy を経由する。
|
|
23
|
+
// vite には harness から localhost 宛で届くため、 scenario 側の vite 設定
|
|
24
|
+
// (--host / allowedHosts) を一切要求しない。
|
|
25
|
+
return {
|
|
26
|
+
...meta,
|
|
27
|
+
scenarioUrl: `${location.protocol}//${location.host}/`,
|
|
28
|
+
serverBaseUrl: `ws://${location.host}`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
async function main() {
|
|
32
|
+
const res = await fetch('/_uzu_meta.json');
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
console.error('[uzu-harness] failed to fetch meta:', res.status);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const rawMeta = (await res.json());
|
|
38
|
+
const meta = rewriteHostForViewer(rawMeta);
|
|
39
|
+
// `?player=N` (0-based) 付きは 1 プレイヤー全画面 mount (スマホ実機用)。
|
|
40
|
+
// admin channel は grid (親frame) だけが張るので、 単体表示では dev メニューを出さない。
|
|
41
|
+
const playerParam = new URLSearchParams(location.search).get('player');
|
|
42
|
+
const playerIndex = playerParam == null ? null : Number(playerParam);
|
|
43
|
+
const seatCount = meta.seats.length;
|
|
44
|
+
if (playerIndex != null && Number.isInteger(playerIndex)) {
|
|
45
|
+
if (playerIndex < 0 || playerIndex >= seatCount) {
|
|
46
|
+
document.body.textContent = `player は 0〜${seatCount - 1} で指定してください`;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
mountSinglePlayer({
|
|
50
|
+
target: document.body,
|
|
51
|
+
playerCount: meta.playerCount,
|
|
52
|
+
orientation: meta.orientation,
|
|
53
|
+
devMinIframeShortEdge: meta.devMinIframeShortEdge,
|
|
54
|
+
scenarioUrl: meta.scenarioUrl,
|
|
55
|
+
serverBaseUrl: meta.serverBaseUrl,
|
|
56
|
+
revisionId: meta.revisionId,
|
|
57
|
+
roomKey: meta.roomKey,
|
|
58
|
+
seats: meta.seats,
|
|
59
|
+
}, playerIndex);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// admin channel client を接続。 window.__uzu_dev として parent frame に expose する。
|
|
63
|
+
const adminUrl = `${meta.serverBaseUrl}/dev/admin`;
|
|
64
|
+
const hooks = attachAdminClient(adminUrl);
|
|
65
|
+
window.__uzu_dev = hooks;
|
|
66
|
+
const stateGetter = () => {
|
|
67
|
+
const snap = hooks.getSnapshot();
|
|
68
|
+
if (snap == null)
|
|
69
|
+
return null;
|
|
70
|
+
return { state: snap, serverTime: 0, myId: 'host' };
|
|
71
|
+
};
|
|
72
|
+
const resetGame = hooks.reset ? () => hooks.reset?.({ seed: 'random' }) : undefined;
|
|
73
|
+
mountIframeGrid({
|
|
74
|
+
target: document.body,
|
|
75
|
+
playerCount: meta.playerCount,
|
|
76
|
+
orientation: meta.orientation,
|
|
77
|
+
devMinIframeShortEdge: meta.devMinIframeShortEdge,
|
|
78
|
+
scenarioUrl: meta.scenarioUrl,
|
|
79
|
+
serverBaseUrl: meta.serverBaseUrl,
|
|
80
|
+
revisionId: meta.revisionId,
|
|
81
|
+
roomKey: meta.roomKey,
|
|
82
|
+
seats: meta.seats,
|
|
83
|
+
// 実機リンク用 host は rewrite 前の meta から取る (localhost 視点の LAN/mDNS 一覧)。
|
|
84
|
+
lanHosts: rawMeta.lanHosts,
|
|
85
|
+
// 各 cell の UZU ボタンに配線する dev メニュー。
|
|
86
|
+
stateGetter,
|
|
87
|
+
resetGame,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
void main();
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UZU ボタンのタップメニュー (Emulator / State Inspector / Reset / 実機で開く) と、
|
|
3
|
+
* 共有の State Inspector overlay。
|
|
4
|
+
*
|
|
5
|
+
* 本番アプリ (Flutter GamePlayScreen) は各プレイヤー画面の左上に UZU ボタン +
|
|
6
|
+
* ActionBar を描画する。 harness も同じ仕様に合わせ、 UZU ボタンは各 iframe cell
|
|
7
|
+
* (mount.ts) 側に置き、 そのボタンへ本メニューを attach する。
|
|
8
|
+
* harness client bundle 内で browser 実行される (Node import 依存なし)。
|
|
9
|
+
*/
|
|
10
|
+
// State Inspector はどの cell の UZU メニューから開いても同じ 1 枚を使う (room は共有)。
|
|
11
|
+
let inspectorEl = null;
|
|
12
|
+
let inspectorTimer = null;
|
|
13
|
+
function isInspectorOpen() {
|
|
14
|
+
return inspectorEl != null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* UZU ボタン (button 要素) にタップメニューを配線する。 メニューはボタン直下に出る。
|
|
18
|
+
*/
|
|
19
|
+
export function attachUzuMenu(button, opts = {}) {
|
|
20
|
+
const { stateGetter, playerCount, resetGame, onOpenMobile } = opts;
|
|
21
|
+
let popup = null;
|
|
22
|
+
const closePopup = () => {
|
|
23
|
+
popup?.remove();
|
|
24
|
+
popup = null;
|
|
25
|
+
};
|
|
26
|
+
button.addEventListener('click', (ev) => {
|
|
27
|
+
ev.stopPropagation();
|
|
28
|
+
if (popup) {
|
|
29
|
+
closePopup();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const el = document.createElement('div');
|
|
33
|
+
popup = el;
|
|
34
|
+
el.style.cssText = [
|
|
35
|
+
'position:fixed',
|
|
36
|
+
'z-index:100001',
|
|
37
|
+
'background:#2d3436',
|
|
38
|
+
'border-radius:8px',
|
|
39
|
+
'padding:12px',
|
|
40
|
+
'box-shadow:0 4px 16px rgba(0,0,0,0.4)',
|
|
41
|
+
'font-family:system-ui,sans-serif',
|
|
42
|
+
'min-width:200px',
|
|
43
|
+
'display:flex',
|
|
44
|
+
'flex-direction:column',
|
|
45
|
+
'gap:8px',
|
|
46
|
+
].join(';');
|
|
47
|
+
const addSeparator = () => {
|
|
48
|
+
if (!el.children.length)
|
|
49
|
+
return;
|
|
50
|
+
const sep = document.createElement('div');
|
|
51
|
+
sep.style.cssText = 'border-top:1px solid #44475a;';
|
|
52
|
+
el.appendChild(sep);
|
|
53
|
+
};
|
|
54
|
+
const addAction = (label, bg, hoverBg, onClick) => {
|
|
55
|
+
const b = document.createElement('button');
|
|
56
|
+
b.textContent = label;
|
|
57
|
+
b.style.cssText = [
|
|
58
|
+
'display:block',
|
|
59
|
+
'width:100%',
|
|
60
|
+
'padding:8px 24px',
|
|
61
|
+
'border:none',
|
|
62
|
+
'border-radius:4px',
|
|
63
|
+
`background:${bg}`,
|
|
64
|
+
'color:#fff',
|
|
65
|
+
'font-size:13px',
|
|
66
|
+
'cursor:pointer',
|
|
67
|
+
'font-family:system-ui,sans-serif',
|
|
68
|
+
].join(';');
|
|
69
|
+
b.onmouseenter = () => {
|
|
70
|
+
b.style.background = hoverBg;
|
|
71
|
+
b.style.color = '#2d3436';
|
|
72
|
+
};
|
|
73
|
+
b.onmouseleave = () => {
|
|
74
|
+
b.style.background = bg;
|
|
75
|
+
b.style.color = '#fff';
|
|
76
|
+
};
|
|
77
|
+
b.onclick = (e) => {
|
|
78
|
+
e.stopPropagation();
|
|
79
|
+
onClick();
|
|
80
|
+
};
|
|
81
|
+
el.appendChild(b);
|
|
82
|
+
return b;
|
|
83
|
+
};
|
|
84
|
+
if (onOpenMobile) {
|
|
85
|
+
addAction('📱 スマホで開く', '#0984e3', '#74b9ff', () => {
|
|
86
|
+
onOpenMobile();
|
|
87
|
+
closePopup();
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
addSeparator();
|
|
91
|
+
addAction('Emulator', '#e17055', '#fab1a0', () => {
|
|
92
|
+
const currentUrl = new URL(window.location.href);
|
|
93
|
+
const endpoint = currentUrl.origin + currentUrl.pathname;
|
|
94
|
+
const emulatorUrl = new URL('https://play-screen-v3-emulator.web.app/');
|
|
95
|
+
emulatorUrl.searchParams.set('endpoint', endpoint);
|
|
96
|
+
if (playerCount) {
|
|
97
|
+
emulatorUrl.searchParams.set('player_count', String(playerCount));
|
|
98
|
+
}
|
|
99
|
+
window.open(emulatorUrl.toString(), '_blank');
|
|
100
|
+
});
|
|
101
|
+
if (stateGetter) {
|
|
102
|
+
addSeparator();
|
|
103
|
+
const stateBtn = addAction(isInspectorOpen() ? 'Hide State' : 'State', '#00b894', '#55efc4', () => {
|
|
104
|
+
if (isInspectorOpen()) {
|
|
105
|
+
closeInspector();
|
|
106
|
+
stateBtn.textContent = 'State';
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
openInspector(stateGetter);
|
|
110
|
+
stateBtn.textContent = 'Hide State';
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (resetGame) {
|
|
115
|
+
addSeparator();
|
|
116
|
+
addAction('Reset State', '#d63031', '#ff7675', () => {
|
|
117
|
+
resetGame();
|
|
118
|
+
closePopup();
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
document.body.appendChild(el);
|
|
122
|
+
const rect = button.getBoundingClientRect();
|
|
123
|
+
el.style.top = `${Math.round(rect.bottom + 6)}px`;
|
|
124
|
+
el.style.left = `${Math.round(rect.left)}px`;
|
|
125
|
+
const close = (e) => {
|
|
126
|
+
if (popup && !popup.contains(e.target) && !button.contains(e.target)) {
|
|
127
|
+
closePopup();
|
|
128
|
+
document.removeEventListener('click', close);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
setTimeout(() => document.addEventListener('click', close), 0);
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function openInspector(getter) {
|
|
135
|
+
if (inspectorEl)
|
|
136
|
+
return;
|
|
137
|
+
inspectorEl = document.createElement('div');
|
|
138
|
+
inspectorEl.style.cssText = [
|
|
139
|
+
'position:fixed',
|
|
140
|
+
'bottom:8px',
|
|
141
|
+
'right:8px',
|
|
142
|
+
'z-index:99998',
|
|
143
|
+
'width:360px',
|
|
144
|
+
'max-height:80vh',
|
|
145
|
+
'overflow:auto',
|
|
146
|
+
'background:#1e272e',
|
|
147
|
+
'border:1px solid #44475a',
|
|
148
|
+
'border-radius:8px',
|
|
149
|
+
'padding:0',
|
|
150
|
+
'box-shadow:0 4px 16px rgba(0,0,0,0.5)',
|
|
151
|
+
'font-family:monospace',
|
|
152
|
+
'font-size:11px',
|
|
153
|
+
'color:#dfe6e9',
|
|
154
|
+
'resize:both',
|
|
155
|
+
'min-width:200px',
|
|
156
|
+
'min-height:120px',
|
|
157
|
+
].join(';');
|
|
158
|
+
const header = document.createElement('div');
|
|
159
|
+
header.style.cssText = [
|
|
160
|
+
'display:flex',
|
|
161
|
+
'justify-content:space-between',
|
|
162
|
+
'align-items:center',
|
|
163
|
+
'padding:6px 10px',
|
|
164
|
+
'background:#2d3436',
|
|
165
|
+
'border-bottom:1px solid #44475a',
|
|
166
|
+
'border-radius:8px 8px 0 0',
|
|
167
|
+
'position:sticky',
|
|
168
|
+
'top:0',
|
|
169
|
+
'cursor:grab',
|
|
170
|
+
'user-select:none',
|
|
171
|
+
].join(';');
|
|
172
|
+
const headerTitle = document.createElement('span');
|
|
173
|
+
headerTitle.textContent = 'State Inspector';
|
|
174
|
+
headerTitle.style.cssText = 'color:#00b894;font-weight:bold;font-size:12px;';
|
|
175
|
+
const closeBtn = document.createElement('button');
|
|
176
|
+
closeBtn.textContent = '×';
|
|
177
|
+
closeBtn.style.cssText =
|
|
178
|
+
'background:none;border:none;color:#dfe6e9;font-size:18px;cursor:pointer;padding:0 4px;';
|
|
179
|
+
closeBtn.onclick = () => closeInspector();
|
|
180
|
+
header.appendChild(headerTitle);
|
|
181
|
+
header.appendChild(closeBtn);
|
|
182
|
+
inspectorEl.appendChild(header);
|
|
183
|
+
let dragOffsetX = 0;
|
|
184
|
+
let dragOffsetY = 0;
|
|
185
|
+
let overlay = null;
|
|
186
|
+
header.addEventListener('mousedown', (e) => {
|
|
187
|
+
if (e.target === closeBtn)
|
|
188
|
+
return;
|
|
189
|
+
const rect = inspectorEl.getBoundingClientRect();
|
|
190
|
+
dragOffsetX = e.clientX - rect.left;
|
|
191
|
+
dragOffsetY = e.clientY - rect.top;
|
|
192
|
+
header.style.cursor = 'grabbing';
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
overlay = document.createElement('div');
|
|
195
|
+
overlay.style.cssText =
|
|
196
|
+
'position:fixed;top:0;left:0;width:100%;height:100%;z-index:99997;cursor:grabbing;';
|
|
197
|
+
document.body.appendChild(overlay);
|
|
198
|
+
const onMouseMove = (ev) => {
|
|
199
|
+
if (!inspectorEl)
|
|
200
|
+
return;
|
|
201
|
+
inspectorEl.style.left = ev.clientX - dragOffsetX + 'px';
|
|
202
|
+
inspectorEl.style.top = ev.clientY - dragOffsetY + 'px';
|
|
203
|
+
inspectorEl.style.right = 'auto';
|
|
204
|
+
inspectorEl.style.bottom = 'auto';
|
|
205
|
+
};
|
|
206
|
+
const onMouseUp = () => {
|
|
207
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
208
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
209
|
+
header.style.cursor = 'grab';
|
|
210
|
+
overlay?.remove();
|
|
211
|
+
overlay = null;
|
|
212
|
+
};
|
|
213
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
214
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
215
|
+
});
|
|
216
|
+
const pre = document.createElement('pre');
|
|
217
|
+
pre.style.cssText = 'margin:0;padding:10px;white-space:pre-wrap;word-break:break-all;';
|
|
218
|
+
inspectorEl.appendChild(pre);
|
|
219
|
+
document.body.appendChild(inspectorEl);
|
|
220
|
+
const update = () => {
|
|
221
|
+
const snap = getter();
|
|
222
|
+
if (!snap) {
|
|
223
|
+
pre.textContent = '(no state)';
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
pre.innerHTML = syntaxHighlight(snap.state);
|
|
227
|
+
};
|
|
228
|
+
update();
|
|
229
|
+
inspectorTimer = setInterval(update, 200);
|
|
230
|
+
}
|
|
231
|
+
function closeInspector() {
|
|
232
|
+
if (inspectorTimer) {
|
|
233
|
+
clearInterval(inspectorTimer);
|
|
234
|
+
inspectorTimer = null;
|
|
235
|
+
}
|
|
236
|
+
if (inspectorEl) {
|
|
237
|
+
inspectorEl.remove();
|
|
238
|
+
inspectorEl = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function syntaxHighlight(obj) {
|
|
242
|
+
const json = JSON.stringify(obj, null, 2);
|
|
243
|
+
return json
|
|
244
|
+
.replace(/("(?:\\.|[^"\\])*")\s*:/g, '<span style="color:#81ecec">$1</span>:')
|
|
245
|
+
.replace(/:\s*("(?:\\.|[^"\\])*")/g, ': <span style="color:#ffeaa7">$1</span>')
|
|
246
|
+
.replace(/:\s*(\d+\.?\d*)/g, ': <span style="color:#fd79a8">$1</span>')
|
|
247
|
+
.replace(/:\s*(true|false)/g, ': <span style="color:#a29bfe">$1</span>')
|
|
248
|
+
.replace(/:\s*(null)/g, ': <span style="color:#636e72">$1</span>');
|
|
249
|
+
}
|