@uzuhq/code-cli 0.5.7 → 0.5.9
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/dist/cli.js +2516 -289
- package/dist/dev-server/sdk-server-shim.js +21 -0
- package/dist/harness/client-entry.js +2779 -74
- package/package.json +5 -4
- package/dist/auth/browser.js +0 -22
- package/dist/auth/config.js +0 -48
- package/dist/auth/env.js +0 -42
- package/dist/auth/jwt.js +0 -27
- package/dist/auth/login-flow.js +0 -44
- package/dist/auth/loopback.js +0 -94
- package/dist/auth/pkce.js +0 -11
- package/dist/auth/publish-token.js +0 -74
- package/dist/auth/token-cache.js +0 -70
- package/dist/auth/uzu-auth.js +0 -94
- package/dist/build-server-logic.js +0 -28
- package/dist/cf-images-upload.js +0 -52
- package/dist/create-2d-game.js +0 -51
- package/dist/dev-server/game-room.js +0 -631
- package/dist/dev-server/game-types.js +0 -11
- package/dist/dev-server/json-patch.js +0 -114
- package/dist/dev-server/load-logic.js +0 -70
- package/dist/dev-server/random.js +0 -35
- package/dist/dev-server/relay-room.js +0 -86
- package/dist/dev-server/server.js +0 -367
- package/dist/dev-server/sync-room.js +0 -270
- package/dist/dev.js +0 -235
- package/dist/harness/admin-client.js +0 -215
- package/dist/harness/dev-button.js +0 -258
- package/dist/harness/mount.js +0 -790
- package/dist/harness/page.js +0 -46
- package/dist/r2-upload.js +0 -93
- package/dist/rest-register.js +0 -52
- package/dist/sdk-version.js +0 -41
- package/dist/upload-session.js +0 -71
package/dist/harness/mount.js
DELETED
|
@@ -1,790 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev harness の iframe grid を parent frame に描画する。
|
|
3
|
-
*
|
|
4
|
-
* 子 iframe URL には `?server=ws://localhost:<port>&roomId=<key>&seatId=dev_N&
|
|
5
|
-
* players=<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 uzu_code 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
|
-
// 擬似ノッチ表示トグル。 createNotchDecor が実際に描く装飾 (横向き端末・左の
|
|
37
|
-
// Dynamic Island・下中央のホームインジケータ) をそのまま象る。 隣の PHONE_SVG
|
|
38
|
-
// (縦向き) とは向きで見分ける。
|
|
39
|
-
const NOTCH_SVG = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
40
|
-
<rect x="2" y="4.5" width="20" height="15" rx="3.5" stroke="white" stroke-width="1.8"/>
|
|
41
|
-
<rect x="4.8" y="8.5" width="2.2" height="7" rx="1.1" fill="white"/>
|
|
42
|
-
<line x1="11" y1="17" x2="17" y2="17" stroke="white" stroke-width="1.6" stroke-linecap="round"/>
|
|
43
|
-
</svg>`;
|
|
44
|
-
/** buildIframeUrl に渡す HUD の避け領域 (Flutter HudInsets と同じ計算)。 */
|
|
45
|
-
function hudInsetParams(hasMenu, actionCount) {
|
|
46
|
-
let x = HUD_LEFT;
|
|
47
|
-
if (hasMenu)
|
|
48
|
-
x += HUD_UZU_SIZE;
|
|
49
|
-
if (actionCount > 0) {
|
|
50
|
-
if (hasMenu)
|
|
51
|
-
x += HUD_GAP;
|
|
52
|
-
x += HUD_ACTION_ITEM_WIDTH * actionCount;
|
|
53
|
-
}
|
|
54
|
-
return { x, y: HUD_TOP + HUD_UZU_SIZE };
|
|
55
|
-
}
|
|
56
|
-
// grid セルの擬似ノッチ (iPhone Pro 横向き相当、 notch は左固定)。 iframe 内は
|
|
57
|
-
// 親の env(safe-area-inset) を継承しないため、 docs の「viewport-fit 未指定」挙動
|
|
58
|
-
// (= SafeArea 内へ物理縮小、 外帯は背景色) を harness 側で再現する。
|
|
59
|
-
const SIM_NOTCH = { top: 0, right: 59, bottom: 21, left: 59, radius: 44 };
|
|
60
|
-
/** 擬似ノッチ装飾 (Dynamic Island 風ピル + ホームインジケータ)。 pointer-events なし。 */
|
|
61
|
-
function createNotchDecor() {
|
|
62
|
-
const decor = document.createElement('div');
|
|
63
|
-
decor.style.cssText = 'position:absolute;inset:0;pointer-events:none;z-index:7;';
|
|
64
|
-
// Dynamic Island 風の縦ピル (左帯の中、 縦中央)。 黒帯上でも見えるよう薄い縁取り。
|
|
65
|
-
const island = document.createElement('div');
|
|
66
|
-
island.style.cssText = [
|
|
67
|
-
'position:absolute;left:16px;top:50%;transform:translateY(-50%)',
|
|
68
|
-
'width:26px;height:88px;border-radius:13px',
|
|
69
|
-
'background:#000;border:1px solid rgba(255,255,255,0.12)',
|
|
70
|
-
].join(';');
|
|
71
|
-
// ホームインジケータ (下帯の中央)。
|
|
72
|
-
const home = document.createElement('div');
|
|
73
|
-
home.style.cssText = [
|
|
74
|
-
'position:absolute;bottom:7px;left:50%;transform:translateX(-50%)',
|
|
75
|
-
'width:32%;max-width:180px;height:4px;border-radius:2px',
|
|
76
|
-
'background:rgba(255,255,255,0.5)',
|
|
77
|
-
].join(';');
|
|
78
|
-
decor.append(island, home);
|
|
79
|
-
return decor;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* `?player=N` 用: 1 プレイヤー分の iframe を全画面で mount する。
|
|
83
|
-
* スマホ実機 (同一 LAN) から 1 キャラの画面をそのまま操作する用途。
|
|
84
|
-
* grid 版と同じ query を組み立てるので、 PC 側 grid の同じ playerId と
|
|
85
|
-
* 同一 GameRoom / 同一キャラとして同期する。
|
|
86
|
-
*/
|
|
87
|
-
/**
|
|
88
|
-
* ⛶ 全画面ボタン。 iOS Safari 16.4+ / Android Chrome で URL バーごと消せる。
|
|
89
|
-
* standalone 起動 (ホーム画面に追加) や Fullscreen API 非対応環境では出さない。
|
|
90
|
-
*/
|
|
91
|
-
function mountFullscreenButton(target) {
|
|
92
|
-
const doc = document;
|
|
93
|
-
const root = document.documentElement;
|
|
94
|
-
const supported = Boolean(root.requestFullscreen || root.webkitRequestFullscreen);
|
|
95
|
-
const standalone = matchMedia('(display-mode: standalone)').matches;
|
|
96
|
-
if (!supported || standalone)
|
|
97
|
-
return;
|
|
98
|
-
const btn = document.createElement('button');
|
|
99
|
-
btn.textContent = '⛶';
|
|
100
|
-
btn.title = '全画面';
|
|
101
|
-
btn.style.cssText = [
|
|
102
|
-
'position:absolute',
|
|
103
|
-
'right:calc(10px + env(safe-area-inset-right))',
|
|
104
|
-
'top:calc(10px + env(safe-area-inset-top))',
|
|
105
|
-
'z-index:10',
|
|
106
|
-
'width:40px',
|
|
107
|
-
'height:40px',
|
|
108
|
-
'border:none',
|
|
109
|
-
'border-radius:10px',
|
|
110
|
-
'background:rgba(0,0,0,0.45)',
|
|
111
|
-
'color:#cfd8ea',
|
|
112
|
-
'font-size:20px',
|
|
113
|
-
'line-height:1',
|
|
114
|
-
'cursor:pointer',
|
|
115
|
-
'backdrop-filter:blur(4px)',
|
|
116
|
-
].join(';');
|
|
117
|
-
const isFullscreen = () => Boolean(document.fullscreenElement ?? doc.webkitFullscreenElement);
|
|
118
|
-
const sync = () => {
|
|
119
|
-
btn.textContent = isFullscreen() ? '✕' : '⛶';
|
|
120
|
-
btn.style.opacity = isFullscreen() ? '0.35' : '1';
|
|
121
|
-
};
|
|
122
|
-
btn.onclick = () => {
|
|
123
|
-
if (isFullscreen()) {
|
|
124
|
-
(document.exitFullscreen ?? doc.webkitExitFullscreen)?.call(document);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
(root.requestFullscreen ?? root.webkitRequestFullscreen)?.call(root);
|
|
128
|
-
};
|
|
129
|
-
document.addEventListener('fullscreenchange', sync);
|
|
130
|
-
document.addEventListener('webkitfullscreenchange', sync);
|
|
131
|
-
target.appendChild(btn);
|
|
132
|
-
}
|
|
133
|
-
export function mountSinglePlayer(opts, playerIndex) {
|
|
134
|
-
document.documentElement.style.cssText =
|
|
135
|
-
'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#0b0b16;';
|
|
136
|
-
// position:relative にして iframe の width/height calc(100% - inset) の基準を target にする。
|
|
137
|
-
opts.target.style.cssText =
|
|
138
|
-
'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#0b0b16;position:relative;';
|
|
139
|
-
const iframe = document.createElement('iframe');
|
|
140
|
-
iframe.src = buildIframeUrl(opts, playerIndex);
|
|
141
|
-
// 本番 Flutter (viewport-fit=auto) の safePadding と同じく、 iframe を safe-area で
|
|
142
|
-
// 物理 inset する。 iframe 内は top-level の env を継承しない (0 になる) ため、
|
|
143
|
-
// parent (= top-level、 page.ts で viewport-fit=cover) 側の env で inset する。
|
|
144
|
-
// iframe は置換要素で top/bottom だけでは伸びないので width/height を明示する。
|
|
145
|
-
iframe.style.cssText = [
|
|
146
|
-
'border:none;display:block;position:absolute',
|
|
147
|
-
'top:env(safe-area-inset-top, 0px)',
|
|
148
|
-
'left:env(safe-area-inset-left, 0px)',
|
|
149
|
-
'width:calc(100% - env(safe-area-inset-left, 0px) - env(safe-area-inset-right, 0px))',
|
|
150
|
-
'height:calc(100% - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))',
|
|
151
|
-
].join(';');
|
|
152
|
-
iframe.allow = 'autoplay';
|
|
153
|
-
const splash = document.createElement('div');
|
|
154
|
-
splash.style.cssText = [
|
|
155
|
-
'position:absolute;inset:0;z-index:5',
|
|
156
|
-
'display:flex;align-items:center;justify-content:center',
|
|
157
|
-
'background:#0b0b16;color:#3a4a6a',
|
|
158
|
-
'font-family:monospace;font-size:12px;letter-spacing:0.08em',
|
|
159
|
-
'transition:opacity 0.3s',
|
|
160
|
-
].join(';');
|
|
161
|
-
splash.textContent = 'waiting firstFrameReady…';
|
|
162
|
-
opts.target.appendChild(iframe);
|
|
163
|
-
opts.target.appendChild(splash);
|
|
164
|
-
// 実機でも本番アプリと同じ overlay chrome を出す (ハリボテ)。 GM 席は端末を
|
|
165
|
-
// 模す必要が無いので載せない。 UZU ボタンだけは「他プレイヤー画面へ切替」に配線する。
|
|
166
|
-
if (paneAt(opts, playerIndex).seatKind !== 'admin') {
|
|
167
|
-
const chrome = createCellChrome(opts, playerIndex, true, undefined, () => {
|
|
168
|
-
const uzuBtn = chrome.querySelector('button');
|
|
169
|
-
if (uzuBtn)
|
|
170
|
-
openPlayerSwitcher(opts, playerIndex, uzuBtn);
|
|
171
|
-
});
|
|
172
|
-
opts.target.appendChild(chrome);
|
|
173
|
-
}
|
|
174
|
-
mountFullscreenButton(opts.target);
|
|
175
|
-
window.addEventListener('message', (event) => {
|
|
176
|
-
if (event.source !== iframe.contentWindow)
|
|
177
|
-
return;
|
|
178
|
-
let data = null;
|
|
179
|
-
try {
|
|
180
|
-
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
181
|
-
}
|
|
182
|
-
catch {
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
if (!data || data.channel !== 'sdk')
|
|
186
|
-
return;
|
|
187
|
-
if (data.type === 'firstFrameReady') {
|
|
188
|
-
splash.style.opacity = '0';
|
|
189
|
-
splash.style.pointerEvents = 'none';
|
|
190
|
-
setTimeout(() => splash.remove(), 350);
|
|
191
|
-
}
|
|
192
|
-
});
|
|
193
|
-
}
|
|
194
|
-
const CELL_ASPECT_RATIO = { portrait: 9.0 / 19.5, landscape: 19.5 / 9.0 };
|
|
195
|
-
const GAP = 4;
|
|
196
|
-
const optimalGrid = (areaWidth, areaHeight, count, cellAspectRatio) => {
|
|
197
|
-
let bestCols = 1;
|
|
198
|
-
let bestRows = count;
|
|
199
|
-
let bestCellArea = 0;
|
|
200
|
-
for (let cols = 1; cols <= count; cols++) {
|
|
201
|
-
const rows = Math.ceil(count / cols);
|
|
202
|
-
const slotW = (areaWidth - GAP * (cols - 1)) / cols;
|
|
203
|
-
const slotH = (areaHeight - GAP * (rows - 1)) / rows;
|
|
204
|
-
let cellW;
|
|
205
|
-
let cellH;
|
|
206
|
-
if (slotW / slotH > cellAspectRatio) {
|
|
207
|
-
cellH = slotH;
|
|
208
|
-
cellW = slotH * cellAspectRatio;
|
|
209
|
-
}
|
|
210
|
-
else {
|
|
211
|
-
cellW = slotW;
|
|
212
|
-
cellH = slotW / cellAspectRatio;
|
|
213
|
-
}
|
|
214
|
-
const area = cellW * cellH;
|
|
215
|
-
if (area > bestCellArea) {
|
|
216
|
-
bestCellArea = area;
|
|
217
|
-
bestCols = cols;
|
|
218
|
-
bestRows = rows;
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return { cols: bestCols, rows: bestRows };
|
|
222
|
-
};
|
|
223
|
-
const computeGridCellSize = (areaWidth, areaHeight, count, cellAspectRatio) => {
|
|
224
|
-
const { cols, rows } = optimalGrid(areaWidth, areaHeight, count, cellAspectRatio);
|
|
225
|
-
const slotW = (areaWidth - GAP * (cols - 1)) / cols;
|
|
226
|
-
const slotH = (areaHeight - GAP * (rows - 1)) / rows;
|
|
227
|
-
let cellW;
|
|
228
|
-
let cellH;
|
|
229
|
-
if (slotW / slotH > cellAspectRatio) {
|
|
230
|
-
cellH = slotH;
|
|
231
|
-
cellW = slotH * cellAspectRatio;
|
|
232
|
-
}
|
|
233
|
-
else {
|
|
234
|
-
cellW = slotW;
|
|
235
|
-
cellH = slotW / cellAspectRatio;
|
|
236
|
-
}
|
|
237
|
-
return { cols, rows, cellW, cellH };
|
|
238
|
-
};
|
|
239
|
-
function createLifecycleStatusRow() {
|
|
240
|
-
const row = document.createElement('div');
|
|
241
|
-
row.style.cssText =
|
|
242
|
-
'display:flex;justify-content:center;gap:8px;font-size:9px;padding-top:1px;font-family:monospace;color:#3a4a6a;';
|
|
243
|
-
const entries = [
|
|
244
|
-
{ key: 'ready', label: 'init' },
|
|
245
|
-
{ key: 'firstFrameReady', label: 'firstFrame' },
|
|
246
|
-
{ key: 'gameReady', label: 'game' },
|
|
247
|
-
];
|
|
248
|
-
const dots = {
|
|
249
|
-
ready: document.createElement('span'),
|
|
250
|
-
firstFrameReady: document.createElement('span'),
|
|
251
|
-
gameReady: document.createElement('span'),
|
|
252
|
-
};
|
|
253
|
-
for (const { key, label } of entries) {
|
|
254
|
-
const item = document.createElement('span');
|
|
255
|
-
item.style.cssText = 'display:inline-flex;align-items:center;gap:2px;';
|
|
256
|
-
const dot = dots[key];
|
|
257
|
-
dot.textContent = '●';
|
|
258
|
-
dot.style.cssText = 'color:#3a4a6a;transition:color 0.15s;';
|
|
259
|
-
const text = document.createElement('span');
|
|
260
|
-
text.textContent = label;
|
|
261
|
-
item.appendChild(dot);
|
|
262
|
-
item.appendChild(text);
|
|
263
|
-
row.appendChild(item);
|
|
264
|
-
}
|
|
265
|
-
// native title の tooltip は的が小さく遅延も長いので、クリックで開くパネル (render 側で
|
|
266
|
-
// frameWrap に置く) を主にする。title は補助。
|
|
267
|
-
const predictionWarn = document.createElement('span');
|
|
268
|
-
predictionWarn.style.cssText = [
|
|
269
|
-
'display:none;align-items:center;gap:3px',
|
|
270
|
-
'color:#1a1a2e;background:#ffb86c;border-radius:6px',
|
|
271
|
-
'padding:0 5px;cursor:pointer;user-select:none;font-weight:bold',
|
|
272
|
-
].join(';');
|
|
273
|
-
row.appendChild(predictionWarn);
|
|
274
|
-
return { el: row, dots, predictionWarn };
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* roster に載らない観測席の pane。 席の中身は固定なので manifest の宣言から起こす。
|
|
278
|
-
* grid の中だけの存在で、 GameRoom にも iframe の `?players=` にも渡らない。
|
|
279
|
-
*/
|
|
280
|
-
function observerPanes(opts) {
|
|
281
|
-
const pane = (id, nickname, seatKind) => ({
|
|
282
|
-
id,
|
|
283
|
-
nickname,
|
|
284
|
-
seatKind,
|
|
285
|
-
playerNo: null,
|
|
286
|
-
});
|
|
287
|
-
return [
|
|
288
|
-
...(opts.spectator ? [pane('spec_0', '観戦', 'spectator')] : []),
|
|
289
|
-
...(opts.admin ? [pane('admin_0', 'Admin', 'admin')] : []),
|
|
290
|
-
];
|
|
291
|
-
}
|
|
292
|
-
export function paneCount(opts) {
|
|
293
|
-
return opts.players.length + observerPanes(opts).length;
|
|
294
|
-
}
|
|
295
|
-
function paneAt(opts, index) {
|
|
296
|
-
const player = opts.players[index];
|
|
297
|
-
if (player) {
|
|
298
|
-
return {
|
|
299
|
-
id: player.id,
|
|
300
|
-
nickname: player.nickname,
|
|
301
|
-
seatKind: 'player',
|
|
302
|
-
playerNo: index + 1,
|
|
303
|
-
};
|
|
304
|
-
}
|
|
305
|
-
const observer = observerPanes(opts)[index - opts.players.length];
|
|
306
|
-
if (!observer)
|
|
307
|
-
throw new Error(`pane index out of range: ${index}`);
|
|
308
|
-
return observer;
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* SDK が読む wire 形式 (nickname → name)。
|
|
312
|
-
*
|
|
313
|
-
* `kind` は移行期の互換措置。 旧 SDK (0.7.3 等) は `kind` の無い席を受け取ると
|
|
314
|
-
* throw する。 roster は player だけになったので値は常に 'player'。
|
|
315
|
-
*/
|
|
316
|
-
function toWireSeat(seat) {
|
|
317
|
-
return {
|
|
318
|
-
id: seat.id,
|
|
319
|
-
name: seat.nickname,
|
|
320
|
-
iconUrl: seat.iconUrl,
|
|
321
|
-
characterId: seat.characterId,
|
|
322
|
-
kind: 'player',
|
|
323
|
-
};
|
|
324
|
-
}
|
|
325
|
-
function buildIframeUrl(opts, playerIndex) {
|
|
326
|
-
const url = new URL(opts.scenarioUrl);
|
|
327
|
-
const pane = paneAt(opts, playerIndex);
|
|
328
|
-
url.searchParams.set('server', opts.serverBaseUrl);
|
|
329
|
-
url.searchParams.set('roomId', opts.roomKey);
|
|
330
|
-
url.searchParams.set('seatId', pane.id);
|
|
331
|
-
// roster は player 席だけ。 観測席の iframe も同じ roster を送る (自分は載らない)。
|
|
332
|
-
// 移行期: 手元の scenario が同梱する SDK が旧版 (0.7.3 等) だと `?seats=` が無いだけで
|
|
333
|
-
// run() の入口が throw する。 旧名も併せて載せる。
|
|
334
|
-
const roster = JSON.stringify(opts.players.map(toWireSeat));
|
|
335
|
-
url.searchParams.set('players', roster);
|
|
336
|
-
url.searchParams.set('seats', roster);
|
|
337
|
-
// 自分の席種別。 観測席で GM ビューと観戦ビューを出し分けるのに要る (state からは
|
|
338
|
-
// 導けない)。 iframe URL 限定で WebSocket には出さない。
|
|
339
|
-
url.searchParams.set('seatKind', pane.seatKind);
|
|
340
|
-
url.searchParams.set('revisionId', opts.revisionId);
|
|
341
|
-
// SDK は解釈しないが、 scenario 側で「parent は harness page (audio consumer 無し) か」を
|
|
342
|
-
// 判定するマーカーとして残す (tenbo の __uzuAudio bridge スキップ判定など)。
|
|
343
|
-
url.searchParams.set('__dev_player', String(playerIndex));
|
|
344
|
-
url.searchParams.set('__dev_room', opts.roomKey);
|
|
345
|
-
if (pane.seatKind === 'admin') {
|
|
346
|
-
// GM 席は本番 overlay chrome を載せないため避け領域も不要。
|
|
347
|
-
url.searchParams.set('uzuHudInsetX', '0');
|
|
348
|
-
url.searchParams.set('uzuHudInsetY', '0');
|
|
349
|
-
return url.toString();
|
|
350
|
-
}
|
|
351
|
-
// 本番同様、 UZU ボタン + ActionBar(chat/mic) の避け領域を game に伝える。
|
|
352
|
-
const hud = hudInsetParams(true, 2);
|
|
353
|
-
url.searchParams.set('uzuHudInsetX', String(hud.x));
|
|
354
|
-
url.searchParams.set('uzuHudInsetY', String(hud.y));
|
|
355
|
-
return url.toString();
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* その player を実機 (同一 LAN) で開く URL。 localhost で harness を見ているときは
|
|
359
|
-
* mDNS 名 (lanHosts 先頭) を、 すでに実機で見ているときは現在の host を使う。
|
|
360
|
-
*/
|
|
361
|
-
function resolveMobileUrl(opts, playerIndex) {
|
|
362
|
-
const isLocal = location.hostname === 'localhost' || location.hostname === '127.0.0.1';
|
|
363
|
-
const host = isLocal ? opts.lanHosts?.[0] : location.hostname;
|
|
364
|
-
if (!host)
|
|
365
|
-
return null;
|
|
366
|
-
const port = location.port ? `:${location.port}` : '';
|
|
367
|
-
return `http://${host}${port}/?player=${playerIndex}`;
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* その player の単体表示 (`?player=N`) URL。 実機リンクと違い LAN host には
|
|
371
|
-
* 読み替えず、 いま harness を見ている origin をそのまま使う。
|
|
372
|
-
*/
|
|
373
|
-
function singleViewUrl(playerIndex) {
|
|
374
|
-
const url = new URL(location.href);
|
|
375
|
-
url.searchParams.set('player', String(playerIndex));
|
|
376
|
-
return url.toString();
|
|
377
|
-
}
|
|
378
|
-
/** 携帯リンクの QR ポップアップ (カメラで読めばそのキャラの画面がスマホで開く)。 */
|
|
379
|
-
function openMobileQrPopup(url, label) {
|
|
380
|
-
const backdrop = document.createElement('div');
|
|
381
|
-
backdrop.style.cssText = [
|
|
382
|
-
'position:fixed;inset:0;z-index:100000',
|
|
383
|
-
'background:rgba(0,0,0,0.6)',
|
|
384
|
-
'display:flex;align-items:center;justify-content:center',
|
|
385
|
-
].join(';');
|
|
386
|
-
const panel = document.createElement('div');
|
|
387
|
-
panel.style.cssText = [
|
|
388
|
-
'background:#2d3436;border-radius:12px;padding:20px',
|
|
389
|
-
'display:flex;flex-direction:column;align-items:center;gap:10px',
|
|
390
|
-
'font-family:system-ui,sans-serif;color:#fff;max-width:80vw',
|
|
391
|
-
].join(';');
|
|
392
|
-
const heading = document.createElement('div');
|
|
393
|
-
heading.textContent = `📱 ${label}`;
|
|
394
|
-
heading.style.cssText = 'font-size:14px;font-weight:bold;';
|
|
395
|
-
const note = document.createElement('div');
|
|
396
|
-
note.textContent = 'QR をカメラで読むと、 このキャラの画面がスマホで開きます';
|
|
397
|
-
note.style.cssText = 'font-size:11px;color:#a0a8b8;';
|
|
398
|
-
const qrWrap = document.createElement('div');
|
|
399
|
-
qrWrap.style.cssText = 'background:#fff;padding:10px;border-radius:8px;line-height:0;';
|
|
400
|
-
const qr = qrcode(0, 'M');
|
|
401
|
-
qr.addData(url);
|
|
402
|
-
qr.make();
|
|
403
|
-
qrWrap.innerHTML = qr.createSvgTag({ cellSize: 4, margin: 0, scalable: true });
|
|
404
|
-
const svg = qrWrap.querySelector('svg');
|
|
405
|
-
if (svg) {
|
|
406
|
-
svg.style.width = '200px';
|
|
407
|
-
svg.style.height = '200px';
|
|
408
|
-
}
|
|
409
|
-
const urlEl = document.createElement('a');
|
|
410
|
-
urlEl.textContent = url;
|
|
411
|
-
urlEl.href = url;
|
|
412
|
-
urlEl.target = '_blank';
|
|
413
|
-
urlEl.style.cssText = 'font-size:12px;color:#8be9fd;word-break:break-all;text-align:center;';
|
|
414
|
-
panel.append(heading, note, qrWrap, urlEl);
|
|
415
|
-
backdrop.appendChild(panel);
|
|
416
|
-
backdrop.onclick = () => backdrop.remove();
|
|
417
|
-
panel.onclick = (e) => e.stopPropagation();
|
|
418
|
-
document.body.appendChild(backdrop);
|
|
419
|
-
}
|
|
420
|
-
/**
|
|
421
|
-
* 単体表示 (実機) の UZU ボタン用プレイヤー切替。 `?player=N` を差し替えて
|
|
422
|
-
* 同じ端末で他キャラ画面に飛ぶ (QR を読み直さず切替できる)。 anchor 直下に出す。
|
|
423
|
-
*/
|
|
424
|
-
function openPlayerSwitcher(opts, currentIndex, anchor) {
|
|
425
|
-
const existing = document.getElementById('__uzu_player_switcher');
|
|
426
|
-
if (existing) {
|
|
427
|
-
existing.remove();
|
|
428
|
-
return;
|
|
429
|
-
}
|
|
430
|
-
const panel = document.createElement('div');
|
|
431
|
-
panel.id = '__uzu_player_switcher';
|
|
432
|
-
panel.style.cssText = [
|
|
433
|
-
'position:fixed;z-index:100001',
|
|
434
|
-
'background:#2d3436;border-radius:10px;padding:8px',
|
|
435
|
-
'box-shadow:0 4px 16px rgba(0,0,0,0.5)',
|
|
436
|
-
'font-family:system-ui,sans-serif;color:#fff',
|
|
437
|
-
'display:flex;flex-direction:column;gap:4px;min-width:180px',
|
|
438
|
-
'max-height:70vh;overflow-y:auto',
|
|
439
|
-
].join(';');
|
|
440
|
-
const heading = document.createElement('div');
|
|
441
|
-
heading.textContent = '画面を切り替え';
|
|
442
|
-
heading.style.cssText = 'font-size:11px;color:#a0a8b8;padding:2px 8px 6px;';
|
|
443
|
-
panel.appendChild(heading);
|
|
444
|
-
for (let i = 0; i < paneCount(opts); i++) {
|
|
445
|
-
const pane = paneAt(opts, i);
|
|
446
|
-
const isCurrent = i === currentIndex;
|
|
447
|
-
const row = document.createElement('button');
|
|
448
|
-
row.textContent = `${isCurrent ? '● ' : ''}${pane.nickname} (${pane.id})`;
|
|
449
|
-
row.style.cssText = [
|
|
450
|
-
'text-align:left;border:none;border-radius:6px',
|
|
451
|
-
'padding:10px 12px;font-size:14px;cursor:pointer',
|
|
452
|
-
`background:${isCurrent ? '#0984e3' : '#3a4553'};color:#fff`,
|
|
453
|
-
].join(';');
|
|
454
|
-
row.onclick = (e) => {
|
|
455
|
-
e.stopPropagation();
|
|
456
|
-
if (isCurrent) {
|
|
457
|
-
panel.remove();
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
const url = new URL(location.href);
|
|
461
|
-
url.searchParams.set('player', String(i));
|
|
462
|
-
location.href = url.toString();
|
|
463
|
-
};
|
|
464
|
-
panel.appendChild(row);
|
|
465
|
-
}
|
|
466
|
-
document.body.appendChild(panel);
|
|
467
|
-
const rect = anchor.getBoundingClientRect();
|
|
468
|
-
panel.style.top = `${Math.round(rect.bottom + 6)}px`;
|
|
469
|
-
panel.style.left = `${Math.round(rect.left)}px`;
|
|
470
|
-
const close = (e) => {
|
|
471
|
-
if (!panel.contains(e.target) && !anchor.contains(e.target)) {
|
|
472
|
-
panel.remove();
|
|
473
|
-
document.removeEventListener('click', close);
|
|
474
|
-
}
|
|
475
|
-
};
|
|
476
|
-
setTimeout(() => document.addEventListener('click', close), 0);
|
|
477
|
-
}
|
|
478
|
-
function createCellChrome(opts, playerIndex,
|
|
479
|
-
// facade=true (実機の単体表示) では見た目だけ揃える。 admin channel が無いので
|
|
480
|
-
// UZU メニュー / 実機 QR は配線せず、 アイコンはタップ無効 (ハリボテ)。
|
|
481
|
-
facade = false,
|
|
482
|
-
// grid では no-op のマイク枠を擬似ノッチ表示トグルに転用する。
|
|
483
|
-
notchToggle,
|
|
484
|
-
// 単体表示 (実機) では UZU ボタンをプレイヤー切替に転用する。
|
|
485
|
-
onUzuClick) {
|
|
486
|
-
const wrap = document.createElement('div');
|
|
487
|
-
wrap.style.cssText = [
|
|
488
|
-
'position:absolute',
|
|
489
|
-
// notch / status bar を避けるため safe-area を足す (PC harness では env=0)。
|
|
490
|
-
`top:calc(${HUD_TOP}px + env(safe-area-inset-top, 0px))`,
|
|
491
|
-
`left:calc(${HUD_LEFT}px + env(safe-area-inset-left, 0px))`,
|
|
492
|
-
'z-index:6',
|
|
493
|
-
'display:flex;align-items:center;gap:6px',
|
|
494
|
-
].join(';');
|
|
495
|
-
const uzu = document.createElement('button');
|
|
496
|
-
const uzuInteractive = Boolean(onUzuClick) || !facade;
|
|
497
|
-
uzu.title = onUzuClick ? '画面を切り替え' : 'UZU メニュー';
|
|
498
|
-
uzu.innerHTML = UZU_LOGO_SVG;
|
|
499
|
-
uzu.style.cssText = [
|
|
500
|
-
`width:${HUD_UZU_SIZE}px;height:${HUD_UZU_SIZE}px`,
|
|
501
|
-
'border:none;border-radius:12px',
|
|
502
|
-
'background:rgba(0,0,0,0.6)',
|
|
503
|
-
`cursor:${uzuInteractive ? 'pointer' : 'default'};padding:0`,
|
|
504
|
-
'display:flex;align-items:center;justify-content:center',
|
|
505
|
-
'flex-shrink:0',
|
|
506
|
-
].join(';');
|
|
507
|
-
const mobileUrl = resolveMobileUrl(opts, playerIndex);
|
|
508
|
-
const label = paneAt(opts, playerIndex).nickname;
|
|
509
|
-
if (onUzuClick) {
|
|
510
|
-
uzu.onclick = onUzuClick;
|
|
511
|
-
}
|
|
512
|
-
else if (!facade) {
|
|
513
|
-
attachUzuMenu(uzu, {
|
|
514
|
-
stateGetter: opts.stateGetter,
|
|
515
|
-
resetGame: opts.resetGame,
|
|
516
|
-
playerCount: opts.playerCount,
|
|
517
|
-
adminEnabled: opts.admin,
|
|
518
|
-
onOpenSingleView: () => {
|
|
519
|
-
window.open(singleViewUrl(playerIndex), '_blank', 'noopener');
|
|
520
|
-
},
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
wrap.appendChild(uzu);
|
|
524
|
-
const bar = document.createElement('div');
|
|
525
|
-
bar.style.cssText = [
|
|
526
|
-
'height:36px',
|
|
527
|
-
'background:rgba(0,0,0,0.6)',
|
|
528
|
-
'border-radius:18px',
|
|
529
|
-
'display:flex;align-items:center',
|
|
530
|
-
].join(';');
|
|
531
|
-
// 1つめ: 本番は音声トグルだが harness には音声が無い。 grid ではこの枠を擬似ノッチ
|
|
532
|
-
// 表示のトグルに転用し、 アイコンも端末フレームへ差し替える (facade は本番の見た目)。
|
|
533
|
-
const micSlot = document.createElement(notchToggle ? 'button' : 'div');
|
|
534
|
-
micSlot.innerHTML = notchToggle ? NOTCH_SVG : MIC_SVG;
|
|
535
|
-
micSlot.style.cssText = notchToggle
|
|
536
|
-
? 'padding:0 10px;height:36px;background:none;border:none;cursor:pointer;display:flex;align-items:center;'
|
|
537
|
-
: 'padding:0 10px;display:flex;align-items:center;';
|
|
538
|
-
if (notchToggle) {
|
|
539
|
-
micSlot.onclick = notchToggle.toggle;
|
|
540
|
-
notchToggle.register((on) => {
|
|
541
|
-
micSlot.style.opacity = on ? '1' : '0.4';
|
|
542
|
-
micSlot.title = `擬似ノッチ表示: ${on ? 'ON' : 'OFF'}`;
|
|
543
|
-
});
|
|
544
|
-
}
|
|
545
|
-
else {
|
|
546
|
-
micSlot.title = 'マイク (dev 表示のみ)';
|
|
547
|
-
}
|
|
548
|
-
bar.appendChild(micSlot);
|
|
549
|
-
// 2つめ: 携帯 (その player を実機で開く QR)。 facade では装飾のみ。
|
|
550
|
-
const phone = document.createElement('button');
|
|
551
|
-
phone.innerHTML = PHONE_SVG;
|
|
552
|
-
phone.title = facade ? '' : 'スマホ実機で開く (QR)';
|
|
553
|
-
phone.style.cssText = [
|
|
554
|
-
'padding:0 10px;height:36px',
|
|
555
|
-
`background:none;border:none;cursor:${facade ? 'default' : 'pointer'}`,
|
|
556
|
-
'display:flex;align-items:center',
|
|
557
|
-
].join(';');
|
|
558
|
-
if (!facade) {
|
|
559
|
-
phone.onclick = () => {
|
|
560
|
-
if (mobileUrl)
|
|
561
|
-
openMobileQrPopup(mobileUrl, label);
|
|
562
|
-
};
|
|
563
|
-
}
|
|
564
|
-
bar.appendChild(phone);
|
|
565
|
-
wrap.appendChild(bar);
|
|
566
|
-
return wrap;
|
|
567
|
-
}
|
|
568
|
-
export function mountIframeGrid(opts) {
|
|
569
|
-
const { target, playerCount, orientation, devMinIframeShortEdge } = opts;
|
|
570
|
-
document.documentElement.style.cssText =
|
|
571
|
-
'margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#1a1a2e;';
|
|
572
|
-
const cells = [];
|
|
573
|
-
const iframeStatusDots = new Map();
|
|
574
|
-
const iframeSplashes = new Map();
|
|
575
|
-
const iframePredictionWarns = new Map();
|
|
576
|
-
// 擬似ノッチ (各 cell 共通トグル)。 iframe を再生成せず inset だけ切り替える。
|
|
577
|
-
// 既定は OFF: ノッチ帯のぶん画面が縮むので、 SafeArea を確認したいときだけ出す。
|
|
578
|
-
let notchSimOn = false;
|
|
579
|
-
const notchAppliers = [];
|
|
580
|
-
const toggleNotchSim = () => {
|
|
581
|
-
notchSimOn = !notchSimOn;
|
|
582
|
-
for (const apply of notchAppliers)
|
|
583
|
-
apply(notchSimOn);
|
|
584
|
-
};
|
|
585
|
-
const layout = () => {
|
|
586
|
-
const n = cells.length;
|
|
587
|
-
if (n === 0)
|
|
588
|
-
return;
|
|
589
|
-
const cellAR = CELL_ASPECT_RATIO[orientation];
|
|
590
|
-
const targetW = target === document.body ? window.innerWidth : target.clientWidth;
|
|
591
|
-
const targetH = target === document.body ? window.innerHeight : target.clientHeight;
|
|
592
|
-
const naive = computeGridCellSize(targetW, targetH, n, cellAR);
|
|
593
|
-
const naiveShortEdge = Math.min(naive.cellW, naive.cellH);
|
|
594
|
-
const zoom = Math.min(1, naiveShortEdge / devMinIframeShortEdge);
|
|
595
|
-
const areaW = targetW / zoom;
|
|
596
|
-
const areaH = targetH / zoom;
|
|
597
|
-
const { cols, rows, cellW, cellH } = computeGridCellSize(areaW, areaH, n, cellAR);
|
|
598
|
-
target.style.cssText = [
|
|
599
|
-
`margin:0;padding:0;width:${areaW}px;height:${areaH}px;overflow:hidden`,
|
|
600
|
-
'display:grid',
|
|
601
|
-
`grid-template-columns:repeat(${cols},${cellW}px)`,
|
|
602
|
-
`grid-template-rows:repeat(${rows},${cellH}px)`,
|
|
603
|
-
'justify-content:center',
|
|
604
|
-
'align-content:center',
|
|
605
|
-
`gap:${GAP}px`,
|
|
606
|
-
'font-family:system-ui,sans-serif',
|
|
607
|
-
'background:#1a1a2e',
|
|
608
|
-
// 縮小表示は zoom ではなく transform: scale で行う。 zoom は layout プロパティ
|
|
609
|
-
// なので WebKit では iframe 内部の percentage height / vh の解決に物理 rect
|
|
610
|
-
// (zoom 適用後) が混入し、 iframe 内 body が viewport より小さくレンダリング
|
|
611
|
-
// される (Safari で下部に白い letterbox が出る)。 transform は視覚のみで
|
|
612
|
-
// iframe 内 layout に波及しないため engine 差が出ない。
|
|
613
|
-
...(zoom < 1 ? [`transform:scale(${zoom})`, 'transform-origin:top left'] : []),
|
|
614
|
-
].join(';');
|
|
615
|
-
for (const cell of cells) {
|
|
616
|
-
cell.style.cssText = `width:${cellW}px;height:${cellH}px;display:flex;flex-direction:column;overflow:hidden;`;
|
|
617
|
-
}
|
|
618
|
-
};
|
|
619
|
-
const render = () => {
|
|
620
|
-
target.innerHTML = '';
|
|
621
|
-
cells.length = 0;
|
|
622
|
-
iframeStatusDots.clear();
|
|
623
|
-
iframeSplashes.clear();
|
|
624
|
-
iframePredictionWarns.clear();
|
|
625
|
-
notchAppliers.length = 0;
|
|
626
|
-
for (let i = 0; i < paneCount(opts); i++) {
|
|
627
|
-
const pane = paneAt(opts, i);
|
|
628
|
-
const cell = document.createElement('div');
|
|
629
|
-
const label = document.createElement('div');
|
|
630
|
-
// GM 席は本番に存在しない dev 専用席なので、 player 席と見分く別配色にする。
|
|
631
|
-
const labelColor = pane.seatKind === 'admin' ? '#ffb86c' : '#8be9fd';
|
|
632
|
-
label.style.cssText = `color:${labelColor};font-size:12px;padding:2px 0;flex-shrink:0;background:#16213e;text-align:center;`;
|
|
633
|
-
const nameRow = document.createElement('div');
|
|
634
|
-
nameRow.textContent =
|
|
635
|
-
pane.seatKind === 'player'
|
|
636
|
-
? `Player ${pane.playerNo} (${pane.id})`
|
|
637
|
-
: `${pane.seatKind === 'admin' ? '⚙' : '👁'} ${pane.nickname} (${pane.id})`;
|
|
638
|
-
label.appendChild(nameRow);
|
|
639
|
-
const statusRow = createLifecycleStatusRow();
|
|
640
|
-
label.appendChild(statusRow.el);
|
|
641
|
-
const iframe = document.createElement('iframe');
|
|
642
|
-
iframe.src = buildIframeUrl(opts, i);
|
|
643
|
-
iframe.style.cssText =
|
|
644
|
-
'border:none;display:block;position:absolute;inset:0;width:100%;height:100%;';
|
|
645
|
-
// frameWrap = 端末ベゼル (黒)、 screen = 画面 (擬似ノッチ時は SafeArea 内へ縮小)。
|
|
646
|
-
const frameWrap = document.createElement('div');
|
|
647
|
-
frameWrap.style.cssText =
|
|
648
|
-
'flex:1;position:relative;min-height:0;overflow:hidden;background:#000;';
|
|
649
|
-
const screen = document.createElement('div');
|
|
650
|
-
screen.style.cssText = 'position:absolute;overflow:hidden;background:#000;';
|
|
651
|
-
// 本番ホスト (mobile WebView / Web emulator) は firstFrameReady 受信まで
|
|
652
|
-
// splash overlay で WebView を隠す。 harness も同じ挙動にしないと、 scenario が
|
|
653
|
-
// firstFrameReady を呼び忘れても harness では動いて見えてしまい、 publish 後に
|
|
654
|
-
// 初めて「splash が外れない」事故が発覚する。
|
|
655
|
-
const splash = document.createElement('div');
|
|
656
|
-
splash.style.cssText = [
|
|
657
|
-
'position:absolute;inset:0;z-index:5',
|
|
658
|
-
'display:flex;align-items:center;justify-content:center',
|
|
659
|
-
'background:#0b0b16;color:#3a4a6a',
|
|
660
|
-
'font-family:monospace;font-size:11px;letter-spacing:0.08em',
|
|
661
|
-
'transition:opacity 0.3s',
|
|
662
|
-
].join(';');
|
|
663
|
-
splash.textContent = 'waiting firstFrameReady…';
|
|
664
|
-
screen.appendChild(iframe);
|
|
665
|
-
screen.appendChild(splash);
|
|
666
|
-
// GM 席は端末を模す必要が無いので overlay chrome / 擬似ノッチを載せず素の画面にする。
|
|
667
|
-
if (pane.seatKind !== 'admin') {
|
|
668
|
-
screen.appendChild(createCellChrome(opts, i, false, {
|
|
669
|
-
toggle: toggleNotchSim,
|
|
670
|
-
register: (apply) => {
|
|
671
|
-
apply(notchSimOn);
|
|
672
|
-
notchAppliers.push(apply);
|
|
673
|
-
},
|
|
674
|
-
}));
|
|
675
|
-
}
|
|
676
|
-
// 先読み警告の内訳パネル。 バッジクリックで開閉する。 画面の上に重ねるので
|
|
677
|
-
// ゲームの見た目は普段どおりのまま、 必要なときだけ前に出る。
|
|
678
|
-
const predictionPanel = document.createElement('div');
|
|
679
|
-
predictionPanel.style.cssText = [
|
|
680
|
-
'display:none;position:absolute;z-index:20;left:6px;right:6px;top:6px',
|
|
681
|
-
'background:#16213e;border:1px solid #ffb86c;border-radius:6px',
|
|
682
|
-
'padding:8px 10px;font-family:monospace;font-size:10px;line-height:1.6',
|
|
683
|
-
'color:#ffb86c;text-align:left;white-space:pre-wrap;cursor:pointer',
|
|
684
|
-
].join(';');
|
|
685
|
-
predictionPanel.addEventListener('click', () => {
|
|
686
|
-
predictionPanel.style.display = 'none';
|
|
687
|
-
});
|
|
688
|
-
const notchDecor = createNotchDecor();
|
|
689
|
-
frameWrap.appendChild(screen);
|
|
690
|
-
frameWrap.appendChild(notchDecor);
|
|
691
|
-
frameWrap.appendChild(predictionPanel);
|
|
692
|
-
statusRow.predictionWarn.addEventListener('click', () => {
|
|
693
|
-
predictionPanel.style.display = predictionPanel.style.display === 'none' ? 'block' : 'none';
|
|
694
|
-
});
|
|
695
|
-
// 擬似ノッチ ON/OFF を screen の inset だけで切り替える (iframe は再生成しない)。
|
|
696
|
-
const applyNotch = (on) => {
|
|
697
|
-
if (pane.seatKind === 'admin')
|
|
698
|
-
on = false;
|
|
699
|
-
if (on) {
|
|
700
|
-
screen.style.top = `${SIM_NOTCH.top}px`;
|
|
701
|
-
screen.style.left = `${SIM_NOTCH.left}px`;
|
|
702
|
-
screen.style.width = `calc(100% - ${SIM_NOTCH.left + SIM_NOTCH.right}px)`;
|
|
703
|
-
screen.style.height = `calc(100% - ${SIM_NOTCH.top + SIM_NOTCH.bottom}px)`;
|
|
704
|
-
screen.style.borderRadius = `${SIM_NOTCH.radius}px`;
|
|
705
|
-
notchDecor.style.display = 'block';
|
|
706
|
-
}
|
|
707
|
-
else {
|
|
708
|
-
screen.style.top = '0';
|
|
709
|
-
screen.style.left = '0';
|
|
710
|
-
screen.style.width = '100%';
|
|
711
|
-
screen.style.height = '100%';
|
|
712
|
-
screen.style.borderRadius = '0';
|
|
713
|
-
notchDecor.style.display = 'none';
|
|
714
|
-
}
|
|
715
|
-
};
|
|
716
|
-
applyNotch(notchSimOn);
|
|
717
|
-
notchAppliers.push(applyNotch);
|
|
718
|
-
cell.appendChild(label);
|
|
719
|
-
cell.appendChild(frameWrap);
|
|
720
|
-
cells.push(cell);
|
|
721
|
-
target.appendChild(cell);
|
|
722
|
-
if (iframe.contentWindow) {
|
|
723
|
-
iframeStatusDots.set(iframe.contentWindow, statusRow.dots);
|
|
724
|
-
iframeSplashes.set(iframe.contentWindow, splash);
|
|
725
|
-
iframePredictionWarns.set(iframe.contentWindow, {
|
|
726
|
-
badge: statusRow.predictionWarn,
|
|
727
|
-
panel: predictionPanel,
|
|
728
|
-
entries: [],
|
|
729
|
-
});
|
|
730
|
-
}
|
|
731
|
-
}
|
|
732
|
-
layout();
|
|
733
|
-
};
|
|
734
|
-
const onParentMessage = (event) => {
|
|
735
|
-
const dots = iframeStatusDots.get(event.source);
|
|
736
|
-
if (!dots)
|
|
737
|
-
return;
|
|
738
|
-
let data = null;
|
|
739
|
-
try {
|
|
740
|
-
data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
741
|
-
}
|
|
742
|
-
catch {
|
|
743
|
-
return;
|
|
744
|
-
}
|
|
745
|
-
if (!data || data.channel !== 'sdk')
|
|
746
|
-
return;
|
|
747
|
-
const type = data.type;
|
|
748
|
-
if (type === 'ready' || type === 'firstFrameReady' || type === 'gameReady') {
|
|
749
|
-
dots[type].style.color = '#5cd6a0';
|
|
750
|
-
}
|
|
751
|
-
if (type === 'firstFrameReady') {
|
|
752
|
-
const splash = iframeSplashes.get(event.source);
|
|
753
|
-
if (splash) {
|
|
754
|
-
splash.style.opacity = '0';
|
|
755
|
-
splash.style.pointerEvents = 'none';
|
|
756
|
-
setTimeout(() => splash.remove(), 350);
|
|
757
|
-
iframeSplashes.delete(event.source);
|
|
758
|
-
}
|
|
759
|
-
}
|
|
760
|
-
if (type === 'predictionWarning') {
|
|
761
|
-
const slot = iframePredictionWarns.get(event.source);
|
|
762
|
-
const payload = data.payload;
|
|
763
|
-
if (!slot || typeof payload !== 'object' || payload === null)
|
|
764
|
-
return;
|
|
765
|
-
if (!('action' in payload) || !('api' in payload))
|
|
766
|
-
return;
|
|
767
|
-
const { action, api } = payload;
|
|
768
|
-
if (typeof action !== 'string' || typeof api !== 'string')
|
|
769
|
-
return;
|
|
770
|
-
slot.entries.push(`${action} → ${api}`);
|
|
771
|
-
const body = [
|
|
772
|
-
'⚠ 先読み中に、サーバーと結果が一致しない API が呼ばれました',
|
|
773
|
-
'',
|
|
774
|
-
...slot.entries.map((e) => ` ${e}`),
|
|
775
|
-
'',
|
|
776
|
-
'この action を serverOnly() で wrap するか、値を state に書かないでください。',
|
|
777
|
-
'直し方の詳細は console を参照。',
|
|
778
|
-
'',
|
|
779
|
-
'(クリックで閉じる)',
|
|
780
|
-
].join('\n');
|
|
781
|
-
slot.badge.textContent = `⚠ ${slot.entries.length}`;
|
|
782
|
-
slot.badge.title = body;
|
|
783
|
-
slot.badge.style.display = 'inline-flex';
|
|
784
|
-
slot.panel.textContent = body;
|
|
785
|
-
}
|
|
786
|
-
};
|
|
787
|
-
window.addEventListener('message', onParentMessage);
|
|
788
|
-
window.addEventListener('resize', layout);
|
|
789
|
-
render();
|
|
790
|
-
}
|