@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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +64 -0
  3. package/dist/auth/browser.js +22 -0
  4. package/dist/auth/config.js +48 -0
  5. package/dist/auth/env.js +42 -0
  6. package/dist/auth/jwt.js +27 -0
  7. package/dist/auth/login-flow.js +44 -0
  8. package/dist/auth/loopback.js +94 -0
  9. package/dist/auth/pkce.js +11 -0
  10. package/dist/auth/publish-token.js +74 -0
  11. package/dist/auth/token-cache.js +70 -0
  12. package/dist/auth/uzu-auth.js +94 -0
  13. package/dist/build-server-logic.js +28 -0
  14. package/dist/cf-images-upload.js +52 -0
  15. package/dist/cli.js +303 -0
  16. package/dist/create-2d-game.js +56 -0
  17. package/dist/dev-server/game-room.js +436 -0
  18. package/dist/dev-server/game-types.js +11 -0
  19. package/dist/dev-server/json-patch.js +114 -0
  20. package/dist/dev-server/load-logic.js +70 -0
  21. package/dist/dev-server/random.js +35 -0
  22. package/dist/dev-server/relay-room.js +84 -0
  23. package/dist/dev-server/server.js +367 -0
  24. package/dist/dev-server/sync-room.js +268 -0
  25. package/dist/dev.js +235 -0
  26. package/dist/harness/admin-client.js +215 -0
  27. package/dist/harness/client-entry.js +90 -0
  28. package/dist/harness/dev-button.js +249 -0
  29. package/dist/harness/mount.js +664 -0
  30. package/dist/harness/page.js +46 -0
  31. package/dist/r2-upload.js +93 -0
  32. package/dist/rest-register.js +50 -0
  33. package/dist/upload-session.js +71 -0
  34. package/game-2d-template/index.html.tpl +18 -0
  35. package/game-2d-template/manifest.json.tpl +6 -0
  36. package/game-2d-template/package.json.tpl +23 -0
  37. package/game-2d-template/src/main.ts +49 -0
  38. package/game-2d-template/src/vite-env.d.ts +1 -0
  39. package/game-2d-template/tsconfig.json +12 -0
  40. package/game-2d-template/vite.config.ts +6 -0
  41. package/package.json +43 -0
