@uzuhq/code-sdk 0.3.8
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 +573 -0
- package/dist/dev-globals.d.ts +28 -0
- package/dist/dev-globals.js +16 -0
- package/dist/dev-hooks.d.ts +233 -0
- package/dist/dev-hooks.js +130 -0
- package/dist/dev-hooks.test.d.ts +1 -0
- package/dist/dev-hooks.test.js +294 -0
- package/dist/dev-state-patch.d.ts +81 -0
- package/dist/dev-state-patch.js +295 -0
- package/dist/dev-state-patch.test.d.ts +1 -0
- package/dist/dev-state-patch.test.js +333 -0
- package/dist/index.d.ts +104 -0
- package/dist/index.js +440 -0
- package/dist/json-patch.d.ts +7 -0
- package/dist/json-patch.js +78 -0
- package/dist/random.d.ts +11 -0
- package/dist/random.js +34 -0
- package/dist/reconnectable-ws.d.ts +60 -0
- package/dist/reconnectable-ws.js +229 -0
- package/dist/room.d.ts +23 -0
- package/dist/room.js +36 -0
- package/dist/run/local-server-action.d.ts +15 -0
- package/dist/run/local-server-action.js +97 -0
- package/dist/run/optimistic-action-client.d.ts +57 -0
- package/dist/run/optimistic-action-client.js +119 -0
- package/dist/run/server-action.d.ts +16 -0
- package/dist/run/server-action.js +170 -0
- package/dist/server-only.d.ts +26 -0
- package/dist/server-only.js +20 -0
- package/dist/sync/local.d.ts +8 -0
- package/dist/sync/local.js +51 -0
- package/dist/sync/online.d.ts +5 -0
- package/dist/sync/online.js +165 -0
- package/dist/types.d.ts +142 -0
- package/dist/types.js +8 -0
- package/package.json +34 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
export { SERVER_TIME, DEFAULT_ICON_URLS } from './types.js';
|
|
2
|
+
export { serverOnly, isServerOnlyAction } from './server-only.js';
|
|
3
|
+
export { Room } from './room.js';
|
|
4
|
+
export { ReconnectableWebSocket } from './reconnectable-ws.js';
|
|
5
|
+
export { SeededRandomImpl } from './random.js';
|
|
6
|
+
export { applyJsonMergePatch, applyJsonPatch } from './dev-state-patch.js';
|
|
7
|
+
export { attachDevHooks, createDevHooks } from './dev-hooks.js';
|
|
8
|
+
import { Room } from './room.js';
|
|
9
|
+
import { ReconnectableWebSocket } from './reconnectable-ws.js';
|
|
10
|
+
import { runOnlineServerAction } from './run/server-action.js';
|
|
11
|
+
import { runLocalServerAction } from './run/local-server-action.js';
|
|
12
|
+
import { syncLocal } from './sync/local.js';
|
|
13
|
+
import { syncOnline } from './sync/online.js';
|
|
14
|
+
import { attachDevHooks } from './dev-hooks.js';
|
|
15
|
+
const gameHandlers = new Map();
|
|
16
|
+
const _playersChangedHandlers = [];
|
|
17
|
+
let _room = null;
|
|
18
|
+
let _lastStateSnap = null;
|
|
19
|
+
let _onRoomCallbacks = [];
|
|
20
|
+
let _wsEndpoint = null;
|
|
21
|
+
let _syncEndpoint = null;
|
|
22
|
+
let _gameEndpoint = null;
|
|
23
|
+
let _syncWs = null;
|
|
24
|
+
let _initialized = false;
|
|
25
|
+
let _usesRun = false;
|
|
26
|
+
let _playerId = null;
|
|
27
|
+
// ─── Dev hooks state (window.__uzu_dev 経由の外部 automation 用) ──────
|
|
28
|
+
// Flutter native ホスト (= window.FlutterHost あり) では一切 attach されない。
|
|
29
|
+
// 注意: `isHosted` は dev harness 子 iframe (parent !== window) も含むため、
|
|
30
|
+
// dev hooks の本番判定は `isHosted` ではなく `window.FlutterHost` の有無で行う。
|
|
31
|
+
// これにより Flutter native では非公開、 dev harness 子 iframe / Playwright iframe /
|
|
32
|
+
// 単独 page では attach、 という挙動を両立させる。
|
|
33
|
+
let _runHandle = null;
|
|
34
|
+
let _syncHandle = null;
|
|
35
|
+
const _snapshotListeners = new Set();
|
|
36
|
+
function notifyDevSnapshot(snap) {
|
|
37
|
+
_snapshotListeners.forEach((cb) => {
|
|
38
|
+
try {
|
|
39
|
+
cb(snap);
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
console.warn('[uzu_dev] snapshot listener threw:', err);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
function attachDevHooksIfNotHosted() {
|
|
47
|
+
// Flutter native ホスト (本番) では attach しない。
|
|
48
|
+
if (window.FlutterHost)
|
|
49
|
+
return;
|
|
50
|
+
const ctx = {
|
|
51
|
+
getSnapshot: () => _lastStateSnap?.state ?? null,
|
|
52
|
+
getRawState: _runHandle?.getRawState ?? _syncHandle?.getRawState,
|
|
53
|
+
setRawState: _runHandle?.setRawState ?? _syncHandle?.setRawState,
|
|
54
|
+
mergeRawState: _runHandle?.mergeRawState ?? _syncHandle?.mergeRawState,
|
|
55
|
+
patchRawState: _runHandle?.patchRawState ?? _syncHandle?.patchRawState,
|
|
56
|
+
playerId: () => _lastStateSnap?.myId ?? _playerId,
|
|
57
|
+
subscribeSnapshot: (cb) => {
|
|
58
|
+
_snapshotListeners.add(cb);
|
|
59
|
+
return () => {
|
|
60
|
+
_snapshotListeners.delete(cb);
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
attachDevHooks(ctx);
|
|
65
|
+
}
|
|
66
|
+
// ─── Public API ─────────────────────────────────────────────
|
|
67
|
+
export const isHosted = !!window.FlutterHost || window.parent !== window;
|
|
68
|
+
export function getRoom() {
|
|
69
|
+
return _room;
|
|
70
|
+
}
|
|
71
|
+
export function onRoom(callback) {
|
|
72
|
+
if (_room) {
|
|
73
|
+
callback(_room);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
_onRoomCallbacks.push(callback);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function init(opts) {
|
|
80
|
+
const params = new URLSearchParams(window.location.search);
|
|
81
|
+
// 送信元プレイヤー ID。 ホストが自分宛メッセージかを判定するために
|
|
82
|
+
// ブリッジメッセージのエンベロープへ含める (sendRaw 参照)。
|
|
83
|
+
// Flutter ホスト (QueryParamsBuilder) と dev ハーネスは必ず seatId を付与する。
|
|
84
|
+
// bridge envelope の key 名は playerId のまま (postMessage bridge の既存プロトコル)。
|
|
85
|
+
_playerId = params.get('seatId');
|
|
86
|
+
if (isHosted && !_playerId) {
|
|
87
|
+
console.warn('[UZU SDK] playerId is missing in the URL. ' +
|
|
88
|
+
'Host-side sender filtering will fall back to legacy behavior.');
|
|
89
|
+
}
|
|
90
|
+
// ?server=ws://host:port → エンドポイントのベースURLを外部注入
|
|
91
|
+
const serverParam = params.get('server');
|
|
92
|
+
if (serverParam && serverParam.length > 0) {
|
|
93
|
+
const base = serverParam.replace(/\/$/, '');
|
|
94
|
+
_wsEndpoint = `${base}/ws/rooms`;
|
|
95
|
+
_syncEndpoint = `${base}/ws/sync`;
|
|
96
|
+
// ServerAction: /ws/games/{revisionId}/{roomId} の形式で接続
|
|
97
|
+
const revisionId = params.get('revisionId');
|
|
98
|
+
if (!revisionId) {
|
|
99
|
+
throw new Error('[UZU SDK] revisionId is required when server is specified. Pass ?revisionId=xxx in the URL.');
|
|
100
|
+
}
|
|
101
|
+
_gameEndpoint = `${base}/ws/games/${revisionId}`;
|
|
102
|
+
}
|
|
103
|
+
// init() の引数は query parameter より優先
|
|
104
|
+
if (opts?.wsEndpoint)
|
|
105
|
+
_wsEndpoint = opts.wsEndpoint;
|
|
106
|
+
if (opts?.syncEndpoint)
|
|
107
|
+
_syncEndpoint = opts.syncEndpoint;
|
|
108
|
+
// 親 frame (= harness が立つ場所) では SDK runtime は何もしない。
|
|
109
|
+
// 子 iframe の生成は `uzu dev` CLI が配信する harness の責務に切り出している。
|
|
110
|
+
if (!isHosted)
|
|
111
|
+
return;
|
|
112
|
+
if (!_initialized) {
|
|
113
|
+
window.__ps = {
|
|
114
|
+
onMessage: (msg) => {
|
|
115
|
+
console.log(`[SDK] ⬅ recv __ps.onMessage channel=${msg.channel} type=${msg.type}`, JSON.stringify(msg));
|
|
116
|
+
handleMessage(msg);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
// Web iframe モード: 親ウィンドウからの postMessage を受信
|
|
120
|
+
window.addEventListener('message', (event) => {
|
|
121
|
+
try {
|
|
122
|
+
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
123
|
+
if (data && typeof data.channel === 'string' && typeof data.type === 'string') {
|
|
124
|
+
console.log(`[SDK] ⬅ recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
|
|
125
|
+
handleMessage(data);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// JSON でないメッセージは無視
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
// HUD 回避領域の CSS カスタムプロパティを設定
|
|
133
|
+
// クエリパラメータがあればその値を使用し、 なければ players 数から推定
|
|
134
|
+
const hudX = params.get('uzuHudInsetX');
|
|
135
|
+
const hudY = params.get('uzuHudInsetY');
|
|
136
|
+
const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
|
|
137
|
+
document.documentElement.style.setProperty('--uzu-hud-inset-x', `${hudX ?? fallbackX}px`);
|
|
138
|
+
document.documentElement.style.setProperty('--uzu-hud-inset-y', `${hudY ?? fallbackY}px`);
|
|
139
|
+
sendRaw('sdk', 'ready', {});
|
|
140
|
+
_initialized = true;
|
|
141
|
+
}
|
|
142
|
+
// Relay 自動接続 (wsEndpoint 未設定時はスキップ)
|
|
143
|
+
if (!_usesRun) {
|
|
144
|
+
const roomIdParam = params.get('roomId');
|
|
145
|
+
if (roomIdParam) {
|
|
146
|
+
if (_wsEndpoint) {
|
|
147
|
+
connectRoom(roomIdParam);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
console.warn('[SDK] roomId is present but server is not configured — skipping Relay connection');
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// run() / sync() を経由しない init() 単独ケースでも dev hooks を attach する
|
|
155
|
+
// (Relay モード等。 run/sync 経由なら呼び出し元で再 attach される)
|
|
156
|
+
if (!_usesRun) {
|
|
157
|
+
attachDevHooksIfNotHosted();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** game channel でカスタムメッセージを送信する。 native コマンドには使用不可。 */
|
|
161
|
+
export function send(type, payload) {
|
|
162
|
+
sendRaw('game', type, payload ?? {});
|
|
163
|
+
}
|
|
164
|
+
/** game channel のカスタムメッセージを受信する。 */
|
|
165
|
+
export function on(type, handler) {
|
|
166
|
+
if (!gameHandlers.has(type))
|
|
167
|
+
gameHandlers.set(type, []);
|
|
168
|
+
gameHandlers.get(type).push(handler);
|
|
169
|
+
}
|
|
170
|
+
// ─── Native コマンド (sdk channel) ──────────────────────
|
|
171
|
+
export function playSound(sound) {
|
|
172
|
+
sendRaw('sdk', 'playSound', { sound });
|
|
173
|
+
}
|
|
174
|
+
export function playBgm(sound) {
|
|
175
|
+
sendRaw('sdk', 'playBgm', { sound });
|
|
176
|
+
}
|
|
177
|
+
export function stopBgm() {
|
|
178
|
+
sendRaw('sdk', 'stopBgm', {});
|
|
179
|
+
}
|
|
180
|
+
export function setMicEnabled(enabled) {
|
|
181
|
+
sendRaw('sdk', 'setMicEnabled', { enabled });
|
|
182
|
+
}
|
|
183
|
+
export function changeRoom(roomId) {
|
|
184
|
+
sendRaw('sdk', 'changeRoom', { roomId });
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* splash 描画完了を Flutter ホストに通知する。
|
|
188
|
+
*
|
|
189
|
+
* YouTube Playables の `firstFrameReady()` 相当。 Flutter は受信時に splash overlay を
|
|
190
|
+
* 非表示にし WebView を可視化する。 **呼ばないと splash が出っぱなしになる**。
|
|
191
|
+
*
|
|
192
|
+
* 呼び出しタイミング: scenario が最初の絵 (splash / loading 画面) を画面に描いた直後。
|
|
193
|
+
* paint commit を保証するため `requestAnimationFrame` を 1 段挟むのが定石。
|
|
194
|
+
*
|
|
195
|
+
* @example
|
|
196
|
+
* ```ts
|
|
197
|
+
* init();
|
|
198
|
+
* drawSplash();
|
|
199
|
+
* requestAnimationFrame(() => firstFrameReady());
|
|
200
|
+
* ```
|
|
201
|
+
*/
|
|
202
|
+
export function firstFrameReady() {
|
|
203
|
+
sendRaw('sdk', 'firstFrameReady', {});
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* ユーザー操作受付可能を Flutter ホストに通知する。
|
|
207
|
+
*
|
|
208
|
+
* YouTube Playables の `gameReady()` 相当。 Flutter は受信時に入力受付を開始し、TTI
|
|
209
|
+
* (Time To Interactive) 計測を終了する。 将来の広告タイマー / leaderboard / analytics
|
|
210
|
+
* の発火点としても使われる予定。
|
|
211
|
+
*
|
|
212
|
+
* 呼び出しタイミング: asset load 完了、 ゲーム本体の setup 完了、 入力受付可能になった直後。
|
|
213
|
+
*
|
|
214
|
+
* @example
|
|
215
|
+
* ```ts
|
|
216
|
+
* await loadAssets();
|
|
217
|
+
* setupGame();
|
|
218
|
+
* gameReady();
|
|
219
|
+
* ```
|
|
220
|
+
*/
|
|
221
|
+
export function gameReady() {
|
|
222
|
+
sendRaw('sdk', 'gameReady', {});
|
|
223
|
+
}
|
|
224
|
+
/** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
|
|
225
|
+
export function onPlayersChanged(handler) {
|
|
226
|
+
_playersChangedHandlers.push(handler);
|
|
227
|
+
}
|
|
228
|
+
export function run(config) {
|
|
229
|
+
_usesRun = true;
|
|
230
|
+
const params = new URLSearchParams(window.location.search);
|
|
231
|
+
// 親 frame では SDK runtime は何もしない。 harness は uzu-cli の `uzu dev` が担当。
|
|
232
|
+
if (!isHosted)
|
|
233
|
+
return;
|
|
234
|
+
init();
|
|
235
|
+
// Wrap onState to capture latest state for dev hooks
|
|
236
|
+
const origOnState = config.onState;
|
|
237
|
+
const wrappedConfig = {
|
|
238
|
+
...config,
|
|
239
|
+
onState(state, myPlayerId) {
|
|
240
|
+
_lastStateSnap = { state, serverTime: 0, myId: myPlayerId };
|
|
241
|
+
origOnState(state, myPlayerId);
|
|
242
|
+
notifyDevSnapshot(state);
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
const roomId = params.get('roomId');
|
|
246
|
+
if (!roomId) {
|
|
247
|
+
// ServerAction: ローカルで logic を直接実行 (ソロモード)
|
|
248
|
+
_runHandle = runLocalServerAction(wrappedConfig);
|
|
249
|
+
}
|
|
250
|
+
else if (_gameEndpoint) {
|
|
251
|
+
// ServerAction モード: GameRoom に接続 (本番 Cloudflare Worker DO / uzu dev の Node ws)
|
|
252
|
+
const { seatId, seats } = resolveSeatParams(params);
|
|
253
|
+
runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, seats);
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
|
|
257
|
+
'Multiplayer requires a WebSocket server URL — pass ?server=ws://host:port in the URL.');
|
|
258
|
+
}
|
|
259
|
+
attachDevHooksIfNotHosted();
|
|
260
|
+
}
|
|
261
|
+
export function sync(config) {
|
|
262
|
+
_usesRun = true;
|
|
263
|
+
const params = new URLSearchParams(window.location.search);
|
|
264
|
+
// 親 frame では SDK runtime は何もしない。 harness は uzu-cli の `uzu dev` が担当。
|
|
265
|
+
if (!isHosted)
|
|
266
|
+
return;
|
|
267
|
+
init();
|
|
268
|
+
// Wrap onState to capture latest state for dev hooks
|
|
269
|
+
const origOnState = config.onState;
|
|
270
|
+
const wrappedConfig = {
|
|
271
|
+
...config,
|
|
272
|
+
onState(state, myPlayerId, serverTime) {
|
|
273
|
+
_lastStateSnap = { state, serverTime, myId: myPlayerId };
|
|
274
|
+
origOnState(state, myPlayerId, serverTime);
|
|
275
|
+
notifyDevSnapshot(state);
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
const roomId = params.get('roomId');
|
|
279
|
+
if (!roomId) {
|
|
280
|
+
_syncHandle = syncLocal(wrappedConfig);
|
|
281
|
+
}
|
|
282
|
+
else if (_syncEndpoint) {
|
|
283
|
+
const { seatId, seats } = resolveSeatParams(params);
|
|
284
|
+
const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, seats);
|
|
285
|
+
_syncWs = ws;
|
|
286
|
+
// online sync: dev hooks は read-only (setRawState 未提供)
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
|
|
290
|
+
'Multiplayer sync requires a WebSocket server URL — pass ?server=ws://host:port in the URL.');
|
|
291
|
+
}
|
|
292
|
+
attachDevHooksIfNotHosted();
|
|
293
|
+
}
|
|
294
|
+
// ─── Internal ───────────────────────────────────────────────
|
|
295
|
+
function resolveSeatParams(params) {
|
|
296
|
+
const seatId = params.get('seatId');
|
|
297
|
+
if (!seatId) {
|
|
298
|
+
throw new Error('[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.');
|
|
299
|
+
}
|
|
300
|
+
const json = params.get('seats');
|
|
301
|
+
if (!json) {
|
|
302
|
+
throw new Error('[UZU SDK] seats is required. Pass ?seats=[...] in the URL.');
|
|
303
|
+
}
|
|
304
|
+
const raw = JSON.parse(json);
|
|
305
|
+
const seats = raw.map((p) => {
|
|
306
|
+
if (!p.kind) {
|
|
307
|
+
throw new Error(`[UZU SDK] seats entry ${p.id} is missing kind.`);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
id: p.id,
|
|
311
|
+
nickname: p.name,
|
|
312
|
+
iconUrl: p.iconUrl,
|
|
313
|
+
characterId: p.characterId,
|
|
314
|
+
kind: p.kind,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
return { seatId, seats };
|
|
318
|
+
}
|
|
319
|
+
function requireEndpoint(endpoint, name) {
|
|
320
|
+
if (!endpoint) {
|
|
321
|
+
throw new Error(`[UZU SDK] ${name} is not configured. Pass ?server=ws://host:port or call init({ wsEndpoint, syncEndpoint }).`);
|
|
322
|
+
}
|
|
323
|
+
return endpoint;
|
|
324
|
+
}
|
|
325
|
+
function connectRoom(roomId) {
|
|
326
|
+
const params = new URLSearchParams(window.location.search);
|
|
327
|
+
const { seatId } = resolveSeatParams(params);
|
|
328
|
+
const wsParams = new URLSearchParams({ playerId: seatId });
|
|
329
|
+
const wsUrl = `${requireEndpoint(_wsEndpoint, 'wsEndpoint')}/${roomId}?${wsParams}`;
|
|
330
|
+
console.log(`[SDK] 🔗 connectRoom wsUrl=${wsUrl}`);
|
|
331
|
+
const ws = new ReconnectableWebSocket(wsUrl);
|
|
332
|
+
ws.addEventListener('message', (ev) => {
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(ev.data);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
console.log(`[SDK] ⬅ recv ws type=${parsed.type}`, JSON.stringify(parsed));
|
|
341
|
+
if (parsed.type === '__room_init') {
|
|
342
|
+
const myId = parsed.myId;
|
|
343
|
+
console.log(`[SDK] ✅ Room initialized myId=${myId}`);
|
|
344
|
+
_room = new Room(ws, myId);
|
|
345
|
+
_onRoomCallbacks.forEach((cb) => cb(_room));
|
|
346
|
+
_onRoomCallbacks = [];
|
|
347
|
+
}
|
|
348
|
+
else if (parsed.type === '__reconnected') {
|
|
349
|
+
// 再接続: ws は同じ ReconnectableWebSocket インスタンスなので Room 再作成不要
|
|
350
|
+
console.log(`[SDK] 🔄 Room reconnected`);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/** 内部送信関数。 channel + type + payload のエンベロープで送信する。 */
|
|
355
|
+
function sendRaw(channel, type, payload) {
|
|
356
|
+
const msg = {
|
|
357
|
+
channel,
|
|
358
|
+
type,
|
|
359
|
+
payload,
|
|
360
|
+
...(_playerId ? { playerId: _playerId } : {}),
|
|
361
|
+
};
|
|
362
|
+
console.log(`[SDK] ➡ send channel=${channel} type=${type}`, JSON.stringify(msg));
|
|
363
|
+
if (window.FlutterHost) {
|
|
364
|
+
// Desktop/Mobile: webview_flutter の JavaScriptChannel 経由
|
|
365
|
+
window.FlutterHost.postMessage(JSON.stringify(msg));
|
|
366
|
+
}
|
|
367
|
+
else if (window.parent !== window) {
|
|
368
|
+
// Web iframe: 親ウィンドウへ postMessage 経由
|
|
369
|
+
window.parent.postMessage(JSON.stringify(msg), '*');
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
function handleMessage(msg) {
|
|
373
|
+
const { channel, type, payload } = msg;
|
|
374
|
+
if (channel === 'sdk') {
|
|
375
|
+
// SDK 内部メッセージの処理
|
|
376
|
+
switch (type) {
|
|
377
|
+
case 'getState':
|
|
378
|
+
console.log(`[SDK] 🔧 handleMessage sdk/getState → responding`);
|
|
379
|
+
sendRaw('sdk', 'stateResponse', { state: _lastStateSnap });
|
|
380
|
+
return;
|
|
381
|
+
case 'clearState':
|
|
382
|
+
console.log(`[SDK] 🗑 handleMessage sdk/clearState → forwarding to server`);
|
|
383
|
+
if (_syncWs && _syncWs.connectionState === 'connected') {
|
|
384
|
+
_syncWs.send(JSON.stringify({ type: '__clear_state' }));
|
|
385
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
case 'playersChanged': {
|
|
388
|
+
const players = (payload?.players ?? {});
|
|
389
|
+
_playersChangedHandlers.forEach((fn) => fn(players));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (channel === 'game') {
|
|
396
|
+
const h = gameHandlers.get(type) || [];
|
|
397
|
+
console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
|
|
398
|
+
h.forEach((fn) => fn(payload ?? {}));
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ─── HUD inset 計算 ─────────────────────────────────────────
|
|
403
|
+
// Flutter 側の HudInsets と同じ定数・ロジック
|
|
404
|
+
const HUD_TOP = 8;
|
|
405
|
+
const HUD_LEFT = 12;
|
|
406
|
+
const UZU_BUTTON_SIZE = 44;
|
|
407
|
+
const GAP = 6;
|
|
408
|
+
const ACTION_ITEM_WIDTH = 38;
|
|
409
|
+
/**
|
|
410
|
+
* HUD 回避領域の右下座標を算出する。
|
|
411
|
+
* クエリパラメータに uzuHudInsetX/Y がない場合のフォールバック値として使用。
|
|
412
|
+
*
|
|
413
|
+
* 前提: UZU ボタンは常に表示される (onMenuPressed は常に非 null)。
|
|
414
|
+
* Dart 側の HudInsets.x() は hasMenu=false を扱えるが、
|
|
415
|
+
* フォールバック計算では安全側に倒して常に UZU ボタンありで算出する。
|
|
416
|
+
*
|
|
417
|
+
* - players が取得できる場合: 2人以上→アクション数2、1人→アクション数0
|
|
418
|
+
* - players が取得できない場合: アクション数2 (安全側に倒す)
|
|
419
|
+
*/
|
|
420
|
+
function calcHudInsets(params) {
|
|
421
|
+
const y = HUD_TOP + UZU_BUTTON_SIZE;
|
|
422
|
+
let actionCount = 2; // デフォルト: 安全側 (マイク + チャット想定)
|
|
423
|
+
const json = params.get('seats');
|
|
424
|
+
if (json) {
|
|
425
|
+
try {
|
|
426
|
+
// spectator / admin 席はゲームの player 数に数えない
|
|
427
|
+
const raw = JSON.parse(json);
|
|
428
|
+
const playerCount = raw.filter((p) => p.kind === 'player').length;
|
|
429
|
+
actionCount = playerCount >= 2 ? 2 : 0;
|
|
430
|
+
}
|
|
431
|
+
catch {
|
|
432
|
+
// パース失敗時はデフォルト値を使用
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
let x = HUD_LEFT + UZU_BUTTON_SIZE;
|
|
436
|
+
if (actionCount > 0) {
|
|
437
|
+
x += GAP + ACTION_ITEM_WIDTH * actionCount;
|
|
438
|
+
}
|
|
439
|
+
return { x, y };
|
|
440
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export interface Operation {
|
|
2
|
+
op: 'replace' | 'add' | 'remove';
|
|
3
|
+
path: string;
|
|
4
|
+
value?: unknown;
|
|
5
|
+
}
|
|
6
|
+
export declare function compare(oldObj: any, newObj: any, basePath?: string): Operation[];
|
|
7
|
+
export declare function applyPatch(doc: any, ops: Operation[]): boolean;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
function escapePointer(key) {
|
|
2
|
+
return key.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
3
|
+
}
|
|
4
|
+
function unescapePointer(token) {
|
|
5
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
6
|
+
}
|
|
7
|
+
export function compare(oldObj, newObj, basePath = '') {
|
|
8
|
+
if (oldObj === newObj)
|
|
9
|
+
return [];
|
|
10
|
+
// Handle nulls / primitives
|
|
11
|
+
if (oldObj === null ||
|
|
12
|
+
newObj === null ||
|
|
13
|
+
typeof oldObj !== 'object' ||
|
|
14
|
+
typeof newObj !== 'object') {
|
|
15
|
+
return [{ op: 'replace', path: basePath || '/', value: newObj }];
|
|
16
|
+
}
|
|
17
|
+
// Arrays: compare as whole (no element-level diff)
|
|
18
|
+
if (Array.isArray(oldObj) || Array.isArray(newObj)) {
|
|
19
|
+
if (JSON.stringify(oldObj) === JSON.stringify(newObj))
|
|
20
|
+
return [];
|
|
21
|
+
return [{ op: 'replace', path: basePath || '/', value: newObj }];
|
|
22
|
+
}
|
|
23
|
+
// Objects: recurse
|
|
24
|
+
const ops = [];
|
|
25
|
+
const oldKeys = Object.keys(oldObj);
|
|
26
|
+
const newKeys = Object.keys(newObj);
|
|
27
|
+
// Removed keys
|
|
28
|
+
for (const key of oldKeys) {
|
|
29
|
+
if (!(key in newObj)) {
|
|
30
|
+
ops.push({ op: 'remove', path: `${basePath}/${escapePointer(key)}` });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
// Added or changed keys
|
|
34
|
+
for (const key of newKeys) {
|
|
35
|
+
const childPath = `${basePath}/${escapePointer(key)}`;
|
|
36
|
+
if (!(key in oldObj)) {
|
|
37
|
+
ops.push({ op: 'add', path: childPath, value: newObj[key] });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const childOps = compare(oldObj[key], newObj[key], childPath);
|
|
41
|
+
ops.push(...childOps);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return ops;
|
|
45
|
+
}
|
|
46
|
+
export function applyPatch(doc, ops) {
|
|
47
|
+
for (const op of ops) {
|
|
48
|
+
const tokens = op.path.split('/').slice(1).map(unescapePointer);
|
|
49
|
+
if (tokens.length === 0)
|
|
50
|
+
return false;
|
|
51
|
+
if (op.op === 'replace' || op.op === 'add') {
|
|
52
|
+
let target = doc;
|
|
53
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
54
|
+
target = target?.[tokens[i]];
|
|
55
|
+
if (target === undefined || target === null)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const lastKey = tokens[tokens.length - 1];
|
|
59
|
+
target[lastKey] = op.value;
|
|
60
|
+
}
|
|
61
|
+
else if (op.op === 'remove') {
|
|
62
|
+
let target = doc;
|
|
63
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
64
|
+
target = target?.[tokens[i]];
|
|
65
|
+
if (target === undefined || target === null)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const lastKey = tokens[tokens.length - 1];
|
|
69
|
+
if (Array.isArray(target)) {
|
|
70
|
+
target.splice(Number(lastKey), 1);
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
delete target[lastKey];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
package/dist/random.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SeededRandom } from './types.js';
|
|
2
|
+
export declare class SeededRandomImpl implements SeededRandom {
|
|
3
|
+
private _state;
|
|
4
|
+
constructor(seed: number);
|
|
5
|
+
get state(): number;
|
|
6
|
+
static fromState(state: number): SeededRandomImpl;
|
|
7
|
+
float(): number;
|
|
8
|
+
int(max: number): number;
|
|
9
|
+
pick<T>(array: T[]): T;
|
|
10
|
+
shuffle<T>(array: T[]): T[];
|
|
11
|
+
}
|
package/dist/random.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export class SeededRandomImpl {
|
|
2
|
+
constructor(seed) {
|
|
3
|
+
this._state = seed | 0;
|
|
4
|
+
}
|
|
5
|
+
get state() {
|
|
6
|
+
return this._state;
|
|
7
|
+
}
|
|
8
|
+
static fromState(state) {
|
|
9
|
+
const r = new SeededRandomImpl(0);
|
|
10
|
+
r._state = state;
|
|
11
|
+
return r;
|
|
12
|
+
}
|
|
13
|
+
float() {
|
|
14
|
+
this._state |= 0;
|
|
15
|
+
this._state = (this._state + 0x6d2b79f5) | 0;
|
|
16
|
+
let t = Math.imul(this._state ^ (this._state >>> 15), 1 | this._state);
|
|
17
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
18
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
19
|
+
}
|
|
20
|
+
int(max) {
|
|
21
|
+
return Math.floor(this.float() * max);
|
|
22
|
+
}
|
|
23
|
+
pick(array) {
|
|
24
|
+
return array[this.int(array.length)];
|
|
25
|
+
}
|
|
26
|
+
shuffle(array) {
|
|
27
|
+
const a = [...array];
|
|
28
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
29
|
+
const j = this.int(i + 1);
|
|
30
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
31
|
+
}
|
|
32
|
+
return a;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自動再接続 WebSocket ラッパー
|
|
3
|
+
*
|
|
4
|
+
* - Exponential backoff + jitter 付き自動再接続
|
|
5
|
+
* - Heartbeat (__ping / __pong) による接続生存確認
|
|
6
|
+
* - 再接続中のメッセージバッファリング
|
|
7
|
+
*/
|
|
8
|
+
export type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
|
9
|
+
export interface ReconnectableWSOptions {
|
|
10
|
+
/** 最大再接続試行回数 (default: 15) */
|
|
11
|
+
maxReconnectAttempts?: number;
|
|
12
|
+
/** Backoff の基準遅延 ms (default: 1000) */
|
|
13
|
+
baseDelay?: number;
|
|
14
|
+
/** Backoff の最大遅延 ms (default: 30000) */
|
|
15
|
+
maxDelay?: number;
|
|
16
|
+
/** Jitter 係数 0〜1 (default: 0.5) */
|
|
17
|
+
jitterFactor?: number;
|
|
18
|
+
/** Heartbeat 送信間隔 ms (default: 25000) */
|
|
19
|
+
heartbeatInterval?: number;
|
|
20
|
+
/** Pong 待ちタイムアウト ms (default: 10000) */
|
|
21
|
+
heartbeatTimeout?: number;
|
|
22
|
+
/** 再接続中のバッファ上限 (default: 50) */
|
|
23
|
+
maxBufferSize?: number;
|
|
24
|
+
/** バッファ対象判定。false を返すとドロップ */
|
|
25
|
+
shouldBuffer?: (data: string) => boolean;
|
|
26
|
+
/** 接続状態変更コールバック */
|
|
27
|
+
onConnectionStateChange?: (state: ConnectionState) => void;
|
|
28
|
+
}
|
|
29
|
+
export declare class ReconnectableWebSocket {
|
|
30
|
+
private ws;
|
|
31
|
+
private readonly url;
|
|
32
|
+
private readonly opts;
|
|
33
|
+
private _state;
|
|
34
|
+
private reconnectAttempt;
|
|
35
|
+
private messageBuffer;
|
|
36
|
+
private messageHandlers;
|
|
37
|
+
private heartbeatTimer;
|
|
38
|
+
private heartbeatTimeoutTimer;
|
|
39
|
+
private reconnectTimer;
|
|
40
|
+
private disposed;
|
|
41
|
+
constructor(url: string, options?: ReconnectableWSOptions);
|
|
42
|
+
get connectionState(): ConnectionState;
|
|
43
|
+
/** WebSocket.readyState 互換 (既存コードとの互換用) */
|
|
44
|
+
get readyState(): number;
|
|
45
|
+
send(data: string): void;
|
|
46
|
+
addEventListener(type: 'message', handler: (ev: MessageEvent) => void): void;
|
|
47
|
+
removeEventListener(type: 'message', handler: (ev: MessageEvent) => void): void;
|
|
48
|
+
/** バッファをクリア(権威的 state 受信時に呼ぶ) */
|
|
49
|
+
clearBuffer(): void;
|
|
50
|
+
/** 全リソース解放 */
|
|
51
|
+
dispose(): void;
|
|
52
|
+
private connect;
|
|
53
|
+
private setState;
|
|
54
|
+
private scheduleReconnect;
|
|
55
|
+
private getReconnectDelay;
|
|
56
|
+
private flushBuffer;
|
|
57
|
+
private startHeartbeat;
|
|
58
|
+
private stopHeartbeat;
|
|
59
|
+
private handlePong;
|
|
60
|
+
}
|