@@ -0,0 +1,664 @@
1
+ /**
2
+ * Dev harness の iframe grid を parent frame に描画する。
3
+ *
4
+ * 子 iframe URL には `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&
5
+ * seats=<json>&revisionId=dev` を付ける。 SDK は既存 online mode で dev-server に
6
+ * 接続する (本番 Cloudflare Worker と同じ shape)。
7
+ */
8
+ import qrcode from 'qrcode-generator';
9
+ import { attachUzuMenu } from './dev-button.js';
10
+ // ── 本番アプリ (Flutter play_screen_v3 GamePlayScreen) の overlay 仕様に合わせた定数 ──
11
+ // SafeArea 内 top:8 / left:12、 UZU ボタン 44、 gap 6、 ActionBar は height:36 の
12
+ // ピル。 アイコン size:18 / 左右 padding:10。 game 側は SDK が uzuHudInsetX/Y から
13
+ // --uzu-hud-inset-x/y を立て、 この矩形を避ける。
14
+ const HUD_TOP = 8;
15
+ const HUD_LEFT = 12;
16
+ const HUD_UZU_SIZE = 44;
17
+ const HUD_GAP = 6;
18
+ const HUD_ACTION_ITEM_WIDTH = 38;
19
+ // UZU ロゴ (28×28、 白)。 本番 brand_uzu_white.svg と同じ渦巻きマーク。
20
+ const UZU_LOGO_SVG = `<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
21
+ <path d="M5.58178 17.2748C4.86125 13.8314 7.60805 8.50894 11.366 7.67694C12.0626 8.32582 12.2856 10.153 12.1701 11.0726C10.7171 11.2637 7.6797 14.1578 7.97429 17.4022C8.02604 17.9595 8.70677 18.278 8.70677 18.278C7.36522 19.3807 5.91618 18.8672 5.58576 17.2748" fill="#BEBEFF"/>
22
+ <path d="M18.6032 12.4103C15.8843 12.9557 14.28 15.6826 14.288 17.8044C14.2919 19.2096 15.7489 19.7231 16.7442 18.8832C16.143 18.927 15.9838 18.5687 15.9838 18.3259C15.9679 16.9326 17.5921 15.0615 18.8102 15.0456C19.065 14.3649 18.9098 12.9039 18.6032 12.4143" fill="#BEBEFF"/>
23
+ <path d="M0.872407 19.5798C-0.684116 15.8935 -0.29399 9.47636 3.15345 5.77415C6.91538 1.73754 13.038 2.19534 14.686 6.56634C16.2983 10.8378 13.9894 17.0321 10.749 19.54C8.83018 21.0249 5.89627 20.376 5.57382 17.2032C5.90025 18.302 7.28162 18.6443 8.25295 17.8243C9.76568 16.5424 11.4536 13.915 11.6168 11.2757C11.9034 6.74946 7.92254 5.59501 4.87319 8.38958C0.75298 12.1674 1.75218 18.2462 1.89549 18.9827C2.0388 19.7192 2.14629 20.1332 1.76412 20.2765C1.44565 20.3999 1.11126 20.1531 0.872407 19.5838" fill="white"/>
24
+ <path d="M22.5562 13.2024C22.5602 14.2175 22.393 15.2366 22.0467 16.196C22.0188 16.2716 21.991 16.3433 21.9631 16.4189C22.3532 17.6212 21.5929 19.0622 20.9878 19.739C20.5777 20.1968 20.9758 20.4237 21.5093 20.2087C22.2378 19.9102 23.9257 18.6084 23.9973 16.7255C24.0451 15.4675 23.4519 13.8911 22.5562 13.2024Z" fill="white"/>
25
+ <path d="M16.2505 6.82111C16.2505 6.82111 16.2505 6.82509 16.2505 6.82907C16.2426 6.82907 16.2306 6.82907 16.2227 6.82907C16.5491 7.99944 16.6645 9.30119 16.561 10.6666C18.3126 11.2558 19.0212 13.4254 18.5355 15.3521C18.0578 17.259 16.7163 18.4891 15.8843 18.5726C14.8692 18.6762 14.284 17.876 14.3397 17.1714C14.1128 18.505 14.4552 20.2526 16.1032 20.3043C18.3843 20.376 19.9328 18.7956 21.0077 15.8298C22.3811 12.044 20.5539 7.29483 16.2465 6.82907" fill="white"/>
26
+ </svg>`;
27
+ // マイク (micOn 相当) と 携帯 (実機で開く用)。 ActionBar は本番同様 2 アイコン。
28
+ const MIC_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
29
+ <path d="M12 1C10.34 1 9 2.34 9 4V12C9 13.66 10.34 15 12 15C13.66 15 15 13.66 15 12V4C15 2.34 13.66 1 12 1Z" fill="white"/>
30
+ <path d="M17 12C17 14.76 14.76 17 12 17C9.24 17 7 14.76 7 12H5C5 15.53 7.61 18.43 11 18.93V22H13V18.93C16.39 18.43 19 15.53 19 12H17Z" fill="white"/>
31
+ </svg>`;
32
+ const PHONE_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
33
+ <rect x="6.5" y="2" width="11" height="20" rx="2.5" stroke="white" stroke-width="2"/>
34
+ <line x1="10" y1="18.5" x2="14" y2="18.5" stroke="white" stroke-width="2" stroke-linecap="round"/>
35
+ </svg>`;
36
+ /** buildIframeUrl に渡す HUD の避け領域 (Flutter HudInsets と同じ計算)。 */
37
+ function hudInsetParams(hasMenu, actionCount) {
38
+ let x = HUD_LEFT;
39
+ if (hasMenu)
40
+ x += HUD_UZU_SIZE;
41
+ if (actionCount > 0) {
42
+ if (hasMenu)
43
+ x += HUD_GAP;
44
+ x += HUD_ACTION_ITEM_WIDTH * actionCount;
45
+ }
46
+ return { x, y: HUD_TOP + HUD_UZU_SIZE };
47
+ }
48
+ // grid セルの擬似ノッチ (iPhone Pro 横向き相当、 notch は左固定)。 iframe 内は
49
+ // 親の env(safe-area-inset) を継承しないため、 docs の「viewport-fit 未指定」挙動
50
+ // (= SafeArea 内へ物理縮小、 外帯は背景色) を harness 側で再現する。
51
+ const SIM_NOTCH = { top: 0, right: 59, bottom: 21, left: 59, radius: 44 };
52
+ /** 擬似ノッチ装飾 (Dynamic Island 風ピル + ホームインジケータ)。 pointer-events なし。 */
53
+ function createNotchDecor() {
54
+ const decor = document.createElement('div');
55
+ decor.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:7;';
56
+ // Dynamic Island 風の縦ピル (左帯の中、 縦中央)。 黒帯上でも見えるよう薄い縁取り。
57
+ const island = document.createElement('div');
58
+ island.style.cssText = [
59
+ 'position:absolute;left:16px;top:50%;transform:translateY(-50%)',
60
+ 'width:26px;height:88px;border-radius:13px',
61
+ 'background:#000;border:1px solid rgba(255,255,255,0.12)',
62
+ ].join(';');
63
+ // ホームインジケータ (下帯の中央)。
64
+ const home = document.createElement('div');
65
+ home.style.cssText = [
66
+ 'position:absolute;bottom:7px;left:50%;transform:translateX(-50%)',
67
+ 'width:32%;max-width:180px;height:4px;border-radius:2px',
68
+ 'background:rgba(255,255,255,0.5)',
69
+ ].join(';');
70
+ decor.append(island, home);
71
+ return decor;
72
+ }
73
+ /**
74
+ * `?player=N` 用: 1 プレイヤー分の iframe を全画面で mount する。
75
+ * スマホ実機 (同一 LAN) から 1 キャラの画面をそのまま操作する用途。
76
+ * grid 版と同じ query を組み立てるので、 PC 側 grid の同じ playerId と
77
+ * 同一 GameRoom / 同一キャラとして同期する。
78
+ */
79
+ /**
80
+ * ⛶ 全画面ボタン。 iOS Safari 16.4+ / Android Chrome で URL バーごと消せる。
81
+ * standalone 起動 (ホーム画面に追加) や Fullscreen API 非対応環境では出さない。
82
+ */
83
+ function mountFullscreenButton(target) {
84
+ const doc = document;
85
+ const root = document.documentElement;
86
+ const supported = Boolean(root.requestFullscreen || root.webkitRequestFullscreen);
87
+ const standalone = matchMedia('(display-mode: standalone)').matches;
88
+ if (!supported || standalone)
89
+ return;
90
+ const btn = document.createElement('button');
91
+ btn.textContent = '⛶';
92
+ btn.title = '全画面';
93
+ btn.style.cssText = [
94
+ 'position:absolute',
95
+ 'right:calc(10px + env(safe-area-inset-right))',
96
+ 'top:calc(10px + env(safe-area-inset-top))',
97
+ 'z-index:10',
98
+ 'width:40px',
99
+ 'height:40px',
100
+ 'border:none',
101
+ 'border-radius:10px',
102
+ 'background:rgba(0,0,0,0.45)',
103
+ 'color:#cfd8ea',
104
+ 'font-size:20px',
105
+ 'line-height:1',
106
+ 'cursor:pointer',
107
+ 'backdrop-filter:blur(4px)',
108
+ ].join(';');
109
+ const isFullscreen = () => Boolean(document.fullscreenElement ?? doc.webkitFullscreenElement);
110
+ const sync = () => {
111
+ btn.textContent = isFullscreen() ? '✕' : '⛶';
112
+ btn.style.opacity = isFullscreen() ? '0.35' : '1';
113
+ };
114
+ btn.onclick = () => {
115
+ if (isFullscreen()) {
116
+ (document.exitFullscreen ?? doc.webkitExitFullscreen)?.call(document);
117
+ return;
118
+ }
119
+ (root.requestFullscreen ?? root.webkitRequestFullscreen)?.call(root);
120
+ };
121
+ document.addEventListener('fullscreenchange', sync);
122
+ document.addEventListener('webkitfullscreenchange', sync);
123
+ target.appendChild(btn);
124
+ }
125
+ export function mountSinglePlayer(opts, playerIndex) {
126
+ document.documentElement.style.cssText =
127
+ 'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#0b0b16;';
128
+ // position:relative にして iframe の width/height calc(100% - inset) の基準を target にする。
129
+ opts.target.style.cssText =
130
+ 'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#0b0b16;position:relative;';
131
+ const iframe = document.createElement('iframe');
132
+ iframe.src = buildIframeUrl(opts, playerIndex);
133
+ // 本番 Flutter (viewport-fit=auto) の safePadding と同じく、 iframe を safe-area で
134
+ // 物理 inset する。 iframe 内は top-level の env を継承しない (0 になる) ため、
135
+ // parent (= top-level、 page.ts で viewport-fit=cover) 側の env で inset する。
136
+ // iframe は置換要素で top/bottom だけでは伸びないので width/height を明示する。
137
+ iframe.style.cssText = [
138
+ 'border:none;display:block;position:absolute',
139
+ 'top:env(safe-area-inset-top, 0px)',
140
+ 'left:env(safe-area-inset-left, 0px)',
141
+ 'width:calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px))',
142
+ 'height:calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))',
143
+ ].join(';');
144
+ iframe.allow = 'autoplay';
145
+ const splash = document.createElement('div');
146
+ splash.style.cssText = [
147
+ 'position:absolute;inset:0;z-index:5',
148
+ 'display:flex;align-items:center;justify-content:center',
149
+ 'background:#0b0b16;color:#3a4a6a',
150
+ 'font-family:monospace;font-size:12px;letter-spacing:0.08em',
151
+ 'transition:opacity 0.3s',
152
+ ].join(';');
153
+ splash.textContent = 'waiting firstFrameReady…';
154
+ opts.target.appendChild(iframe);
155
+ opts.target.appendChild(splash);
156
+ // 実機でも本番アプリと同じ overlay chrome を出す (ハリボテ)。 admin 席は端末を
157
+ // 模す必要が無いので載せない。 UZU ボタンだけは「他プレイヤー画面へ切替」に配線する。
158
+ if (seatAt(opts, playerIndex).kind !== 'admin') {
159
+ const chrome = createCellChrome(opts, playerIndex, true, undefined, () => {
160
+ const uzuBtn = chrome.querySelector('button');
161
+ if (uzuBtn)
162
+ openPlayerSwitcher(opts, playerIndex, uzuBtn);
163
+ });
164
+ opts.target.appendChild(chrome);
165
+ }
166
+ mountFullscreenButton(opts.target);
167
+ window.addEventListener('message', (event) => {
168
+ if (event.source !== iframe.contentWindow)
169
+ return;
170
+ let data = null;
171
+ try {
172
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
173
+ }
174
+ catch {
175
+ return;
176
+ }
177
+ if (!data || data.channel !== 'sdk')
178
+ return;
179
+ if (data.type === 'firstFrameReady') {
180
+ splash.style.opacity = '0';
181
+ splash.style.pointerEvents = 'none';
182
+ setTimeout(() => splash.remove(), 350);
183
+ }
184
+ });
185
+ }
186
+ const CELL_ASPECT_RATIO = { portrait: 9.0 / 19.5, landscape: 19.5 / 9.0 };
187
+ const GAP = 4;
188
+ const optimalGrid = (areaWidth, areaHeight, count, cellAspectRatio) => {
189
+ let bestCols = 1;
190
+ let bestRows = count;
191
+ let bestCellArea = 0;
192
+ for (let cols = 1; cols <= count; cols++) {
193
+ const rows = Math.ceil(count / cols);
194
+ const slotW = (areaWidth - GAP * (cols - 1)) / cols;
195
+ const slotH = (areaHeight - GAP * (rows - 1)) / rows;
196
+ let cellW;
197
+ let cellH;
198
+ if (slotW / slotH > cellAspectRatio) {
199
+ cellH = slotH;
200
+ cellW = slotH * cellAspectRatio;
201
+ }
202
+ else {
203
+ cellW = slotW;
204
+ cellH = slotW / cellAspectRatio;
205
+ }
206
+ const area = cellW * cellH;
207
+ if (area > bestCellArea) {
208
+ bestCellArea = area;
209
+ bestCols = cols;
210
+ bestRows = rows;
211
+ }
212
+ }
213
+ return { cols: bestCols, rows: bestRows };
214
+ };
215
+ const computeGridCellSize = (areaWidth, areaHeight, count, cellAspectRatio) => {
216
+ const { cols, rows } = optimalGrid(areaWidth, areaHeight, count, cellAspectRatio);
217
+ const slotW = (areaWidth - GAP * (cols - 1)) / cols;
218
+ const slotH = (areaHeight - GAP * (rows - 1)) / rows;
219
+ let cellW;
220
+ let cellH;
221
+ if (slotW / slotH > cellAspectRatio) {
222
+ cellH = slotH;
223
+ cellW = slotH * cellAspectRatio;
224
+ }
225
+ else {
226
+ cellW = slotW;
227
+ cellH = slotW / cellAspectRatio;
228
+ }
229
+ return { cols, rows, cellW, cellH };
230
+ };
231
+ function createLifecycleStatusRow() {
232
+ const row = document.createElement('div');
233
+ row.style.cssText =
234
+ 'display:flex;justify-content:center;gap:8px;font-size:9px;padding-top:1px;font-family:monospace;color:#3a4a6a;';
235
+ const entries = [
236
+ { key: 'ready', label: 'init' },
237
+ { key: 'firstFrameReady', label: 'firstFrame' },
238
+ { key: 'gameReady', label: 'game' },
239
+ ];
240
+ const dots = {
241
+ ready: document.createElement('span'),
242
+ firstFrameReady: document.createElement('span'),
243
+ gameReady: document.createElement('span'),
244
+ };
245
+ for (const { key, label } of entries) {
246
+ const item = document.createElement('span');
247
+ item.style.cssText = 'display:inline-flex;align-items:center;gap:2px;';
248
+ const dot = dots[key];
249
+ dot.textContent = '●';
250
+ dot.style.cssText = 'color:#3a4a6a;transition:color 0.15s;';
251
+ const text = document.createElement('span');
252
+ text.textContent = label;
253
+ item.appendChild(dot);
254
+ item.appendChild(text);
255
+ row.appendChild(item);
256
+ }
257
+ return { el: row, dots };
258
+ }
259
+ function seatAt(opts, index) {
260
+ const seat = opts.seats[index];
261
+ if (!seat)
262
+ throw new Error(`seat index out of range: ${index}`);
263
+ return seat;
264
+ }
265
+ function isPlayerSeat(seat) {
266
+ return seat.kind === 'player';
267
+ }
268
+ /** SDK が読む wire 形式 (nickname → name)。 */
269
+ function toWireSeat(seat) {
270
+ return {
271
+ id: seat.id,
272
+ name: seat.nickname,
273
+ iconUrl: seat.iconUrl,
274
+ characterId: seat.characterId,
275
+ kind: seat.kind,
276
+ };
277
+ }
278
+ function buildIframeUrl(opts, playerIndex) {
279
+ const url = new URL(opts.scenarioUrl);
280
+ const seat = seatAt(opts, playerIndex);
281
+ url.searchParams.set('server', opts.serverBaseUrl);
282
+ url.searchParams.set('roomId', opts.roomKey);
283
+ url.searchParams.set('seatId', seat.id);
284
+ url.searchParams.set('seats', JSON.stringify(opts.seats.map(toWireSeat)));
285
+ url.searchParams.set('revisionId', opts.revisionId);
286
+ // SDK は解釈しないが、 scenario 側で「parent は harness page (audio consumer 無し) か」を
287
+ // 判定するマーカーとして残す (tenbo の __uzuAudio bridge スキップ判定など)。
288
+ url.searchParams.set('__dev_player', String(playerIndex));
289
+ url.searchParams.set('__dev_room', opts.roomKey);
290
+ if (seat.kind === 'admin') {
291
+ // admin 席は本番 overlay chrome を載せないため避け領域も不要。
292
+ url.searchParams.set('uzuHudInsetX', '0');
293
+ url.searchParams.set('uzuHudInsetY', '0');
294
+ return url.toString();
295
+ }
296
+ // 本番同様、 UZU ボタン + ActionBar(chat/mic) の避け領域を game に伝える。
297
+ const hud = hudInsetParams(true, 2);
298
+ url.searchParams.set('uzuHudInsetX', String(hud.x));
299
+ url.searchParams.set('uzuHudInsetY', String(hud.y));
300
+ return url.toString();
301
+ }
302
+ /**
303
+ * その player を実機 (同一 LAN) で開く URL。 localhost で harness を見ているときは
304
+ * mDNS 名 (lanHosts 先頭) を、 すでに実機で見ているときは現在の host を使う。
305
+ */
306
+ function resolveMobileUrl(opts, playerIndex) {
307
+ const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
308
+ const host = isLocal ? opts.lanHosts?.[0] : location.hostname;
309
+ if (!host)
310
+ return null;
311
+ const port = location.port ? `:${location.port}` : '';
312
+ return `http://${host}${port}/?player=${playerIndex}`;
313
+ }
314
+ /** 携帯リンクの QR ポップアップ (カメラで読めばそのキャラの画面がスマホで開く)。 */
315
+ function openMobileQrPopup(url, label) {
316
+ const backdrop = document.createElement('div');
317
+ backdrop.style.cssText = [
318
+ 'position:fixed;inset:0;z-index:100000',
319
+ 'background:rgba(0,0,0,0.6)',
320
+ 'display:flex;align-items:center;justify-content:center',
321
+ ].join(';');
322
+ const panel = document.createElement('div');
323
+ panel.style.cssText = [
324
+ 'background:#2d3436;border-radius:12px;padding:20px',
325
+ 'display:flex;flex-direction:column;align-items:center;gap:10px',
326
+ 'font-family:system-ui,sans-serif;color:#fff;max-width:80vw',
327
+ ].join(';');
328
+ const heading = document.createElement('div');
329
+ heading.textContent = `📱 ${label}`;
330
+ heading.style.cssText = 'font-size:14px;font-weight:bold;';
331
+ const note = document.createElement('div');
332
+ note.textContent = 'QR をカメラで読むと、 このキャラの画面がスマホで開きます';
333
+ note.style.cssText = 'font-size:11px;color:#a0a8b8;';
334
+ const qrWrap = document.createElement('div');
335
+ qrWrap.style.cssText = 'background:#fff;padding:10px;border-radius:8px;line-height:0;';
336
+ const qr = qrcode(0, 'M');
337
+ qr.addData(url);
338
+ qr.make();
339
+ qrWrap.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0, scalable: true });
340
+ const svg = qrWrap.querySelector('svg');
341
+ if (svg) {
342
+ svg.style.width = '200px';
343
+ svg.style.height = '200px';
344
+ }
345
+ const urlEl = document.createElement('a');
346
+ urlEl.textContent = url;
347
+ urlEl.href = url;
348
+ urlEl.target = '_blank';
349
+ urlEl.style.cssText = 'font-size:12px;color:#8be9fd;word-break:break-all;text-align:center;';
350
+ panel.append(heading, note, qrWrap, urlEl);
351
+ backdrop.appendChild(panel);
352
+ backdrop.onclick = () => backdrop.remove();
353
+ panel.onclick = (e) => e.stopPropagation();
354
+ document.body.appendChild(backdrop);
355
+ }
356
+ /**
357
+ * 単体表示 (実機) の UZU ボタン用プレイヤー切替。 `?player=N` を差し替えて
358
+ * 同じ端末で他キャラ画面に飛ぶ (QR を読み直さず切替できる)。 anchor 直下に出す。
359
+ */
360
+ function openPlayerSwitcher(opts, currentIndex, anchor) {
361
+ const existing = document.getElementById('__uzu_player_switcher');
362
+ if (existing) {
363
+ existing.remove();
364
+ return;
365
+ }
366
+ const panel = document.createElement('div');
367
+ panel.id = '__uzu_player_switcher';
368
+ panel.style.cssText = [
369
+ 'position:fixed;z-index:100001',
370
+ 'background:#2d3436;border-radius:10px;padding:8px',
371
+ 'box-shadow:0 4px 16px rgba(0,0,0,0.5)',
372
+ 'font-family:system-ui,sans-serif;color:#fff',
373
+ 'display:flex;flex-direction:column;gap:4px;min-width:180px',
374
+ 'max-height:70vh;overflow-y:auto',
375
+ ].join(';');
376
+ const heading = document.createElement('div');
377
+ heading.textContent = '画面を切り替え';
378
+ heading.style.cssText = 'font-size:11px;color:#a0a8b8;padding:2px 8px 6px;';
379
+ panel.appendChild(heading);
380
+ for (let i = 0; i < opts.seats.length; i++) {
381
+ const seat = seatAt(opts, i);
382
+ const isCurrent = i === currentIndex;
383
+ const row = document.createElement('button');
384
+ row.textContent = `${isCurrent ? '● ' : ''}${seat.nickname} (${seat.id})`;
385
+ row.style.cssText = [
386
+ 'text-align:left;border:none;border-radius:6px',
387
+ 'padding:10px 12px;font-size:14px;cursor:pointer',
388
+ `background:${isCurrent ? '#0984e3' : '#3a4553'};color:#fff`,
389
+ ].join(';');
390
+ row.onclick = (e) => {
391
+ e.stopPropagation();
392
+ if (isCurrent) {
393
+ panel.remove();
394
+ return;
395
+ }
396
+ const url = new URL(location.href);
397
+ url.searchParams.set('player', String(i));
398
+ location.href = url.toString();
399
+ };
400
+ panel.appendChild(row);
401
+ }
402
+ document.body.appendChild(panel);
403
+ const rect = anchor.getBoundingClientRect();
404
+ panel.style.top = `${Math.round(rect.bottom + 6)}px`;
405
+ panel.style.left = `${Math.round(rect.left)}px`;
406
+ const close = (e) => {
407
+ if (!panel.contains(e.target) && !anchor.contains(e.target)) {
408
+ panel.remove();
409
+ document.removeEventListener('click', close);
410
+ }
411
+ };
412
+ setTimeout(() => document.addEventListener('click', close), 0);
413
+ }
414
+ /**
415
+ * 各 iframe (= 各プレイヤー画面) の左上に載せる overlay chrome。
416
+ * 本番アプリ (Flutter GamePlayScreen) と同じ仕様:
417
+ * SafeArea top:8 / left:12 → [UZU ボタン 44] [gap 6] [ActionBar ピル: chat, mic]。
418
+ * UZU ボタンは dev メニュー (State/Reset/Emulator) + その player の「スマホで開く」を開く。
419
+ * chat / mic は本番の見た目に合わせた dev 表示 (機能は SDK 側が担うため配線しない)。
420
+ */
421
+ function createCellChrome(opts, playerIndex,
422
+ // facade=true (実機の単体表示) では見た目だけ揃える。 admin channel が無いので
423
+ // UZU メニュー / 実機 QR は配線せず、 アイコンはタップ無効 (ハリボテ)。
424
+ facade = false,
425
+ // grid では no-op のマイクアイコンを擬似ノッチ表示トグルに転用する。
426
+ onMicClick,
427
+ // 単体表示 (実機) では UZU ボタンをプレイヤー切替に転用する。
428
+ onUzuClick) {
429
+ const wrap = document.createElement('div');
430
+ wrap.style.cssText = [
431
+ 'position:absolute',
432
+ // notch / status bar を避けるため safe-area を足す (PC harness では env=0)。
433
+ `top:calc(${HUD_TOP}px + env(safe-area-inset-top, 0px))`,
434
+ `left:calc(${HUD_LEFT}px + env(safe-area-inset-left, 0px))`,
435
+ 'z-index:6',
436
+ 'display:flex;align-items:center;gap:6px',
437
+ ].join(';');
438
+ const uzu = document.createElement('button');
439
+ const uzuInteractive = Boolean(onUzuClick) || !facade;
440
+ uzu.title = onUzuClick ? '画面を切り替え' : 'UZU メニュー';
441
+ uzu.innerHTML = UZU_LOGO_SVG;
442
+ uzu.style.cssText = [
443
+ `width:${HUD_UZU_SIZE}px;height:${HUD_UZU_SIZE}px`,
444
+ 'border:none;border-radius:12px',
445
+ 'background:rgba(0,0,0,0.6)',
446
+ `cursor:${uzuInteractive ? 'pointer' : 'default'};padding:0`,
447
+ 'display:flex;align-items:center;justify-content:center',
448
+ 'flex-shrink:0',
449
+ ].join(';');
450
+ const mobileUrl = resolveMobileUrl(opts, playerIndex);
451
+ const label = seatAt(opts, playerIndex).nickname;
452
+ if (onUzuClick) {
453
+ uzu.onclick = onUzuClick;
454
+ }
455
+ else if (!facade) {
456
+ attachUzuMenu(uzu, {
457
+ stateGetter: opts.stateGetter,
458
+ resetGame: opts.resetGame,
459
+ playerCount: opts.playerCount,
460
+ });
461
+ }
462
+ wrap.appendChild(uzu);
463
+ const bar = document.createElement('div');
464
+ bar.style.cssText = [
465
+ 'height:36px',
466
+ 'background:rgba(0,0,0,0.6)',
467
+ 'border-radius:18px',
468
+ 'display:flex;align-items:center',
469
+ ].join(';');
470
+ // 1つめ: マイク。 本番は音声トグルだが harness では no-op なので、 grid では
471
+ // 擬似ノッチ表示のトグルに転用する (単体表示 facade では装飾のみ)。
472
+ const mic = document.createElement(onMicClick ? 'button' : 'div');
473
+ mic.innerHTML = MIC_SVG;
474
+ mic.title = onMicClick ? '擬似ノッチ表示 ON/OFF' : 'マイク (dev 表示のみ)';
475
+ mic.style.cssText = onMicClick
476
+ ? 'padding:0 10px;height:36px;background:none;border:none;cursor:pointer;display:flex;align-items:center;'
477
+ : 'padding:0 10px;display:flex;align-items:center;';
478
+ if (onMicClick)
479
+ mic.onclick = onMicClick;
480
+ bar.appendChild(mic);
481
+ // 2つめ: 携帯 (その player を実機で開く QR)。 facade では装飾のみ。
482
+ const phone = document.createElement('button');
483
+ phone.innerHTML = PHONE_SVG;
484
+ phone.title = facade ? '' : 'スマホ実機で開く (QR)';
485
+ phone.style.cssText = [
486
+ 'padding:0 10px;height:36px',
487
+ `background:none;border:none;cursor:${facade ? 'default' : 'pointer'}`,
488
+ 'display:flex;align-items:center',
489
+ ].join(';');
490
+ if (!facade) {
491
+ phone.onclick = () => {
492
+ if (mobileUrl)
493
+ openMobileQrPopup(mobileUrl, label);
494
+ };
495
+ }
496
+ bar.appendChild(phone);
497
+ wrap.appendChild(bar);
498
+ return wrap;
499
+ }
500
+ export function mountIframeGrid(opts) {
501
+ const { target, playerCount, orientation, devMinIframeShortEdge } = opts;
502
+ document.documentElement.style.cssText =
503
+ 'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#1a1a2e;';
504
+ const cells = [];
505
+ const iframeStatusDots = new Map();
506
+ const iframeSplashes = new Map();
507
+ // 擬似ノッチ (各 cell 共通トグル)。 iframe を再生成せず inset だけ切り替える。
508
+ let notchSimOn = true;
509
+ const notchAppliers = [];
510
+ const toggleNotchSim = () => {
511
+ notchSimOn = !notchSimOn;
512
+ for (const apply of notchAppliers)
513
+ apply(notchSimOn);
514
+ };
515
+ const layout = () => {
516
+ const n = cells.length;
517
+ if (n === 0)
518
+ return;
519
+ const cellAR = CELL_ASPECT_RATIO[orientation];
520
+ const targetW = target === document.body ? window.innerWidth : target.clientWidth;
521
+ const targetH = target === document.body ? window.innerHeight : target.clientHeight;
522
+ const naive = computeGridCellSize(targetW, targetH, n, cellAR);
523
+ const naiveShortEdge = Math.min(naive.cellW, naive.cellH);
524
+ const zoom = Math.min(1, naiveShortEdge / devMinIframeShortEdge);
525
+ const areaW = targetW / zoom;
526
+ const areaH = targetH / zoom;
527
+ const { cols, rows, cellW, cellH } = computeGridCellSize(areaW, areaH, n, cellAR);
528
+ target.style.cssText = [
529
+ `margin:0;padding:0;width:${areaW}px;height:${areaH}px;overflow:hidden`,
530
+ 'display:grid',
531
+ `grid-template-columns:repeat(${cols},${cellW}px)`,
532
+ `grid-template-rows:repeat(${rows},${cellH}px)`,
533
+ 'justify-content:center',
534
+ 'align-content:center',
535
+ `gap:${GAP}px`,
536
+ 'font-family:system-ui,sans-serif',
537
+ 'background:#1a1a2e',
538
+ // 縮小表示は zoom ではなく transform: scale で行う。 zoom は layout プロパティ
539
+ // なので WebKit では iframe 内部の percentage height / vh の解決に物理 rect
540
+ // (zoom 適用後) が混入し、 iframe 内 body が viewport より小さくレンダリング
541
+ // される (Safari で下部に白い letterbox が出る)。 transform は視覚のみで
542
+ // iframe 内 layout に波及しないため engine 差が出ない。
543
+ ...(zoom < 1 ? [`transform:scale(${zoom})`, 'transform-origin:top left'] : []),
544
+ ].join(';');
545
+ for (const cell of cells) {
546
+ cell.style.cssText = `width:${cellW}px;height:${cellH}px;display:flex;flex-direction:column;overflow:hidden;`;
547
+ }
548
+ };
549
+ const render = () => {
550
+ target.innerHTML = '';
551
+ cells.length = 0;
552
+ iframeStatusDots.clear();
553
+ iframeSplashes.clear();
554
+ for (let i = 0; i < opts.seats.length; i++) {
555
+ const seat = seatAt(opts, i);
556
+ const cell = document.createElement('div');
557
+ const label = document.createElement('div');
558
+ // admin は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
559
+ const labelColor = seat.kind === 'admin' ? '#ffb86c' : '#8be9fd';
560
+ label.style.cssText = `color:${labelColor};font-size:12px;padding:2px 0;flex-shrink:0;background:#16213e;text-align:center;`;
561
+ const nameRow = document.createElement('div');
562
+ nameRow.textContent = isPlayerSeat(seat)
563
+ ? `Player ${i + 1} (${seat.id})`
564
+ : `${seat.kind === 'admin' ? '⚙' : '👁'} ${seat.nickname} (${seat.id})`;
565
+ label.appendChild(nameRow);
566
+ const statusRow = createLifecycleStatusRow();
567
+ label.appendChild(statusRow.el);
568
+ const iframe = document.createElement('iframe');
569
+ iframe.src = buildIframeUrl(opts, i);
570
+ iframe.style.cssText =
571
+ 'border:none;display:block;position:absolute;inset:0;width:100%;height:100%;';
572
+ // frameWrap = 端末ベゼル (黒)、 screen = 画面 (擬似ノッチ時は SafeArea 内へ縮小)。
573
+ const frameWrap = document.createElement('div');
574
+ frameWrap.style.cssText =
575
+ 'flex:1;position:relative;min-height:0;overflow:hidden;background:#000;';
576
+ const screen = document.createElement('div');
577
+ screen.style.cssText = 'position:absolute;overflow:hidden;background:#000;';
578
+ // 本番ホスト (mobile WebView / Web emulator) は firstFrameReady 受信まで
579
+ // splash overlay で WebView を隠す。 harness も同じ挙動にしないと、 scenario が
580
+ // firstFrameReady を呼び忘れても harness では動いて見えてしまい、 publish 後に
581
+ // 初めて「splash が外れない」事故が発覚する。
582
+ const splash = document.createElement('div');
583
+ splash.style.cssText = [
584
+ 'position:absolute;inset:0;z-index:5',
585
+ 'display:flex;align-items:center;justify-content:center',
586
+ 'background:#0b0b16;color:#3a4a6a',
587
+ 'font-family:monospace;font-size:11px;letter-spacing:0.08em',
588
+ 'transition:opacity 0.3s',
589
+ ].join(';');
590
+ splash.textContent = 'waiting firstFrameReady…';
591
+ screen.appendChild(iframe);
592
+ screen.appendChild(splash);
593
+ // admin は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
594
+ if (seat.kind !== 'admin') {
595
+ screen.appendChild(createCellChrome(opts, i, false, toggleNotchSim));
596
+ }
597
+ const notchDecor = createNotchDecor();
598
+ frameWrap.appendChild(screen);
599
+ frameWrap.appendChild(notchDecor);
600
+ // 擬似ノッチ ON/OFF を screen の inset だけで切り替える (iframe は再生成しない)。
601
+ const applyNotch = (on) => {
602
+ if (seat.kind === 'admin')
603
+ on = false;
604
+ if (on) {
605
+ screen.style.top = `${SIM_NOTCH.top}px`;
606
+ screen.style.left = `${SIM_NOTCH.left}px`;
607
+ screen.style.width = `calc(100% - ${SIM_NOTCH.left + SIM_NOTCH.right}px)`;
608
+ screen.style.height = `calc(100% - ${SIM_NOTCH.top + SIM_NOTCH.bottom}px)`;
609
+ screen.style.borderRadius = `${SIM_NOTCH.radius}px`;
610
+ notchDecor.style.display = 'block';
611
+ }
612
+ else {
613
+ screen.style.top = '0';
614
+ screen.style.left = '0';
615
+ screen.style.width = '100%';
616
+ screen.style.height = '100%';
617
+ screen.style.borderRadius = '0';
618
+ notchDecor.style.display = 'none';
619
+ }
620
+ };
621
+ applyNotch(notchSimOn);
622
+ notchAppliers.push(applyNotch);
623
+ cell.appendChild(label);
624
+ cell.appendChild(frameWrap);
625
+ cells.push(cell);
626
+ target.appendChild(cell);
627
+ if (iframe.contentWindow) {
628
+ iframeStatusDots.set(iframe.contentWindow, statusRow.dots);
629
+ iframeSplashes.set(iframe.contentWindow, splash);
630
+ }
631
+ }
632
+ layout();
633
+ };
634
+ const onParentMessage = (event) => {
635
+ const dots = iframeStatusDots.get(event.source);
636
+ if (!dots)
637
+ return;
638
+ let data = null;
639
+ try {
640
+ data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
641
+ }
642
+ catch {
643
+ return;
644
+ }
645
+ if (!data || data.channel !== 'sdk')
646
+ return;
647
+ const type = data.type;
648
+ if (type === 'ready' || type === 'firstFrameReady' || type === 'gameReady') {
649
+ dots[type].style.color = '#5cd6a0';
650
+ }
651
+ if (type === 'firstFrameReady') {
652
+ const splash = iframeSplashes.get(event.source);
653
+ if (splash) {
654
+ splash.style.opacity = '0';
655
+ splash.style.pointerEvents = 'none';
656
+ setTimeout(() => splash.remove(), 350);
657
+ iframeSplashes.delete(event.source);
658
+ }
659
+ }
660
+ };
661
+ window.addEventListener('message', onParentMessage);
662
+ window.addEventListener('resize', layout);
663
+ render();
664
+ }