@uzuhq/code-sdk 0.7.6 → 0.7.7
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/dev-globals.d.ts +12 -27
- package/dist/dev-globals.js +0 -15
- package/dist/dev-hooks-ClWM8HzI.d.ts +682 -0
- package/dist/index.d.ts +152 -66
- package/dist/index.js +1837 -416
- package/package.json +8 -5
- package/dist/action-types.test-d.d.ts +0 -11
- package/dist/action-types.test-d.js +0 -101
- package/dist/dev-hooks.d.ts +0 -241
- package/dist/dev-hooks.js +0 -132
- package/dist/dev-hooks.test.d.ts +0 -1
- package/dist/dev-hooks.test.js +0 -294
- package/dist/dev-prediction-traps.d.ts +0 -32
- package/dist/dev-prediction-traps.js +0 -0
- package/dist/dev-prediction-traps.test.d.ts +0 -1
- package/dist/dev-prediction-traps.test.js +0 -178
- package/dist/dev-state-patch.d.ts +0 -81
- package/dist/dev-state-patch.js +0 -295
- package/dist/dev-state-patch.test.d.ts +0 -1
- package/dist/dev-state-patch.test.js +0 -333
- package/dist/json-patch.d.ts +0 -7
- package/dist/json-patch.js +0 -78
- package/dist/random.d.ts +0 -11
- package/dist/random.js +0 -34
- package/dist/reconnectable-ws.d.ts +0 -60
- package/dist/reconnectable-ws.js +0 -229
- package/dist/room.d.ts +0 -23
- package/dist/room.js +0 -36
- package/dist/roster-params.test.d.ts +0 -1
- package/dist/roster-params.test.js +0 -86
- package/dist/run/local-server-action.d.ts +0 -16
- package/dist/run/local-server-action.js +0 -217
- package/dist/run/local-server-action.test.d.ts +0 -1
- package/dist/run/local-server-action.test.js +0 -242
- package/dist/run/optimistic-action-client.d.ts +0 -68
- package/dist/run/optimistic-action-client.js +0 -209
- package/dist/run/optimistic-action-client.test.d.ts +0 -1
- package/dist/run/optimistic-action-client.test.js +0 -430
- package/dist/run/server-action.d.ts +0 -16
- package/dist/run/server-action.js +0 -181
- package/dist/run/server-action.test.d.ts +0 -1
- package/dist/run/server-action.test.js +0 -105
- package/dist/server-clock.d.ts +0 -29
- package/dist/server-clock.js +0 -40
- package/dist/server-only.d.ts +0 -33
- package/dist/server-only.js +0 -21
- package/dist/sync/local.d.ts +0 -8
- package/dist/sync/local.js +0 -50
- package/dist/sync/online.d.ts +0 -5
- package/dist/sync/online.js +0 -165
- package/dist/types.d.ts +0 -345
- package/dist/types.js +0 -8
package/dist/index.js
CHANGED
|
@@ -1,20 +1,1537 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
1
|
+
//#region ../engine-core/src/types.ts
|
|
2
|
+
/** Sentinel value — patch の value にセットすると、サーバーが Date.now() に置換する */
|
|
3
|
+
const SERVER_TIME = "__SERVER_TIME__";
|
|
4
|
+
/** デフォルトのプレイヤーアイコン URL 一覧(dev / local モード用) */
|
|
5
|
+
const DEFAULT_ICON_URLS = [
|
|
6
|
+
"https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/4d0da24d-bcf2-4f7b-d1a0-f1bb8c747300/original",
|
|
7
|
+
"https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/8c75fccb-41d6-429d-e943-06c728a72a00/original",
|
|
8
|
+
"https://imagedelivery.net/htp-D7B2hJT5XtdWYN9e7Q/43f45d11-da38-4d6e-637d-3df78e583500/original"
|
|
9
|
+
];
|
|
10
|
+
//#endregion
|
|
11
|
+
//#region ../engine-core/src/json-patch.ts
|
|
12
|
+
function unescapePointer(token) {
|
|
13
|
+
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
14
|
+
}
|
|
15
|
+
function applyPatch(doc, ops) {
|
|
16
|
+
for (const op of ops) {
|
|
17
|
+
const tokens = op.path.split("/").slice(1).map(unescapePointer);
|
|
18
|
+
if (tokens.length === 0) return false;
|
|
19
|
+
if (op.op === "replace" || op.op === "add") {
|
|
20
|
+
let target = doc;
|
|
21
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
22
|
+
target = target?.[tokens[i]];
|
|
23
|
+
if (target === void 0 || target === null) return false;
|
|
24
|
+
}
|
|
25
|
+
const lastKey = tokens[tokens.length - 1];
|
|
26
|
+
target[lastKey] = op.value;
|
|
27
|
+
} else if (op.op === "remove") {
|
|
28
|
+
let target = doc;
|
|
29
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
30
|
+
target = target?.[tokens[i]];
|
|
31
|
+
if (target === void 0 || target === null) return false;
|
|
32
|
+
}
|
|
33
|
+
const lastKey = tokens[tokens.length - 1];
|
|
34
|
+
if (Array.isArray(target)) target.splice(Number(lastKey), 1);
|
|
35
|
+
else delete target[lastKey];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region ../engine-core/src/random.ts
|
|
42
|
+
var SeededRandomImpl = class SeededRandomImpl {
|
|
43
|
+
constructor(seed) {
|
|
44
|
+
this._state = seed | 0;
|
|
45
|
+
}
|
|
46
|
+
get state() {
|
|
47
|
+
return this._state;
|
|
48
|
+
}
|
|
49
|
+
static fromState(state) {
|
|
50
|
+
const r = new SeededRandomImpl(0);
|
|
51
|
+
r._state = state;
|
|
52
|
+
return r;
|
|
53
|
+
}
|
|
54
|
+
float() {
|
|
55
|
+
this._state |= 0;
|
|
56
|
+
this._state = this._state + 1831565813 | 0;
|
|
57
|
+
let t = Math.imul(this._state ^ this._state >>> 15, 1 | this._state);
|
|
58
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
59
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
60
|
+
}
|
|
61
|
+
int(max) {
|
|
62
|
+
return Math.floor(this.float() * max);
|
|
63
|
+
}
|
|
64
|
+
pick(array) {
|
|
65
|
+
return array[this.int(array.length)];
|
|
66
|
+
}
|
|
67
|
+
shuffle(array) {
|
|
68
|
+
const a = [...array];
|
|
69
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
70
|
+
const j = this.int(i + 1);
|
|
71
|
+
[a[i], a[j]] = [a[j], a[i]];
|
|
72
|
+
}
|
|
73
|
+
return a;
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region ../engine-core/src/server-only.ts
|
|
78
|
+
/**
|
|
79
|
+
* @deprecated `logic.serverActions` に直接書く。
|
|
80
|
+
*
|
|
81
|
+
* ```ts
|
|
82
|
+
* // before
|
|
83
|
+
* actions: { notifyExternal: serverOnly(async (state) => { ... }) }
|
|
84
|
+
* // after
|
|
85
|
+
* serverActions: { notifyExternal: async (state) => { ... } }
|
|
86
|
+
* ```
|
|
87
|
+
*/
|
|
88
|
+
function serverOnly(handler) {
|
|
89
|
+
return Object.assign(handler, { __serverOnly: true });
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* handler が `serverOnly()` で wrap されているか判定する。
|
|
93
|
+
*
|
|
94
|
+
* 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
|
|
95
|
+
*/
|
|
96
|
+
function isServerOnlyAction(handler) {
|
|
97
|
+
return typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
|
|
98
|
+
}
|
|
99
|
+
//#endregion
|
|
100
|
+
//#region src/server-clock.ts
|
|
101
|
+
/**
|
|
102
|
+
* @docs
|
|
103
|
+
* - ServerAction仕様: docs/docs/uzu_code/connection-method/arch3-authority.md
|
|
104
|
+
*
|
|
105
|
+
* サーバー時刻の推定値をクライアント全体へ配る。
|
|
106
|
+
*
|
|
107
|
+
* state に入っている `endsAt` のような絶対時刻はサーバーの時計で打たれている。
|
|
108
|
+
* それを端末の `Date.now()` と引き算すると、端末の時計ズレがそのまま表示のズレになる
|
|
109
|
+
* (残り時間が恒久的に狂う / 期限判定がサーバーと食い違う)。読み取り側も同じ時計に
|
|
110
|
+
* 揃えるための関数。
|
|
111
|
+
*
|
|
112
|
+
* ```ts
|
|
113
|
+
* const remain = Math.ceil((state.game.timerEndsAt - serverNow()) / 1000);
|
|
114
|
+
* ```
|
|
115
|
+
*
|
|
116
|
+
* オフセットは transport がサーバーからのメッセージを受けるたびに更新する。
|
|
117
|
+
* 未接続 / 未観測ならローカル時刻をそのまま返す (オフラインでも壊れない)。
|
|
118
|
+
*/
|
|
119
|
+
let offset = 0;
|
|
120
|
+
/** transport から呼ぶ内部関数。サーバーが打刻した時刻を観測してオフセットを更新する。 */
|
|
121
|
+
function observeServerTime(serverTime) {
|
|
122
|
+
if (typeof serverTime !== "number" || !Number.isFinite(serverTime)) return;
|
|
123
|
+
offset = serverTime - Date.now();
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* サーバー時刻の推定値 (ms)。
|
|
127
|
+
*
|
|
128
|
+
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
129
|
+
* state の到着とは無関係にいつでも呼べる。
|
|
130
|
+
*/
|
|
131
|
+
function serverNow() {
|
|
132
|
+
return Date.now() + offset;
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
//#region src/room.ts
|
|
136
|
+
var Room = class {
|
|
137
|
+
constructor(ws, myId) {
|
|
138
|
+
this.handlers = /* @__PURE__ */ new Map();
|
|
139
|
+
this.ws = ws;
|
|
140
|
+
this.myId = myId;
|
|
141
|
+
this.ws.addEventListener("message", (ev) => {
|
|
142
|
+
let parsed;
|
|
143
|
+
try {
|
|
144
|
+
parsed = JSON.parse(ev.data);
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const type = parsed.type;
|
|
149
|
+
if (!type) return;
|
|
150
|
+
const from = parsed.__from;
|
|
151
|
+
console.log(`[SDK Room] ⬅ recv type=${type} from=${from}`, JSON.stringify(parsed));
|
|
152
|
+
(this.handlers.get(type) || []).forEach((h) => h({
|
|
153
|
+
...parsed,
|
|
154
|
+
__from: from
|
|
155
|
+
}));
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
broadcast(msg) {
|
|
159
|
+
console.log(`[SDK Room] ➡ broadcast type=${msg.type}`, JSON.stringify(msg));
|
|
160
|
+
this.ws.send(JSON.stringify(msg));
|
|
161
|
+
}
|
|
162
|
+
send(id, msg) {
|
|
163
|
+
console.log(`[SDK Room] ➡ send to=${id} type=${msg.type}`, JSON.stringify(msg));
|
|
164
|
+
this.ws.send(JSON.stringify({
|
|
165
|
+
...msg,
|
|
166
|
+
__to: id
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
on(type, handler) {
|
|
170
|
+
if (!this.handlers.has(type)) this.handlers.set(type, []);
|
|
171
|
+
this.handlers.get(type).push(handler);
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/reconnectable-ws.ts
|
|
176
|
+
const DEFAULTS = {
|
|
177
|
+
maxReconnectAttempts: 15,
|
|
178
|
+
baseDelay: 1e3,
|
|
179
|
+
maxDelay: 3e4,
|
|
180
|
+
jitterFactor: .5,
|
|
181
|
+
heartbeatInterval: 25e3,
|
|
182
|
+
heartbeatTimeout: 1e4,
|
|
183
|
+
maxBufferSize: 50
|
|
184
|
+
};
|
|
185
|
+
var ReconnectableWebSocket = class {
|
|
186
|
+
constructor(url, options = {}) {
|
|
187
|
+
this.ws = null;
|
|
188
|
+
this._state = "connecting";
|
|
189
|
+
this.reconnectAttempt = 0;
|
|
190
|
+
this.messageBuffer = [];
|
|
191
|
+
this.messageHandlers = [];
|
|
192
|
+
this.heartbeatTimer = null;
|
|
193
|
+
this.heartbeatTimeoutTimer = null;
|
|
194
|
+
this.reconnectTimer = null;
|
|
195
|
+
this.disposed = false;
|
|
196
|
+
this.url = url;
|
|
197
|
+
this.opts = {
|
|
198
|
+
maxReconnectAttempts: options.maxReconnectAttempts ?? DEFAULTS.maxReconnectAttempts,
|
|
199
|
+
baseDelay: options.baseDelay ?? DEFAULTS.baseDelay,
|
|
200
|
+
maxDelay: options.maxDelay ?? DEFAULTS.maxDelay,
|
|
201
|
+
jitterFactor: options.jitterFactor ?? DEFAULTS.jitterFactor,
|
|
202
|
+
heartbeatInterval: options.heartbeatInterval ?? DEFAULTS.heartbeatInterval,
|
|
203
|
+
heartbeatTimeout: options.heartbeatTimeout ?? DEFAULTS.heartbeatTimeout,
|
|
204
|
+
maxBufferSize: options.maxBufferSize ?? DEFAULTS.maxBufferSize,
|
|
205
|
+
shouldBuffer: options.shouldBuffer,
|
|
206
|
+
onConnectionStateChange: options.onConnectionStateChange
|
|
207
|
+
};
|
|
208
|
+
this.connect();
|
|
209
|
+
}
|
|
210
|
+
get connectionState() {
|
|
211
|
+
return this._state;
|
|
212
|
+
}
|
|
213
|
+
/** WebSocket.readyState 互換 (既存コードとの互換用) */
|
|
214
|
+
get readyState() {
|
|
215
|
+
return this.ws?.readyState ?? WebSocket.CLOSED;
|
|
216
|
+
}
|
|
217
|
+
send(data) {
|
|
218
|
+
if (this._state === "connected" && this.ws?.readyState === WebSocket.OPEN) this.ws.send(data);
|
|
219
|
+
else if (this._state === "reconnecting") {
|
|
220
|
+
if (this.opts.shouldBuffer && !this.opts.shouldBuffer(data)) return;
|
|
221
|
+
if (this.messageBuffer.length < this.opts.maxBufferSize) this.messageBuffer.push(data);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
addEventListener(type, handler) {
|
|
225
|
+
if (type === "message") this.messageHandlers.push(handler);
|
|
226
|
+
}
|
|
227
|
+
removeEventListener(type, handler) {
|
|
228
|
+
if (type === "message") {
|
|
229
|
+
const index = this.messageHandlers.indexOf(handler);
|
|
230
|
+
if (index !== -1) this.messageHandlers.splice(index, 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** バッファをクリア(権威的 state 受信時に呼ぶ) */
|
|
234
|
+
clearBuffer() {
|
|
235
|
+
this.messageBuffer = [];
|
|
236
|
+
}
|
|
237
|
+
/** 全リソース解放 */
|
|
238
|
+
dispose() {
|
|
239
|
+
this.disposed = true;
|
|
240
|
+
this.stopHeartbeat();
|
|
241
|
+
if (this.reconnectTimer) {
|
|
242
|
+
clearTimeout(this.reconnectTimer);
|
|
243
|
+
this.reconnectTimer = null;
|
|
244
|
+
}
|
|
245
|
+
if (this.ws) {
|
|
246
|
+
this.ws.onopen = null;
|
|
247
|
+
this.ws.onclose = null;
|
|
248
|
+
this.ws.onerror = null;
|
|
249
|
+
this.ws.onmessage = null;
|
|
250
|
+
try {
|
|
251
|
+
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) this.ws.close();
|
|
252
|
+
} catch (e) {
|
|
253
|
+
console.warn("[ReconnectableWS] Error closing WebSocket:", e);
|
|
254
|
+
}
|
|
255
|
+
this.ws = null;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
connect() {
|
|
259
|
+
if (this.disposed) return;
|
|
260
|
+
console.log(`[ReconnectableWS] 🔗 Connecting to ${this.url} (attempt=${this.reconnectAttempt})`);
|
|
261
|
+
const ws = new WebSocket(this.url);
|
|
262
|
+
ws.onopen = () => {
|
|
263
|
+
if (this.disposed) return;
|
|
264
|
+
console.log(`[ReconnectableWS] ✅ Connected`);
|
|
265
|
+
this.reconnectAttempt = 0;
|
|
266
|
+
this.setState("connected");
|
|
267
|
+
this.startHeartbeat();
|
|
268
|
+
this.flushBuffer();
|
|
269
|
+
};
|
|
270
|
+
ws.onclose = (ev) => {
|
|
271
|
+
if (this.disposed) return;
|
|
272
|
+
if (ev.code === 1e3) {
|
|
273
|
+
console.log(`[ReconnectableWS] 🔒 Closed cleanly (code=${ev.code})`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
console.log(`[ReconnectableWS] ❌ Closed (code=${ev.code} reason=${ev.reason})`);
|
|
277
|
+
this.stopHeartbeat();
|
|
278
|
+
this.scheduleReconnect();
|
|
279
|
+
};
|
|
280
|
+
ws.onerror = () => {
|
|
281
|
+
if (this.disposed) return;
|
|
282
|
+
console.log(`[ReconnectableWS] ⚠️ Error`);
|
|
283
|
+
};
|
|
284
|
+
ws.onmessage = (ev) => {
|
|
285
|
+
if (this.disposed) return;
|
|
286
|
+
if (ev.data === "__pong") {
|
|
287
|
+
this.handlePong();
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const handler of this.messageHandlers) handler(ev);
|
|
291
|
+
};
|
|
292
|
+
this.ws = ws;
|
|
293
|
+
}
|
|
294
|
+
setState(state) {
|
|
295
|
+
if (this._state === state) return;
|
|
296
|
+
this._state = state;
|
|
297
|
+
console.log(`[ReconnectableWS] 📡 State: ${state}`);
|
|
298
|
+
this.opts.onConnectionStateChange?.(state);
|
|
299
|
+
}
|
|
300
|
+
scheduleReconnect() {
|
|
301
|
+
if (this.disposed) return;
|
|
302
|
+
if (this.reconnectAttempt >= this.opts.maxReconnectAttempts) {
|
|
303
|
+
console.log(`[ReconnectableWS] 🚫 Max reconnect attempts reached (${this.opts.maxReconnectAttempts})`);
|
|
304
|
+
this.setState("disconnected");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
this.setState("reconnecting");
|
|
308
|
+
const delay = this.getReconnectDelay();
|
|
309
|
+
console.log(`[ReconnectableWS] ⏳ Reconnecting in ${Math.round(delay)}ms (attempt=${this.reconnectAttempt + 1})`);
|
|
310
|
+
this.reconnectTimer = setTimeout(() => {
|
|
311
|
+
this.reconnectAttempt++;
|
|
312
|
+
this.connect();
|
|
313
|
+
}, delay);
|
|
314
|
+
}
|
|
315
|
+
getReconnectDelay() {
|
|
316
|
+
const { baseDelay, maxDelay, jitterFactor } = this.opts;
|
|
317
|
+
const exponentialDelay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempt), maxDelay);
|
|
318
|
+
const jitter = exponentialDelay * jitterFactor * (Math.random() * 2 - 1);
|
|
319
|
+
return Math.max(0, exponentialDelay + jitter);
|
|
320
|
+
}
|
|
321
|
+
flushBuffer() {
|
|
322
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
|
323
|
+
const buffer = this.messageBuffer;
|
|
324
|
+
this.messageBuffer = [];
|
|
325
|
+
for (const data of buffer) try {
|
|
326
|
+
this.ws.send(data);
|
|
327
|
+
} catch (e) {
|
|
328
|
+
console.warn(`[ReconnectableWS] ⚠️ Failed to flush buffered message:`, e);
|
|
329
|
+
}
|
|
330
|
+
if (buffer.length > 0) console.log(`[ReconnectableWS] 📤 Flushed ${buffer.length} buffered messages`);
|
|
331
|
+
}
|
|
332
|
+
startHeartbeat() {
|
|
333
|
+
this.stopHeartbeat();
|
|
334
|
+
this.heartbeatTimer = setInterval(() => {
|
|
335
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
336
|
+
this.ws.send("__ping");
|
|
337
|
+
this.heartbeatTimeoutTimer = setTimeout(() => {
|
|
338
|
+
console.log(`[ReconnectableWS] 💔 Heartbeat timeout`);
|
|
339
|
+
this.ws?.close(4001, "heartbeat_timeout");
|
|
340
|
+
}, this.opts.heartbeatTimeout);
|
|
341
|
+
}
|
|
342
|
+
}, this.opts.heartbeatInterval);
|
|
343
|
+
}
|
|
344
|
+
stopHeartbeat() {
|
|
345
|
+
if (this.heartbeatTimer) {
|
|
346
|
+
clearInterval(this.heartbeatTimer);
|
|
347
|
+
this.heartbeatTimer = null;
|
|
348
|
+
}
|
|
349
|
+
if (this.heartbeatTimeoutTimer) {
|
|
350
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
351
|
+
this.heartbeatTimeoutTimer = null;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
handlePong() {
|
|
355
|
+
if (this.heartbeatTimeoutTimer) {
|
|
356
|
+
clearTimeout(this.heartbeatTimeoutTimer);
|
|
357
|
+
this.heartbeatTimeoutTimer = null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
//#endregion
|
|
362
|
+
//#region src/dev-state-patch.ts
|
|
363
|
+
/**
|
|
364
|
+
* RFC 7396 (Merge Patch) を target に in-place 適用する。
|
|
365
|
+
*
|
|
366
|
+
* 規則:
|
|
367
|
+
* - `undefined` は no-op (key 自体を patch から落としたのと同じ)
|
|
368
|
+
* - `null` は **null をセット** (RFC 7396 strict と異なる、deletion はしない)
|
|
369
|
+
* - patch が object かつ target も object → 再帰 merge
|
|
370
|
+
* - patch が array → atomic replace (要素単位 merge なし、RFC 7396 spec 通り)
|
|
371
|
+
* - patch が primitive → overwrite
|
|
372
|
+
* - **target が array で patch が non-array object** → throw (silent な
|
|
373
|
+
* `[..., ...]` → `{ "0": ..., "1": ... }` 化を防ぐ)
|
|
374
|
+
*
|
|
375
|
+
* 型 mismatch (例: object → primitive) は overwrite で許可する。
|
|
376
|
+
*/
|
|
377
|
+
function applyJsonMergePatch(target, patch) {
|
|
378
|
+
if (Array.isArray(target)) throw new TypeError("[uzu_dev] mergeRawState: cannot deep-merge into an array field. Use patchRawState ({ op: \"replace\", path: \"/.../<index>\" }) for element-level writes, or pass a full array to atomic-replace it.");
|
|
379
|
+
for (const key of Object.keys(patch)) {
|
|
380
|
+
const value = patch[key];
|
|
381
|
+
if (value === void 0) continue;
|
|
382
|
+
if (value === null) {
|
|
383
|
+
target[key] = null;
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const current = target[key];
|
|
387
|
+
if (typeof value === "object" && !Array.isArray(value) && current !== null && typeof current === "object") {
|
|
388
|
+
if (Array.isArray(current)) throw new TypeError(`[uzu_dev] mergeRawState: refusing to merge a plain object into array field "${key}". Use patchRawState ({ op: "replace", path: "/${key}/<index>" }) for element-level writes, or pass a full array to atomic-replace it.`);
|
|
389
|
+
applyJsonMergePatch(current, value);
|
|
390
|
+
} else target[key] = value;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* JSON Pointer (RFC 6901) を tokens に分解する。
|
|
395
|
+
* `/board/1/~04` → `['board', '1', '~4']`
|
|
396
|
+
*
|
|
397
|
+
* RFC 6901 のエスケープ:
|
|
398
|
+
* - `~1` → `/`
|
|
399
|
+
* - `~0` → `~`
|
|
400
|
+
*
|
|
401
|
+
* 復号順は ~1 → ~0。逆順で解くと `~01` が `/` に化けるので注意。
|
|
402
|
+
*/
|
|
403
|
+
function parseJsonPointer(pointer) {
|
|
404
|
+
if (pointer === "") return [];
|
|
405
|
+
if (!pointer.startsWith("/")) throw new TypeError(`[uzu_dev] invalid JSON Pointer: ${pointer} (must start with "/")`);
|
|
406
|
+
return pointer.slice(1).split("/").map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* JSON Pointer の **親 container** と **末端 key** に解決する。
|
|
410
|
+
* root pointer (`''`) は対応外: 呼び出し側で別途扱う。
|
|
411
|
+
*/
|
|
412
|
+
function resolveParent(doc, tokens) {
|
|
413
|
+
if (tokens.length === 0) throw new TypeError("[uzu_dev] root pointer (\"\") is not addressable by this helper");
|
|
414
|
+
let current = doc;
|
|
415
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
416
|
+
const token = tokens[i];
|
|
417
|
+
if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer path "${tokens.slice(0, i + 1).join("/")}" goes through a non-container`);
|
|
418
|
+
if (Array.isArray(current)) {
|
|
419
|
+
const idx = parseArrayIndex(token, current.length, false);
|
|
420
|
+
current = current[idx];
|
|
421
|
+
} else current = current[token];
|
|
422
|
+
}
|
|
423
|
+
if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer parent "${tokens.slice(0, -1).join("/")}" is not a container`);
|
|
424
|
+
return {
|
|
425
|
+
parent: current,
|
|
426
|
+
key: tokens[tokens.length - 1]
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* JSON Pointer から値を取得する。root pointer は doc 自体。
|
|
431
|
+
* 存在しない path は throw する。
|
|
432
|
+
*/
|
|
433
|
+
function getByPointer(doc, pointer) {
|
|
434
|
+
const tokens = parseJsonPointer(pointer);
|
|
435
|
+
let current = doc;
|
|
436
|
+
for (const token of tokens) {
|
|
437
|
+
if (current === null || typeof current !== "object") throw new TypeError(`[uzu_dev] JSON Pointer path "${pointer}" goes through a non-container`);
|
|
438
|
+
if (Array.isArray(current)) {
|
|
439
|
+
const idx = parseArrayIndex(token, current.length, false);
|
|
440
|
+
current = current[idx];
|
|
441
|
+
} else {
|
|
442
|
+
const obj = current;
|
|
443
|
+
if (!(token in obj)) throw new TypeError(`[uzu_dev] JSON Pointer path "${pointer}" does not exist`);
|
|
444
|
+
current = obj[token];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return current;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Array index token を numeric index に解決する。
|
|
451
|
+
* `'-'` は append (RFC 6902 仕様、`add` 操作のみ許可) で `length` を返す。
|
|
452
|
+
*/
|
|
453
|
+
function parseArrayIndex(token, length, allowAppend) {
|
|
454
|
+
if (token === "-") {
|
|
455
|
+
if (!allowAppend) throw new TypeError("[uzu_dev] array append (\"-\") is only valid for `add` operations");
|
|
456
|
+
return length;
|
|
457
|
+
}
|
|
458
|
+
if (!/^(?:0|[1-9][0-9]*)$/.test(token)) throw new TypeError(`[uzu_dev] invalid array index "${token}"`);
|
|
459
|
+
const idx = Number(token);
|
|
460
|
+
if (idx < 0 || idx > length) throw new RangeError(`[uzu_dev] array index ${idx} out of range (length=${length})`);
|
|
461
|
+
return idx;
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* 値の深い等価判定 (RFC 6902 `test` 用)。`JSON.stringify` ベース。
|
|
465
|
+
* `undefined` を含む値や key 順序差まで真面目に見るほどには使わないので十分。
|
|
466
|
+
*/
|
|
467
|
+
function deepEqual(a, b) {
|
|
468
|
+
if (a === b) return true;
|
|
469
|
+
try {
|
|
470
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
471
|
+
} catch {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* RFC 6902 (JSON Patch) operations を target に in-place 適用する。
|
|
477
|
+
*
|
|
478
|
+
* 1 op でも失敗したら **その時点で throw** する (RFC 6902 spec: operations は
|
|
479
|
+
* sequentially evaluate、失敗時の rollback は規定なし)。caller が atomic に
|
|
480
|
+
* したいなら state を事前に snapshot しておく。
|
|
481
|
+
*
|
|
482
|
+
* root pointer (`''`) は本実装では `add` / `replace` のみ許可し、
|
|
483
|
+
* doc のフィールドを丸ごと差し替える形で動く (= 結局 `setRawState` で十分な
|
|
484
|
+
* ケースなので、わざわざ `patchRawState` で使うことはほぼない)。
|
|
485
|
+
*/
|
|
486
|
+
function applyJsonPatch(target, ops) {
|
|
487
|
+
for (const op of ops) applyOne(target, op);
|
|
488
|
+
}
|
|
489
|
+
function applyOne(doc, op) {
|
|
490
|
+
switch (op.op) {
|
|
491
|
+
case "add": {
|
|
492
|
+
const tokens = parseJsonPointer(op.path);
|
|
493
|
+
if (tokens.length === 0) {
|
|
494
|
+
replaceRoot(doc, op.value);
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
const { parent, key } = resolveParent(doc, tokens);
|
|
498
|
+
if (Array.isArray(parent)) {
|
|
499
|
+
const idx = parseArrayIndex(key, parent.length, true);
|
|
500
|
+
parent.splice(idx, 0, op.value);
|
|
501
|
+
} else parent[key] = op.value;
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
case "remove": {
|
|
505
|
+
const tokens = parseJsonPointer(op.path);
|
|
506
|
+
if (tokens.length === 0) throw new TypeError("[uzu_dev] cannot remove the root document");
|
|
507
|
+
const { parent, key } = resolveParent(doc, tokens);
|
|
508
|
+
if (Array.isArray(parent)) {
|
|
509
|
+
const idx = parseArrayIndex(key, parent.length - 1, false);
|
|
510
|
+
parent.splice(idx, 1);
|
|
511
|
+
} else {
|
|
512
|
+
if (!(key in parent)) throw new TypeError(`[uzu_dev] cannot remove non-existent path "${op.path}"`);
|
|
513
|
+
delete parent[key];
|
|
514
|
+
}
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
case "replace": {
|
|
518
|
+
const tokens = parseJsonPointer(op.path);
|
|
519
|
+
if (tokens.length === 0) {
|
|
520
|
+
replaceRoot(doc, op.value);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const { parent, key } = resolveParent(doc, tokens);
|
|
524
|
+
if (Array.isArray(parent)) {
|
|
525
|
+
const idx = parseArrayIndex(key, parent.length - 1, false);
|
|
526
|
+
parent[idx] = op.value;
|
|
527
|
+
} else {
|
|
528
|
+
if (!(key in parent)) throw new TypeError(`[uzu_dev] cannot replace non-existent path "${op.path}"`);
|
|
529
|
+
parent[key] = op.value;
|
|
530
|
+
}
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
case "move": {
|
|
534
|
+
if (op.from === op.path) return;
|
|
535
|
+
if (isProperPrefixPointer(op.from, op.path)) throw new TypeError(`[uzu_dev] cannot move into own descendant: from="${op.from}" path="${op.path}"`);
|
|
536
|
+
const value = getByPointer(doc, op.from);
|
|
537
|
+
applyOne(doc, {
|
|
538
|
+
op: "remove",
|
|
539
|
+
path: op.from
|
|
540
|
+
});
|
|
541
|
+
applyOne(doc, {
|
|
542
|
+
op: "add",
|
|
543
|
+
path: op.path,
|
|
544
|
+
value
|
|
545
|
+
});
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
case "copy": {
|
|
549
|
+
const value = getByPointer(doc, op.from);
|
|
550
|
+
const cloned = structuredClone(value);
|
|
551
|
+
applyOne(doc, {
|
|
552
|
+
op: "add",
|
|
553
|
+
path: op.path,
|
|
554
|
+
value: cloned
|
|
555
|
+
});
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
case "test":
|
|
559
|
+
if (!deepEqual(getByPointer(doc, op.path), op.value)) throw new Error(`[uzu_dev] test failed at "${op.path}"`);
|
|
560
|
+
return;
|
|
561
|
+
default: throw new TypeError(`[uzu_dev] unknown JSON Patch op: ${JSON.stringify(op)}`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function isProperPrefixPointer(prefix, path) {
|
|
565
|
+
if (prefix === "") return path !== "";
|
|
566
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* root pointer (`''`) への add / replace 用。doc を in-place で空にして
|
|
570
|
+
* value の中身を流し込む。value が object でない場合は Record の形に
|
|
571
|
+
* 入らないので throw する (root を primitive / array に置換はサポート対象外、
|
|
572
|
+
* setRawState を使うべき)。
|
|
573
|
+
*/
|
|
574
|
+
function replaceRoot(doc, value) {
|
|
575
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError("[uzu_dev] root replace requires a plain object value; use setRawState for full state replacement");
|
|
576
|
+
for (const key of Object.keys(doc)) delete doc[key];
|
|
577
|
+
Object.assign(doc, value);
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/dev-prediction-traps.ts
|
|
581
|
+
const TRAPS = [
|
|
582
|
+
{ install: (report) => {
|
|
583
|
+
const RealDate = Date;
|
|
584
|
+
const realNow = Date.now;
|
|
585
|
+
globalThis.Date = new Proxy(RealDate, { construct: (target, args) => {
|
|
586
|
+
if (args.length === 0) report("new Date()");
|
|
587
|
+
return Reflect.construct(target, args);
|
|
588
|
+
} });
|
|
589
|
+
Date.now = () => {
|
|
590
|
+
report("Date.now()");
|
|
591
|
+
return realNow();
|
|
592
|
+
};
|
|
593
|
+
return () => {
|
|
594
|
+
Date.now = realNow;
|
|
595
|
+
globalThis.Date = RealDate;
|
|
596
|
+
};
|
|
597
|
+
} },
|
|
598
|
+
{ install: (report) => {
|
|
599
|
+
const real = Math.random;
|
|
600
|
+
Math.random = () => {
|
|
601
|
+
report("Math.random()");
|
|
602
|
+
return real();
|
|
603
|
+
};
|
|
604
|
+
return () => {
|
|
605
|
+
Math.random = real;
|
|
606
|
+
};
|
|
607
|
+
} },
|
|
608
|
+
{ install: (report) => {
|
|
609
|
+
const real = globalThis.crypto?.randomUUID;
|
|
610
|
+
if (!real) return () => {};
|
|
611
|
+
const bound = real.bind(globalThis.crypto);
|
|
612
|
+
globalThis.crypto.randomUUID = () => {
|
|
613
|
+
report("crypto.randomUUID()");
|
|
614
|
+
return bound();
|
|
615
|
+
};
|
|
616
|
+
return () => {
|
|
617
|
+
globalThis.crypto.randomUUID = real;
|
|
618
|
+
};
|
|
619
|
+
} },
|
|
620
|
+
{ install: (report) => {
|
|
621
|
+
const real = globalThis.performance?.now;
|
|
622
|
+
if (!real) return () => {};
|
|
623
|
+
const bound = real.bind(globalThis.performance);
|
|
624
|
+
globalThis.performance.now = () => {
|
|
625
|
+
report("performance.now()");
|
|
626
|
+
return bound();
|
|
627
|
+
};
|
|
628
|
+
return () => {
|
|
629
|
+
globalThis.performance.now = real;
|
|
630
|
+
};
|
|
631
|
+
} }
|
|
632
|
+
];
|
|
633
|
+
const warnings = [];
|
|
634
|
+
const reported = /* @__PURE__ */ new Set();
|
|
635
|
+
const printWarning = (action, api) => {
|
|
636
|
+
console.groupCollapsed(`⚠️ [uzu-code] action "${action}" が先読み中に ${api} を呼びました`);
|
|
637
|
+
console.log([
|
|
638
|
+
"先読み (楽観的更新) は「サーバーと同じコードを同じ入力で走らせれば同じ結果になる」",
|
|
639
|
+
`ことが前提です。${api} はクライアント側の値を読むため、サーバーの実行結果とズレます。`,
|
|
640
|
+
"ズレた state は一瞬表示されたあと ack で上書きされ、画面が飛びます。",
|
|
641
|
+
"",
|
|
642
|
+
"直し方:",
|
|
643
|
+
` A. action "${action}" を serverOnly() で wrap する (先読みしなくなる)`,
|
|
644
|
+
" B. 値を state に書かず、クライアント側の描画だけで使う",
|
|
645
|
+
"",
|
|
646
|
+
"https://docs.code.uzu-app.com/reference/api#serveronly-handler"
|
|
647
|
+
].join("\n"));
|
|
648
|
+
console.groupEnd();
|
|
649
|
+
};
|
|
650
|
+
/**
|
|
651
|
+
* dev harness の HUD にバッジを出すため親フレームへ通知する。
|
|
652
|
+
* 単独 page (親が自分自身) では送り先が無いので何もしない。
|
|
653
|
+
*/
|
|
654
|
+
const notifyParent = (action, api) => {
|
|
655
|
+
if (window.parent === window) return;
|
|
656
|
+
try {
|
|
657
|
+
window.parent.postMessage(JSON.stringify({
|
|
658
|
+
channel: "sdk",
|
|
659
|
+
type: "predictionWarning",
|
|
660
|
+
payload: {
|
|
661
|
+
action,
|
|
662
|
+
api
|
|
663
|
+
}
|
|
664
|
+
}), "*");
|
|
665
|
+
} catch {}
|
|
666
|
+
};
|
|
667
|
+
/**
|
|
668
|
+
* 素の action handler の先読み実行を計装して走らせる。
|
|
669
|
+
*
|
|
670
|
+
* Flutter native ホスト (本番) では計装せず素通しする。判定基準は dev hooks と同じ。
|
|
671
|
+
*/
|
|
672
|
+
const runPredicted = (action, run) => {
|
|
673
|
+
if (typeof window === "undefined" || window.FlutterHost) {
|
|
674
|
+
run();
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
const report = (api) => {
|
|
678
|
+
const key = `${action}${api}`;
|
|
679
|
+
if (reported.has(key)) return;
|
|
680
|
+
reported.add(key);
|
|
681
|
+
warnings.push({
|
|
682
|
+
action,
|
|
683
|
+
api
|
|
684
|
+
});
|
|
685
|
+
printWarning(action, api);
|
|
686
|
+
notifyParent(action, api);
|
|
687
|
+
};
|
|
688
|
+
const restores = TRAPS.map((trap) => trap.install(report));
|
|
689
|
+
try {
|
|
690
|
+
run();
|
|
691
|
+
} finally {
|
|
692
|
+
for (let i = restores.length - 1; i >= 0; i--) restores[i]();
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
/** 検出済みの警告一覧。`__uzu_dev.getPredictionWarnings()` から E2E で assert する用。 */
|
|
696
|
+
const getPredictionWarnings = () => warnings;
|
|
697
|
+
//#endregion
|
|
698
|
+
//#region src/dev-hooks.ts
|
|
699
|
+
function createDevHooks(ctx) {
|
|
700
|
+
const getRawState = () => ctx.getRawState ? ctx.getRawState() : null;
|
|
701
|
+
const waitForSnapshot = (predicate, options) => {
|
|
702
|
+
const timeoutMs = options?.timeoutMs ?? 1e4;
|
|
703
|
+
return new Promise((resolve, reject) => {
|
|
704
|
+
const current = ctx.getSnapshot();
|
|
705
|
+
if (current != null) {
|
|
706
|
+
let ok = false;
|
|
707
|
+
try {
|
|
708
|
+
ok = predicate(current);
|
|
709
|
+
} catch (err) {
|
|
710
|
+
reject(err);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (ok) {
|
|
714
|
+
resolve(current);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
const timer = setTimeout(() => {
|
|
719
|
+
unsubscribe();
|
|
720
|
+
reject(/* @__PURE__ */ new Error(`[uzu_dev] waitForSnapshot timed out after ${timeoutMs}ms`));
|
|
721
|
+
}, timeoutMs);
|
|
722
|
+
const unsubscribe = ctx.subscribeSnapshot((snap) => {
|
|
723
|
+
let ok;
|
|
724
|
+
try {
|
|
725
|
+
ok = predicate(snap);
|
|
726
|
+
} catch (err) {
|
|
727
|
+
clearTimeout(timer);
|
|
728
|
+
unsubscribe();
|
|
729
|
+
reject(err);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
if (ok) {
|
|
733
|
+
clearTimeout(timer);
|
|
734
|
+
unsubscribe();
|
|
735
|
+
resolve(snap);
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
const hooks = {
|
|
741
|
+
getSnapshot: () => ctx.getSnapshot(),
|
|
742
|
+
getRawState,
|
|
743
|
+
playerId: () => ctx.playerId(),
|
|
744
|
+
subscribeSnapshot: (cb) => ctx.subscribeSnapshot(cb),
|
|
745
|
+
waitForSnapshot,
|
|
746
|
+
getPredictionWarnings: () => getPredictionWarnings()
|
|
747
|
+
};
|
|
748
|
+
if (ctx.sendAction) {
|
|
749
|
+
const sendAction = ctx.sendAction;
|
|
750
|
+
hooks.send = (args) => sendAction(args);
|
|
751
|
+
}
|
|
752
|
+
if (ctx.setRawState) {
|
|
753
|
+
const setRawState = ctx.setRawState;
|
|
754
|
+
hooks.setRawState = (state) => setRawState(state);
|
|
755
|
+
}
|
|
756
|
+
if (ctx.mergeRawState) {
|
|
757
|
+
const mergeRawState = ctx.mergeRawState;
|
|
758
|
+
hooks.mergeRawState = (patch) => mergeRawState(patch);
|
|
759
|
+
}
|
|
760
|
+
if (ctx.patchRawState) {
|
|
761
|
+
const patchRawState = ctx.patchRawState;
|
|
762
|
+
hooks.patchRawState = (ops) => patchRawState(ops);
|
|
763
|
+
}
|
|
764
|
+
if (ctx.getSeed) {
|
|
765
|
+
const getSeed = ctx.getSeed;
|
|
766
|
+
hooks.getSeed = () => getSeed();
|
|
767
|
+
}
|
|
768
|
+
if (ctx.pauseTick) {
|
|
769
|
+
const pauseTick = ctx.pauseTick;
|
|
770
|
+
hooks.pauseTick = () => pauseTick();
|
|
771
|
+
}
|
|
772
|
+
if (ctx.resumeTick) {
|
|
773
|
+
const resumeTick = ctx.resumeTick;
|
|
774
|
+
hooks.resumeTick = () => resumeTick();
|
|
775
|
+
}
|
|
776
|
+
if (ctx.stepTick) {
|
|
777
|
+
const stepTick = ctx.stepTick;
|
|
778
|
+
hooks.stepTick = (n) => stepTick(n);
|
|
779
|
+
}
|
|
780
|
+
if (ctx.getCurrentTick) {
|
|
781
|
+
const getCurrentTick = ctx.getCurrentTick;
|
|
782
|
+
hooks.getCurrentTick = () => getCurrentTick();
|
|
783
|
+
}
|
|
784
|
+
if (ctx.isTickPaused) {
|
|
785
|
+
const isTickPaused = ctx.isTickPaused;
|
|
786
|
+
hooks.isTickPaused = () => isTickPaused();
|
|
787
|
+
}
|
|
788
|
+
if (ctx.reset) {
|
|
789
|
+
const reset = ctx.reset;
|
|
790
|
+
hooks.reset = (opts) => reset(opts);
|
|
791
|
+
}
|
|
792
|
+
if (ctx.subscribeEvents) {
|
|
793
|
+
const subscribeEvents = ctx.subscribeEvents;
|
|
794
|
+
hooks.subscribeEvents = (cb) => subscribeEvents(cb);
|
|
795
|
+
}
|
|
796
|
+
return hooks;
|
|
797
|
+
}
|
|
798
|
+
function attachDevHooks(ctx) {
|
|
799
|
+
window.__uzu_dev = createDevHooks(ctx);
|
|
800
|
+
}
|
|
801
|
+
//#endregion
|
|
802
|
+
//#region src/run/optimistic-action-client.ts
|
|
803
|
+
function createOptimisticActionClient(config) {
|
|
804
|
+
const { logic, playerId, onState, events, sendAction } = config;
|
|
805
|
+
/** サーバー確定 state (楽観的更新のベース) */
|
|
806
|
+
let confirmedState = null;
|
|
807
|
+
/** 表示用 state (pending actions 適用済み) */
|
|
808
|
+
let displayState = null;
|
|
809
|
+
/** クライアント側の action 通番 */
|
|
810
|
+
let actionSeq = 0;
|
|
811
|
+
/**
|
|
812
|
+
* 送信済みだがサーバー未確認の action キュー。
|
|
813
|
+
* `now` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
|
|
814
|
+
*/
|
|
815
|
+
const pendingActions = [];
|
|
816
|
+
/**
|
|
817
|
+
* 先読みで実行済みの events。
|
|
818
|
+
*
|
|
819
|
+
* IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
|
|
820
|
+
* action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
|
|
821
|
+
* (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
|
|
822
|
+
* 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
|
|
823
|
+
*
|
|
824
|
+
* 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
|
|
825
|
+
* された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
|
|
826
|
+
* 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
|
|
827
|
+
* 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
|
|
828
|
+
* 握り潰してしまう。
|
|
829
|
+
*
|
|
830
|
+
* サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
|
|
831
|
+
* 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
|
|
832
|
+
*/
|
|
833
|
+
let firedPredictions = [];
|
|
834
|
+
/** 再適用時はイベントを発火しない (送信時に既に発火済み) */
|
|
835
|
+
const noopEmit = () => {};
|
|
836
|
+
const dispatchEvents = (evts) => {
|
|
837
|
+
for (const e of evts) events?.[e.name]?.handler(e.data);
|
|
838
|
+
};
|
|
839
|
+
/** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
|
|
840
|
+
const eventKey = (e) => `${e.name}\u0000${JSON.stringify(e.data ?? {})}`;
|
|
841
|
+
/**
|
|
842
|
+
* サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
|
|
843
|
+
*
|
|
844
|
+
* - 予測が当たった → 差が空。二重に実行しない
|
|
845
|
+
* - 予測が外れた → サーバー側の正しい event が残り、確定として実行される
|
|
846
|
+
* - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
|
|
847
|
+
* → そのまま残って実行される
|
|
848
|
+
*/
|
|
849
|
+
const subtractFired = (serverEvts) => {
|
|
850
|
+
if (firedPredictions.length === 0) return serverEvts;
|
|
851
|
+
const out = [];
|
|
852
|
+
for (const e of serverEvts) {
|
|
853
|
+
const k = eventKey(e);
|
|
854
|
+
const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
|
|
855
|
+
if (idx >= 0) firedPredictions.splice(idx, 1);
|
|
856
|
+
else out.push(e);
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
};
|
|
860
|
+
/**
|
|
861
|
+
* confirmedState をベースに pending actions を再適用して displayState を更新する。
|
|
862
|
+
* 再適用失敗した action はキューから除去する。
|
|
863
|
+
*/
|
|
864
|
+
const reapplyPendingActions = () => {
|
|
865
|
+
if (confirmedState === null) return;
|
|
866
|
+
displayState = structuredClone(confirmedState);
|
|
867
|
+
let i = 0;
|
|
868
|
+
while (i < pendingActions.length) {
|
|
869
|
+
const { action, payload, now } = pendingActions[i];
|
|
870
|
+
const handler = logic.actions[action];
|
|
871
|
+
if (!handler || isServerOnlyAction(handler)) {
|
|
872
|
+
pendingActions.splice(i, 1);
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const base = structuredClone(displayState);
|
|
876
|
+
try {
|
|
877
|
+
runPredicted(action, () => handler({
|
|
878
|
+
state: displayState,
|
|
879
|
+
payload,
|
|
880
|
+
playerId,
|
|
881
|
+
ctx: {
|
|
882
|
+
now,
|
|
883
|
+
emit: noopEmit
|
|
884
|
+
}
|
|
885
|
+
}));
|
|
886
|
+
i++;
|
|
887
|
+
} catch {
|
|
888
|
+
displayState = base;
|
|
889
|
+
pendingActions.splice(i, 1);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
onState(displayState, playerId);
|
|
893
|
+
};
|
|
894
|
+
/**
|
|
895
|
+
* サーバーからの配信を処理する。
|
|
896
|
+
*
|
|
897
|
+
* 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
|
|
898
|
+
* 他プレイヤーの action が同じ出来事を起こしたときに二重実行になる。
|
|
899
|
+
*/
|
|
900
|
+
const handleAck = (ack, from, evts) => {
|
|
901
|
+
dispatchEvents(subtractFired(evts));
|
|
902
|
+
if (from === playerId && ack !== void 0) {
|
|
903
|
+
while (pendingActions.length > 0 && pendingActions[0].seq <= ack) pendingActions.shift();
|
|
904
|
+
firedPredictions = firedPredictions.filter((f) => f.seq > ack);
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
return {
|
|
908
|
+
send(type, payload) {
|
|
909
|
+
actionSeq++;
|
|
910
|
+
const seq = actionSeq;
|
|
911
|
+
const handler = logic.actions[type];
|
|
912
|
+
if (!handler && !logic.serverActions?.[type]) console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
|
|
913
|
+
const target = displayState;
|
|
914
|
+
const now = serverNow();
|
|
915
|
+
const predictEmit = (eventName, data) => {
|
|
916
|
+
const subscription = events?.[eventName];
|
|
917
|
+
if (!subscription?.predict) return;
|
|
918
|
+
const payloadData = data ?? {};
|
|
919
|
+
subscription.handler(payloadData);
|
|
920
|
+
firedPredictions.push({
|
|
921
|
+
seq,
|
|
922
|
+
name: eventName,
|
|
923
|
+
data: payloadData
|
|
924
|
+
});
|
|
925
|
+
};
|
|
926
|
+
if (handler && !isServerOnlyAction(handler) && target !== null) try {
|
|
927
|
+
runPredicted(type, () => handler({
|
|
928
|
+
state: target,
|
|
929
|
+
payload: payload ?? {},
|
|
930
|
+
playerId,
|
|
931
|
+
ctx: {
|
|
932
|
+
now,
|
|
933
|
+
emit: predictEmit
|
|
934
|
+
}
|
|
935
|
+
}));
|
|
936
|
+
pendingActions.push({
|
|
937
|
+
seq,
|
|
938
|
+
action: type,
|
|
939
|
+
payload: payload ?? {},
|
|
940
|
+
now
|
|
941
|
+
});
|
|
942
|
+
onState(target, playerId);
|
|
943
|
+
} catch {}
|
|
944
|
+
sendAction({
|
|
945
|
+
action: type,
|
|
946
|
+
payload: payload ?? {},
|
|
947
|
+
seq
|
|
948
|
+
});
|
|
949
|
+
},
|
|
950
|
+
observeServerTime(serverTime) {
|
|
951
|
+
observeServerTime(serverTime);
|
|
952
|
+
},
|
|
953
|
+
applyState(state, options = {}) {
|
|
954
|
+
handleAck(options.ack, options.from, options.events ?? []);
|
|
955
|
+
confirmedState = state;
|
|
956
|
+
reapplyPendingActions();
|
|
957
|
+
},
|
|
958
|
+
applyDelta(patches, options = {}) {
|
|
959
|
+
if (confirmedState === null) return false;
|
|
960
|
+
const cloned = structuredClone(confirmedState);
|
|
961
|
+
if (!applyPatch(cloned, patches)) return false;
|
|
962
|
+
confirmedState = cloned;
|
|
963
|
+
handleAck(options.ack, options.from, options.events ?? []);
|
|
964
|
+
reapplyPendingActions();
|
|
965
|
+
return true;
|
|
966
|
+
},
|
|
967
|
+
rollback(seq) {
|
|
968
|
+
const idx = pendingActions.findIndex((p) => p.seq === seq);
|
|
969
|
+
if (idx !== -1) {
|
|
970
|
+
pendingActions.splice(idx, 1);
|
|
971
|
+
firedPredictions = firedPredictions.filter((f) => f.seq !== seq);
|
|
972
|
+
reapplyPendingActions();
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
reset(state) {
|
|
976
|
+
pendingActions.length = 0;
|
|
977
|
+
firedPredictions = [];
|
|
978
|
+
confirmedState = state;
|
|
979
|
+
displayState = structuredClone(state);
|
|
980
|
+
onState(displayState, playerId);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
}
|
|
984
|
+
//#endregion
|
|
985
|
+
//#region src/run/server-action.ts
|
|
986
|
+
function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, seatKind) {
|
|
987
|
+
const toWire = (p) => ({
|
|
988
|
+
id: p.id,
|
|
989
|
+
name: p.nickname,
|
|
990
|
+
iconUrl: p.iconUrl,
|
|
991
|
+
characterId: p.characterId,
|
|
992
|
+
kind: "player"
|
|
993
|
+
});
|
|
994
|
+
const roster = JSON.stringify(players.map(toWire));
|
|
995
|
+
const wsUrl = `${gameEndpoint}/${roomId}?${new URLSearchParams({
|
|
996
|
+
seatId,
|
|
997
|
+
nickname: "Player",
|
|
998
|
+
players: roster,
|
|
999
|
+
seats: roster
|
|
1000
|
+
})}`;
|
|
1001
|
+
console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
|
|
1002
|
+
const ws = new ReconnectableWebSocket(wsUrl, {
|
|
1003
|
+
onConnectionStateChange: (state) => {
|
|
1004
|
+
console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
|
|
1005
|
+
config.onConnectionStateChange?.(state);
|
|
1006
|
+
},
|
|
1007
|
+
shouldBuffer: (data) => {
|
|
1008
|
+
try {
|
|
1009
|
+
const parsed = JSON.parse(data);
|
|
1010
|
+
return parsed.type !== "__state" && parsed.type !== "__tick" && parsed.type !== "__action_result" && parsed.type !== "__game_start" && parsed.type !== "__tick_delta" && parsed.type !== "__action_result_delta";
|
|
1011
|
+
} catch {
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
/** サーバー seq (delta の連続性チェック用) */
|
|
1017
|
+
let serverSeq = 0;
|
|
1018
|
+
/** フル state 再要求中フラグ (多重要求防止) */
|
|
1019
|
+
let requestStatePending = false;
|
|
1020
|
+
/** 楽観更新クライアント (transport は本ファイル内で WebSocket に bind) */
|
|
1021
|
+
const client = createOptimisticActionClient({
|
|
1022
|
+
logic: config.logic,
|
|
1023
|
+
playerId: seatId,
|
|
1024
|
+
onState: (state, playerId) => config.onState(state, playerId, seatKind),
|
|
1025
|
+
events: config.events,
|
|
1026
|
+
sendAction: ({ action, payload, seq }) => {
|
|
1027
|
+
console.log(`[SDK ServerAction] ➡ send __action action=${action} seq=${seq}`);
|
|
1028
|
+
ws.send(JSON.stringify({
|
|
1029
|
+
type: "__action",
|
|
1030
|
+
action,
|
|
1031
|
+
payload,
|
|
1032
|
+
seq
|
|
1033
|
+
}));
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
/** サーバーにフル state の再送を要求する */
|
|
1037
|
+
const requestFullState = () => {
|
|
1038
|
+
if (requestStatePending) return;
|
|
1039
|
+
requestStatePending = true;
|
|
1040
|
+
console.log(`[SDK ServerAction] 🔄 Requesting full state (seq gap or patch failure)`);
|
|
1041
|
+
ws.send(JSON.stringify({ type: "__request_state" }));
|
|
1042
|
+
};
|
|
1043
|
+
config.inputs((type, payload) => {
|
|
1044
|
+
client.send(type, payload);
|
|
1045
|
+
});
|
|
1046
|
+
ws.addEventListener("message", (ev) => {
|
|
1047
|
+
let parsed;
|
|
1048
|
+
try {
|
|
1049
|
+
parsed = JSON.parse(ev.data);
|
|
1050
|
+
} catch {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
const msgType = parsed.type;
|
|
1054
|
+
console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
|
|
1055
|
+
client.observeServerTime(parsed.serverTime);
|
|
1056
|
+
if (msgType === "__room_init") {
|
|
1057
|
+
console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
if (msgType === "__game_start") {
|
|
1061
|
+
serverSeq = parsed.seq ?? 0;
|
|
1062
|
+
requestStatePending = false;
|
|
1063
|
+
client.reset(parsed.state);
|
|
1064
|
+
console.log(`[SDK ServerAction] 🎮 Game started (pending cleared)`);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (msgType === "__tick") {
|
|
1068
|
+
const evts = parsed.events ?? [];
|
|
1069
|
+
serverSeq = parsed.seq ?? serverSeq + 1;
|
|
1070
|
+
requestStatePending = false;
|
|
1071
|
+
client.applyState(parsed.state, { events: evts });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (msgType === "__tick_delta") {
|
|
1075
|
+
const evts = parsed.events ?? [];
|
|
1076
|
+
const newSeq = parsed.seq ?? serverSeq + 1;
|
|
1077
|
+
if (newSeq !== serverSeq + 1) {
|
|
1078
|
+
requestFullState();
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
if (!client.applyDelta(parsed.patches ?? [], { events: evts })) {
|
|
1082
|
+
requestFullState();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
serverSeq = newSeq;
|
|
1086
|
+
requestStatePending = false;
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (msgType === "__action_result") {
|
|
1090
|
+
const ack = parsed.ack;
|
|
1091
|
+
const from = parsed.from;
|
|
1092
|
+
const evts = parsed.events ?? [];
|
|
1093
|
+
serverSeq = parsed.seq ?? serverSeq + 1;
|
|
1094
|
+
requestStatePending = false;
|
|
1095
|
+
client.applyState(parsed.state, {
|
|
1096
|
+
ack,
|
|
1097
|
+
from,
|
|
1098
|
+
events: evts
|
|
1099
|
+
});
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (msgType === "__action_result_delta") {
|
|
1103
|
+
const ack = parsed.ack;
|
|
1104
|
+
const from = parsed.from;
|
|
1105
|
+
const evts = parsed.events ?? [];
|
|
1106
|
+
const newSeq = parsed.seq ?? serverSeq + 1;
|
|
1107
|
+
if (newSeq !== serverSeq + 1) {
|
|
1108
|
+
requestFullState();
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (!client.applyDelta(parsed.patches ?? [], {
|
|
1112
|
+
ack,
|
|
1113
|
+
from,
|
|
1114
|
+
events: evts
|
|
1115
|
+
})) {
|
|
1116
|
+
requestFullState();
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
serverSeq = newSeq;
|
|
1120
|
+
requestStatePending = false;
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (msgType === "__action_error") {
|
|
1124
|
+
const errorSeq = parsed.seq;
|
|
1125
|
+
console.warn(`[SDK ServerAction] ⚠️ Action error: ${parsed.error} (seq=${errorSeq})`);
|
|
1126
|
+
if (errorSeq !== void 0) client.rollback(errorSeq);
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
if (msgType === "__state") {
|
|
1130
|
+
serverSeq = parsed.seq ?? 0;
|
|
1131
|
+
requestStatePending = false;
|
|
1132
|
+
console.log(`[SDK ServerAction] 🔄 State restored (reconnect/late join) seq=${serverSeq}`);
|
|
1133
|
+
client.reset(parsed.state);
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
//#endregion
|
|
1139
|
+
//#region src/run/local-server-action.ts
|
|
1140
|
+
function runLocalServerAction(config) {
|
|
1141
|
+
const { logic, inputs, events } = config;
|
|
1142
|
+
const onState = (next, id) => config.onState(next, id, "player");
|
|
1143
|
+
const tickRate = logic.tickRate ?? 0;
|
|
1144
|
+
const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
|
|
1145
|
+
const players = Array.from({ length: config.playerCount }, (_, i) => ({
|
|
1146
|
+
id: `local_${i}`,
|
|
1147
|
+
nickname: `Player ${i + 1}`,
|
|
1148
|
+
iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
|
|
1149
|
+
}));
|
|
1150
|
+
const myId = players[0].id;
|
|
1151
|
+
let wakeupTimer = null;
|
|
1152
|
+
let wakeupAt = null;
|
|
1153
|
+
const nextDeadline = () => {
|
|
1154
|
+
if (!logic.deadlines) return null;
|
|
1155
|
+
let earliest = null;
|
|
1156
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1157
|
+
let at;
|
|
1158
|
+
try {
|
|
1159
|
+
at = deadline.at({ state });
|
|
1160
|
+
} catch (err) {
|
|
1161
|
+
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (typeof at !== "number" || !Number.isFinite(at)) continue;
|
|
1165
|
+
if (earliest === null || at < earliest) earliest = at;
|
|
1166
|
+
}
|
|
1167
|
+
return earliest;
|
|
1168
|
+
};
|
|
1169
|
+
const fireDue = () => {
|
|
1170
|
+
wakeupTimer = null;
|
|
1171
|
+
wakeupAt = null;
|
|
1172
|
+
if (!logic.deadlines) return;
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
const evts = [];
|
|
1175
|
+
const firedKeys = [];
|
|
1176
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1177
|
+
const pending = [];
|
|
1178
|
+
let snapshot = null;
|
|
1179
|
+
try {
|
|
1180
|
+
const at = deadline.at({ state });
|
|
1181
|
+
if (typeof at !== "number" || !Number.isFinite(at) || at > now) continue;
|
|
1182
|
+
snapshot = structuredClone(state);
|
|
1183
|
+
deadline.handler({
|
|
1184
|
+
state,
|
|
1185
|
+
ctx: {
|
|
1186
|
+
now,
|
|
1187
|
+
random,
|
|
1188
|
+
emit: (name, data) => pending.push({
|
|
1189
|
+
name,
|
|
1190
|
+
data: data ?? {}
|
|
1191
|
+
})
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
evts.push(...pending);
|
|
1195
|
+
firedKeys.push(key);
|
|
1196
|
+
} catch (err) {
|
|
1197
|
+
if (snapshot !== null) state = snapshot;
|
|
1198
|
+
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
syncWakeup();
|
|
1202
|
+
if (firedKeys.length === 0) return;
|
|
1203
|
+
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(", ")}`);
|
|
1204
|
+
dispatchEvents(evts);
|
|
1205
|
+
onState(state, myId);
|
|
1206
|
+
};
|
|
1207
|
+
const syncWakeup = () => {
|
|
1208
|
+
const next = nextDeadline();
|
|
1209
|
+
if (next === wakeupAt) return;
|
|
1210
|
+
if (wakeupTimer) clearTimeout(wakeupTimer);
|
|
1211
|
+
wakeupTimer = null;
|
|
1212
|
+
wakeupAt = next;
|
|
1213
|
+
if (next === null) return;
|
|
1214
|
+
wakeupTimer = setTimeout(fireDue, Math.max(0, next - Date.now()));
|
|
1215
|
+
};
|
|
1216
|
+
const dispatchEvents = (evts) => {
|
|
1217
|
+
for (const e of evts) events?.[e.name]?.handler(e.data);
|
|
1218
|
+
};
|
|
1219
|
+
const setupArgs = {
|
|
1220
|
+
players,
|
|
1221
|
+
seats: players,
|
|
1222
|
+
ctx: {
|
|
1223
|
+
random,
|
|
1224
|
+
now: Date.now()
|
|
1225
|
+
}
|
|
1226
|
+
};
|
|
1227
|
+
let state = logic.setup(setupArgs);
|
|
1228
|
+
let tick = 0;
|
|
1229
|
+
const playerInputs = {};
|
|
1230
|
+
const dispatchAction = (type, payload) => {
|
|
1231
|
+
const plain = logic.actions[type];
|
|
1232
|
+
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
1233
|
+
const server = logic.serverActions?.[type] ?? legacyServerOnly;
|
|
1234
|
+
if (!plain && !server) return;
|
|
1235
|
+
const plainEvents = [];
|
|
1236
|
+
const serverEvents = [];
|
|
1237
|
+
const plainEmit = (name, data) => plainEvents.push({
|
|
1238
|
+
name,
|
|
1239
|
+
data: data ?? {}
|
|
1240
|
+
});
|
|
1241
|
+
const serverEmit = (name, data) => serverEvents.push({
|
|
1242
|
+
name,
|
|
1243
|
+
data: data ?? {}
|
|
1244
|
+
});
|
|
1245
|
+
const now = Date.now();
|
|
1246
|
+
if (plain && !legacyServerOnly) {
|
|
1247
|
+
try {
|
|
1248
|
+
plain({
|
|
1249
|
+
state,
|
|
1250
|
+
payload: payload ?? {},
|
|
1251
|
+
playerId: myId,
|
|
1252
|
+
ctx: {
|
|
1253
|
+
now,
|
|
1254
|
+
emit: plainEmit
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
} catch (err) {
|
|
1258
|
+
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
dispatchEvents(plainEvents);
|
|
1262
|
+
syncWakeup();
|
|
1263
|
+
onState(state, myId);
|
|
1264
|
+
}
|
|
1265
|
+
if (!server) return;
|
|
1266
|
+
(async () => {
|
|
1267
|
+
try {
|
|
1268
|
+
await server({
|
|
1269
|
+
state,
|
|
1270
|
+
payload: payload ?? {},
|
|
1271
|
+
playerId: myId,
|
|
1272
|
+
ctx: {
|
|
1273
|
+
tick,
|
|
1274
|
+
random,
|
|
1275
|
+
now,
|
|
1276
|
+
emit: serverEmit
|
|
1277
|
+
}
|
|
1278
|
+
});
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
dispatchEvents(serverEvents);
|
|
1284
|
+
syncWakeup();
|
|
1285
|
+
onState(state, myId);
|
|
1286
|
+
})();
|
|
1287
|
+
};
|
|
1288
|
+
inputs(dispatchAction);
|
|
1289
|
+
syncWakeup();
|
|
1290
|
+
onState(state, myId);
|
|
1291
|
+
if (tickRate > 0) setInterval(() => {
|
|
1292
|
+
const tickEvents = [];
|
|
1293
|
+
const tickEmit = (name, data) => tickEvents.push({
|
|
1294
|
+
name,
|
|
1295
|
+
data: data ?? {}
|
|
1296
|
+
});
|
|
1297
|
+
try {
|
|
1298
|
+
logic.update({
|
|
1299
|
+
state,
|
|
1300
|
+
ctx: {
|
|
1301
|
+
random,
|
|
1302
|
+
tick,
|
|
1303
|
+
now: Date.now(),
|
|
1304
|
+
emit: tickEmit,
|
|
1305
|
+
playerInputs
|
|
1306
|
+
}
|
|
1307
|
+
});
|
|
1308
|
+
} catch (err) {
|
|
1309
|
+
console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
|
|
1310
|
+
tick++;
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
tick++;
|
|
1314
|
+
dispatchEvents(tickEvents);
|
|
1315
|
+
syncWakeup();
|
|
1316
|
+
onState(state, myId);
|
|
1317
|
+
}, 1e3 / tickRate);
|
|
1318
|
+
return {
|
|
1319
|
+
getRawState: () => state,
|
|
1320
|
+
setRawState: async (next) => {
|
|
1321
|
+
state = next;
|
|
1322
|
+
syncWakeup();
|
|
1323
|
+
onState(state, myId);
|
|
1324
|
+
},
|
|
1325
|
+
mergeRawState: async (patch) => {
|
|
1326
|
+
applyJsonMergePatch(state, patch);
|
|
1327
|
+
onState(state, myId);
|
|
1328
|
+
},
|
|
1329
|
+
patchRawState: async (ops) => {
|
|
1330
|
+
applyJsonPatch(state, ops);
|
|
1331
|
+
onState(state, myId);
|
|
1332
|
+
},
|
|
1333
|
+
sendAction: dispatchAction
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
//#endregion
|
|
1337
|
+
//#region src/sync/local.ts
|
|
1338
|
+
function resolveLocalTime(ops) {
|
|
1339
|
+
const now = Date.now();
|
|
1340
|
+
return ops.map((op) => op.value === "__SERVER_TIME__" ? {
|
|
1341
|
+
...op,
|
|
1342
|
+
value: now
|
|
1343
|
+
} : op);
|
|
1344
|
+
}
|
|
1345
|
+
function syncLocal(config) {
|
|
1346
|
+
const { initialState, onState, inputs, events: _events } = config;
|
|
1347
|
+
const localCount = config.playerCount;
|
|
1348
|
+
const players = [];
|
|
1349
|
+
for (let i = 0; i < localCount; i++) players.push({
|
|
1350
|
+
id: `local_${i}`,
|
|
1351
|
+
nickname: `Player ${i + 1}`,
|
|
1352
|
+
iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
|
|
1353
|
+
});
|
|
1354
|
+
let state = initialState(players);
|
|
1355
|
+
const myId = players[0].id;
|
|
1356
|
+
const patchFn = (ops) => {
|
|
1357
|
+
applyPatch(state, resolveLocalTime(ops));
|
|
1358
|
+
onState(state, myId, Date.now());
|
|
1359
|
+
};
|
|
1360
|
+
const setFn = (path, value) => {
|
|
1361
|
+
patchFn([{
|
|
1362
|
+
op: "replace",
|
|
1363
|
+
path,
|
|
1364
|
+
value
|
|
1365
|
+
}]);
|
|
1366
|
+
};
|
|
1367
|
+
inputs(patchFn, setFn);
|
|
1368
|
+
onState(state, myId, Date.now());
|
|
1369
|
+
setInterval(() => {
|
|
1370
|
+
onState(state, myId, Date.now());
|
|
1371
|
+
}, 100);
|
|
1372
|
+
return {
|
|
1373
|
+
getRawState: () => state,
|
|
1374
|
+
setRawState: async (next) => {
|
|
1375
|
+
state = next;
|
|
1376
|
+
onState(state, myId, Date.now());
|
|
1377
|
+
},
|
|
1378
|
+
mergeRawState: async (patch) => {
|
|
1379
|
+
applyJsonMergePatch(state, patch);
|
|
1380
|
+
onState(state, myId, Date.now());
|
|
1381
|
+
},
|
|
1382
|
+
patchRawState: async (ops) => {
|
|
1383
|
+
applyJsonPatch(state, ops);
|
|
1384
|
+
onState(state, myId, Date.now());
|
|
1385
|
+
}
|
|
1386
|
+
};
|
|
1387
|
+
}
|
|
1388
|
+
//#endregion
|
|
1389
|
+
//#region src/sync/online.ts
|
|
1390
|
+
function syncOnline(config, syncEndpoint, roomId, playerId, hostPlayers) {
|
|
1391
|
+
const { initialState, onState, inputs, events: _events } = config;
|
|
1392
|
+
const wsUrl = `${syncEndpoint}/${roomId}?playerId=${encodeURIComponent(playerId)}`;
|
|
1393
|
+
console.log(`[SDK Sync] 🔗 Connecting wsUrl=${wsUrl}`);
|
|
1394
|
+
const ws = new ReconnectableWebSocket(wsUrl, {
|
|
1395
|
+
onConnectionStateChange: (state) => {
|
|
1396
|
+
console.log(`[SDK Sync] 📡 Connection state: ${state}`);
|
|
1397
|
+
config.onConnectionStateChange?.(state);
|
|
1398
|
+
},
|
|
1399
|
+
shouldBuffer: (data) => {
|
|
1400
|
+
try {
|
|
1401
|
+
const parsed = JSON.parse(data);
|
|
1402
|
+
return parsed.type !== "__patch" && parsed.type !== "__patch_ack";
|
|
1403
|
+
} catch {
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
const myId = playerId;
|
|
1409
|
+
const players = hostPlayers;
|
|
1410
|
+
let state = null;
|
|
1411
|
+
let gameInitSent = false;
|
|
1412
|
+
let serverTimeOffset = 0;
|
|
1413
|
+
let localSeq = 0;
|
|
1414
|
+
let requestStatePending = false;
|
|
1415
|
+
const estimateServerTime = () => Date.now() + serverTimeOffset;
|
|
1416
|
+
const resolveToServerTime = (ops) => {
|
|
1417
|
+
const now = estimateServerTime();
|
|
1418
|
+
return ops.map((op) => op.value === "__SERVER_TIME__" ? {
|
|
1419
|
+
...op,
|
|
1420
|
+
value: now
|
|
1421
|
+
} : op);
|
|
1422
|
+
};
|
|
1423
|
+
const sendPatch = (ops) => {
|
|
1424
|
+
if (!ops || ops.length === 0) return;
|
|
1425
|
+
if (state) {
|
|
1426
|
+
applyPatch(state, resolveToServerTime(ops));
|
|
1427
|
+
onState(state, myId, estimateServerTime());
|
|
1428
|
+
}
|
|
1429
|
+
console.log(`[SDK Sync] ➡ send __patch ops=${ops.length}`, JSON.stringify(ops));
|
|
1430
|
+
ws.send(JSON.stringify({
|
|
1431
|
+
type: "__patch",
|
|
1432
|
+
ops
|
|
1433
|
+
}));
|
|
1434
|
+
};
|
|
1435
|
+
const sendSet = (path, value) => {
|
|
1436
|
+
sendPatch([{
|
|
1437
|
+
op: "replace",
|
|
1438
|
+
path,
|
|
1439
|
+
value
|
|
1440
|
+
}]);
|
|
1441
|
+
};
|
|
1442
|
+
inputs(sendPatch, sendSet);
|
|
1443
|
+
setInterval(() => {
|
|
1444
|
+
if (state) onState(state, myId, estimateServerTime());
|
|
1445
|
+
}, 100);
|
|
1446
|
+
const tryInitState = () => {
|
|
1447
|
+
if (gameInitSent) return;
|
|
1448
|
+
gameInitSent = true;
|
|
1449
|
+
const initState = initialState(players);
|
|
1450
|
+
console.log(`[SDK Sync] ➡ send __init_state players=${players.length}`);
|
|
1451
|
+
ws.send(JSON.stringify({
|
|
1452
|
+
type: "__init_state",
|
|
1453
|
+
state: initState
|
|
1454
|
+
}));
|
|
1455
|
+
};
|
|
1456
|
+
ws.addEventListener("message", (ev) => {
|
|
1457
|
+
let parsed;
|
|
1458
|
+
try {
|
|
1459
|
+
parsed = JSON.parse(ev.data);
|
|
1460
|
+
} catch {
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const msgType = parsed.type;
|
|
1464
|
+
console.log(`[SDK Sync] ⬅ recv type=${msgType}`, JSON.stringify(parsed));
|
|
1465
|
+
if (msgType === "__room_init") {
|
|
1466
|
+
console.log(`[SDK Sync] ✅ Room init myId=${myId} players=${players.length}`);
|
|
1467
|
+
tryInitState();
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (msgType === "__reconnected") {
|
|
1471
|
+
console.log(`[SDK Sync] 🔄 Reconnected myId=${myId}`);
|
|
1472
|
+
if (!gameInitSent) tryInitState();
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (msgType === "__state_cleared") {
|
|
1476
|
+
console.log(`[SDK Sync] 🗑 State cleared, reinitializing`);
|
|
1477
|
+
state = null;
|
|
1478
|
+
localSeq = 0;
|
|
1479
|
+
requestStatePending = false;
|
|
1480
|
+
gameInitSent = false;
|
|
1481
|
+
tryInitState();
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (msgType === "__patch_failed") {
|
|
1485
|
+
console.log(`[SDK Sync] ⚠️ Patch failed: ${parsed.reason}`);
|
|
1486
|
+
config.onPatchFailed?.(parsed.reason);
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
if (msgType === "__patch_ack") {
|
|
1490
|
+
if (!state) return;
|
|
1491
|
+
const ackSeq = parsed.seq ?? 0;
|
|
1492
|
+
const ackSenderId = parsed.senderId;
|
|
1493
|
+
const ackOps = parsed.ops;
|
|
1494
|
+
const serverTime = parsed.serverTime;
|
|
1495
|
+
if (ackSenderId === myId) {
|
|
1496
|
+
localSeq = ackSeq;
|
|
1497
|
+
serverTimeOffset = serverTime - Date.now();
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (requestStatePending) return;
|
|
1501
|
+
if (ackSeq === localSeq + 1) {
|
|
1502
|
+
if (!applyPatch(state, ackOps)) {
|
|
1503
|
+
console.log(`[SDK Sync] ⚠️ Local applyPatch failed, requesting full state`);
|
|
1504
|
+
requestStatePending = true;
|
|
1505
|
+
ws.send(JSON.stringify({ type: "__request_state" }));
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
localSeq = ackSeq;
|
|
1509
|
+
serverTimeOffset = serverTime - Date.now();
|
|
1510
|
+
onState(state, myId, serverTime);
|
|
1511
|
+
} else if (ackSeq > localSeq + 1) {
|
|
1512
|
+
console.log(`[SDK Sync] ⚠️ Seq gap detected: expected=${localSeq + 1} received=${ackSeq}, requesting full state`);
|
|
1513
|
+
requestStatePending = true;
|
|
1514
|
+
ws.send(JSON.stringify({ type: "__request_state" }));
|
|
1515
|
+
}
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
if (msgType === "__state") {
|
|
1519
|
+
state = parsed.state;
|
|
1520
|
+
const serverTime = parsed.serverTime;
|
|
1521
|
+
localSeq = parsed.seq ?? 0;
|
|
1522
|
+
requestStatePending = false;
|
|
1523
|
+
serverTimeOffset = serverTime - Date.now();
|
|
1524
|
+
console.log(`[SDK Sync] ✅ State received serverTime=${serverTime} offset=${serverTimeOffset} seq=${localSeq}`);
|
|
1525
|
+
ws.clearBuffer();
|
|
1526
|
+
onState(state, myId, serverTime);
|
|
1527
|
+
return;
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
return { ws };
|
|
1531
|
+
}
|
|
1532
|
+
//#endregion
|
|
1533
|
+
//#region src/index.ts
|
|
1534
|
+
const gameHandlers = /* @__PURE__ */ new Map();
|
|
18
1535
|
const _playersChangedHandlers = [];
|
|
19
1536
|
let _room = null;
|
|
20
1537
|
let _lastStateSnap = null;
|
|
@@ -26,443 +1543,347 @@ let _syncWs = null;
|
|
|
26
1543
|
let _initialized = false;
|
|
27
1544
|
let _usesRun = false;
|
|
28
1545
|
let _playerId = null;
|
|
29
|
-
// ─── Dev hooks state (window.__uzu_dev 経由の外部 automation 用) ──────
|
|
30
|
-
// Flutter native ホスト (= window.FlutterHost あり) では一切 attach されない。
|
|
31
|
-
// 注意: `isHosted` は dev harness 子 iframe (parent !== window) も含むため、
|
|
32
|
-
// dev hooks の本番判定は `isHosted` ではなく `window.FlutterHost` の有無で行う。
|
|
33
|
-
// これにより Flutter native では非公開、 dev harness 子 iframe / Playwright iframe /
|
|
34
|
-
// 単独 page では attach、 という挙動を両立させる。
|
|
35
1546
|
let _runHandle = null;
|
|
36
1547
|
let _syncHandle = null;
|
|
37
|
-
const _snapshotListeners = new Set();
|
|
1548
|
+
const _snapshotListeners = /* @__PURE__ */ new Set();
|
|
38
1549
|
function notifyDevSnapshot(snap) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
1550
|
+
_snapshotListeners.forEach((cb) => {
|
|
1551
|
+
try {
|
|
1552
|
+
cb(snap);
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
console.warn("[uzu_dev] snapshot listener threw:", err);
|
|
1555
|
+
}
|
|
1556
|
+
});
|
|
47
1557
|
}
|
|
48
1558
|
function attachDevHooksIfNotHosted() {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
onMessage: (msg) => {
|
|
120
|
-
console.log(`[SDK] ⬅ recv __ps.onMessage channel=${msg.channel} type=${msg.type}`, JSON.stringify(msg));
|
|
121
|
-
handleMessage(msg);
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
// Web iframe モード: 親ウィンドウからの postMessage を受信
|
|
125
|
-
window.addEventListener('message', (event) => {
|
|
126
|
-
try {
|
|
127
|
-
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
|
|
128
|
-
if (data && typeof data.channel === 'string' && typeof data.type === 'string') {
|
|
129
|
-
console.log(`[SDK] ⬅ recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
|
|
130
|
-
handleMessage(data);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// JSON でないメッセージは無視
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
// HUD 回避領域の CSS カスタムプロパティを設定
|
|
138
|
-
// クエリパラメータがあればその値を使用し、 なければ players 数から推定
|
|
139
|
-
const hudX = params.get('uzuHudInsetX');
|
|
140
|
-
const hudY = params.get('uzuHudInsetY');
|
|
141
|
-
const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
|
|
142
|
-
document.documentElement.style.setProperty('--uzu-hud-inset-x', `${hudX ?? fallbackX}px`);
|
|
143
|
-
document.documentElement.style.setProperty('--uzu-hud-inset-y', `${hudY ?? fallbackY}px`);
|
|
144
|
-
sendRaw('sdk', 'ready', {});
|
|
145
|
-
_initialized = true;
|
|
146
|
-
}
|
|
147
|
-
// Relay 自動接続 (wsEndpoint 未設定時はスキップ)
|
|
148
|
-
if (!_usesRun) {
|
|
149
|
-
const roomIdParam = params.get('roomId');
|
|
150
|
-
if (roomIdParam) {
|
|
151
|
-
if (_wsEndpoint) {
|
|
152
|
-
connectRoom(roomIdParam);
|
|
153
|
-
}
|
|
154
|
-
else {
|
|
155
|
-
console.warn('[SDK] roomId is present but server is not configured — skipping Relay connection');
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
// run() / sync() を経由しない init() 単独ケースでも dev hooks を attach する
|
|
160
|
-
// (Relay モード等。 run/sync 経由なら呼び出し元で再 attach される)
|
|
161
|
-
if (!_usesRun) {
|
|
162
|
-
attachDevHooksIfNotHosted();
|
|
163
|
-
}
|
|
1559
|
+
if (window.FlutterHost) return;
|
|
1560
|
+
attachDevHooks({
|
|
1561
|
+
getSnapshot: () => _lastStateSnap?.state ?? null,
|
|
1562
|
+
getRawState: _runHandle?.getRawState ?? _syncHandle?.getRawState,
|
|
1563
|
+
setRawState: _runHandle?.setRawState ?? _syncHandle?.setRawState,
|
|
1564
|
+
mergeRawState: _runHandle?.mergeRawState ?? _syncHandle?.mergeRawState,
|
|
1565
|
+
patchRawState: _runHandle?.patchRawState ?? _syncHandle?.patchRawState,
|
|
1566
|
+
playerId: () => _lastStateSnap?.myId ?? _playerId,
|
|
1567
|
+
subscribeSnapshot: (cb) => {
|
|
1568
|
+
_snapshotListeners.add(cb);
|
|
1569
|
+
return () => {
|
|
1570
|
+
_snapshotListeners.delete(cb);
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
const isHosted = typeof window !== "undefined" && (!!window.FlutterHost || window.parent !== window);
|
|
1576
|
+
function getRoom() {
|
|
1577
|
+
return _room;
|
|
1578
|
+
}
|
|
1579
|
+
function onRoom(callback) {
|
|
1580
|
+
if (_room) callback(_room);
|
|
1581
|
+
else _onRoomCallbacks.push(callback);
|
|
1582
|
+
}
|
|
1583
|
+
function init(opts) {
|
|
1584
|
+
const params = new URLSearchParams(window.location.search);
|
|
1585
|
+
_playerId = params.get("seatId");
|
|
1586
|
+
if (isHosted && !_playerId) console.warn("[UZU SDK] playerId is missing in the URL. Host-side sender filtering will fall back to legacy behavior.");
|
|
1587
|
+
const serverParam = params.get("server");
|
|
1588
|
+
if (serverParam && serverParam.length > 0) {
|
|
1589
|
+
const base = serverParam.replace(/\/$/, "");
|
|
1590
|
+
_wsEndpoint = `${base}/ws/rooms`;
|
|
1591
|
+
_syncEndpoint = `${base}/ws/sync`;
|
|
1592
|
+
const revisionId = params.get("revisionId");
|
|
1593
|
+
if (!revisionId) throw new Error("[UZU SDK] revisionId is required when server is specified. Pass ?revisionId=xxx in the URL.");
|
|
1594
|
+
_gameEndpoint = `${base}/ws/games/${revisionId}`;
|
|
1595
|
+
}
|
|
1596
|
+
if (opts?.wsEndpoint) _wsEndpoint = opts.wsEndpoint;
|
|
1597
|
+
if (opts?.syncEndpoint) _syncEndpoint = opts.syncEndpoint;
|
|
1598
|
+
if (!isHosted) return;
|
|
1599
|
+
if (!_initialized) {
|
|
1600
|
+
window.__ps = { onMessage: (msg) => {
|
|
1601
|
+
console.log(`[SDK] ⬅ recv __ps.onMessage channel=${msg.channel} type=${msg.type}`, JSON.stringify(msg));
|
|
1602
|
+
handleMessage(msg);
|
|
1603
|
+
} };
|
|
1604
|
+
window.addEventListener("message", (event) => {
|
|
1605
|
+
try {
|
|
1606
|
+
const data = typeof event.data === "string" ? JSON.parse(event.data) : event.data;
|
|
1607
|
+
if (data && typeof data.channel === "string" && typeof data.type === "string") {
|
|
1608
|
+
console.log(`[SDK] ⬅ recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
|
|
1609
|
+
handleMessage(data);
|
|
1610
|
+
}
|
|
1611
|
+
} catch {}
|
|
1612
|
+
});
|
|
1613
|
+
const hudX = params.get("uzuHudInsetX");
|
|
1614
|
+
const hudY = params.get("uzuHudInsetY");
|
|
1615
|
+
const { x: fallbackX, y: fallbackY } = calcHudInsets(params);
|
|
1616
|
+
document.documentElement.style.setProperty("--uzu-hud-inset-x", `${hudX ?? fallbackX}px`);
|
|
1617
|
+
document.documentElement.style.setProperty("--uzu-hud-inset-y", `${hudY ?? fallbackY}px`);
|
|
1618
|
+
sendRaw("sdk", "ready", {});
|
|
1619
|
+
_initialized = true;
|
|
1620
|
+
}
|
|
1621
|
+
if (!_usesRun) {
|
|
1622
|
+
const roomIdParam = params.get("roomId");
|
|
1623
|
+
if (roomIdParam) {
|
|
1624
|
+
if (_wsEndpoint) connectRoom(roomIdParam);
|
|
1625
|
+
else console.warn("[SDK] roomId is present but server is not configured — skipping Relay connection");
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
if (!_usesRun) attachDevHooksIfNotHosted();
|
|
164
1629
|
}
|
|
165
1630
|
/** game channel でカスタムメッセージを送信する。 native コマンドには使用不可。 */
|
|
166
|
-
|
|
167
|
-
|
|
1631
|
+
function send(type, payload) {
|
|
1632
|
+
sendRaw("game", type, payload ?? {});
|
|
168
1633
|
}
|
|
169
1634
|
/** game channel のカスタムメッセージを受信する。 */
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
gameHandlers.get(type).push(handler);
|
|
1635
|
+
function on(type, handler) {
|
|
1636
|
+
if (!gameHandlers.has(type)) gameHandlers.set(type, []);
|
|
1637
|
+
gameHandlers.get(type).push(handler);
|
|
174
1638
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
sendRaw('sdk', 'playSound', { sound });
|
|
1639
|
+
function playSound(sound) {
|
|
1640
|
+
sendRaw("sdk", "playSound", { sound });
|
|
178
1641
|
}
|
|
179
|
-
|
|
180
|
-
|
|
1642
|
+
function playBgm(sound) {
|
|
1643
|
+
sendRaw("sdk", "playBgm", { sound });
|
|
181
1644
|
}
|
|
182
|
-
|
|
183
|
-
|
|
1645
|
+
function stopBgm() {
|
|
1646
|
+
sendRaw("sdk", "stopBgm", {});
|
|
184
1647
|
}
|
|
185
|
-
|
|
186
|
-
|
|
1648
|
+
function setMicEnabled(enabled) {
|
|
1649
|
+
sendRaw("sdk", "setMicEnabled", { enabled });
|
|
187
1650
|
}
|
|
188
|
-
|
|
189
|
-
|
|
1651
|
+
function changeRoom(roomId) {
|
|
1652
|
+
sendRaw("sdk", "changeRoom", { roomId });
|
|
190
1653
|
}
|
|
191
1654
|
/**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
1655
|
+
* splash 描画完了を Flutter ホストに通知する。
|
|
1656
|
+
*
|
|
1657
|
+
* YouTube Playables の `firstFrameReady()` 相当。 Flutter は受信時に splash overlay を
|
|
1658
|
+
* 非表示にし WebView を可視化する。 **呼ばないと splash が出っぱなしになる**。
|
|
1659
|
+
*
|
|
1660
|
+
* 呼び出しタイミング: scenario が最初の絵 (splash / loading 画面) を画面に描いた直後。
|
|
1661
|
+
* paint commit を保証するため `requestAnimationFrame` を 1 段挟むのが定石。
|
|
1662
|
+
*
|
|
1663
|
+
* @example
|
|
1664
|
+
* ```ts
|
|
1665
|
+
* init();
|
|
1666
|
+
* drawSplash();
|
|
1667
|
+
* requestAnimationFrame(() => firstFrameReady());
|
|
1668
|
+
* ```
|
|
1669
|
+
*/
|
|
1670
|
+
function firstFrameReady() {
|
|
1671
|
+
sendRaw("sdk", "firstFrameReady", {});
|
|
209
1672
|
}
|
|
210
1673
|
/**
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
1674
|
+
* ユーザー操作受付可能を Flutter ホストに通知する。
|
|
1675
|
+
*
|
|
1676
|
+
* YouTube Playables の `gameReady()` 相当。 Flutter は受信時に入力受付を開始し、TTI
|
|
1677
|
+
* (Time To Interactive) 計測を終了する。 将来の広告タイマー / leaderboard / analytics
|
|
1678
|
+
* の発火点としても使われる予定。
|
|
1679
|
+
*
|
|
1680
|
+
* 呼び出しタイミング: asset load 完了、 ゲーム本体の setup 完了、 入力受付可能になった直後。
|
|
1681
|
+
*
|
|
1682
|
+
* @example
|
|
1683
|
+
* ```ts
|
|
1684
|
+
* await loadAssets();
|
|
1685
|
+
* setupGame();
|
|
1686
|
+
* gameReady();
|
|
1687
|
+
* ```
|
|
1688
|
+
*/
|
|
1689
|
+
function gameReady() {
|
|
1690
|
+
sendRaw("sdk", "gameReady", {});
|
|
228
1691
|
}
|
|
229
1692
|
/** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
|
|
230
|
-
|
|
231
|
-
|
|
1693
|
+
function onPlayersChanged(handler) {
|
|
1694
|
+
_playersChangedHandlers.push(handler);
|
|
232
1695
|
}
|
|
233
1696
|
/**
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
297
|
-
else if (_syncEndpoint) {
|
|
298
|
-
const { seatId, players } = resolveSeatParams(params);
|
|
299
|
-
const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, players);
|
|
300
|
-
_syncWs = ws;
|
|
301
|
-
// online sync: dev hooks は read-only (setRawState 未提供)
|
|
302
|
-
}
|
|
303
|
-
else {
|
|
304
|
-
throw new Error('[UZU SDK] roomId is set but ?server= is missing. ' +
|
|
305
|
-
'Multiplayer sync requires a WebSocket server URL — pass ?server=ws://host:port in the URL.');
|
|
306
|
-
}
|
|
307
|
-
attachDevHooksIfNotHosted();
|
|
308
|
-
}
|
|
309
|
-
// ─── Internal ───────────────────────────────────────────────
|
|
1697
|
+
* `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
|
|
1698
|
+
* 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
|
|
1699
|
+
* 呼び出しは `A` が推論されて検査が効き、 `run<State>` は従来どおり通る。
|
|
1700
|
+
*
|
|
1701
|
+
* `SA` の既定が `ServerActionMap<S>` (= `Record<string, …>`) だと
|
|
1702
|
+
* `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
|
|
1703
|
+
* `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
|
|
1704
|
+
*/
|
|
1705
|
+
function run(config) {
|
|
1706
|
+
_usesRun = true;
|
|
1707
|
+
const params = new URLSearchParams(window.location.search);
|
|
1708
|
+
if (!isHosted) return;
|
|
1709
|
+
init();
|
|
1710
|
+
const origOnState = config.onState;
|
|
1711
|
+
const wrappedConfig = {
|
|
1712
|
+
...config,
|
|
1713
|
+
onState(state, myPlayerId, mySeatKind) {
|
|
1714
|
+
_lastStateSnap = {
|
|
1715
|
+
state,
|
|
1716
|
+
serverTime: 0,
|
|
1717
|
+
myId: myPlayerId
|
|
1718
|
+
};
|
|
1719
|
+
origOnState(state, myPlayerId, mySeatKind);
|
|
1720
|
+
notifyDevSnapshot(state);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
const roomId = params.get("roomId");
|
|
1724
|
+
if (!roomId) _runHandle = runLocalServerAction(wrappedConfig);
|
|
1725
|
+
else if (_gameEndpoint) {
|
|
1726
|
+
const { seatId, players } = resolveSeatParams(params);
|
|
1727
|
+
const seatKind = resolveSeatKind(params);
|
|
1728
|
+
runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
|
|
1729
|
+
} else throw new Error("[UZU SDK] roomId is set but ?server= is missing. Multiplayer requires a WebSocket server URL — pass ?server=ws://host:port in the URL.");
|
|
1730
|
+
attachDevHooksIfNotHosted();
|
|
1731
|
+
}
|
|
1732
|
+
function sync(config) {
|
|
1733
|
+
_usesRun = true;
|
|
1734
|
+
const params = new URLSearchParams(window.location.search);
|
|
1735
|
+
if (!isHosted) return;
|
|
1736
|
+
init();
|
|
1737
|
+
const origOnState = config.onState;
|
|
1738
|
+
const wrappedConfig = {
|
|
1739
|
+
...config,
|
|
1740
|
+
onState(state, myPlayerId, serverTime) {
|
|
1741
|
+
_lastStateSnap = {
|
|
1742
|
+
state,
|
|
1743
|
+
serverTime,
|
|
1744
|
+
myId: myPlayerId
|
|
1745
|
+
};
|
|
1746
|
+
origOnState(state, myPlayerId, serverTime);
|
|
1747
|
+
notifyDevSnapshot(state);
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
const roomId = params.get("roomId");
|
|
1751
|
+
if (!roomId) _syncHandle = syncLocal(wrappedConfig);
|
|
1752
|
+
else if (_syncEndpoint) {
|
|
1753
|
+
const { seatId, players } = resolveSeatParams(params);
|
|
1754
|
+
const { ws } = syncOnline(wrappedConfig, _syncEndpoint, roomId, seatId, players);
|
|
1755
|
+
_syncWs = ws;
|
|
1756
|
+
} else throw new Error("[UZU SDK] roomId is set but ?server= is missing. Multiplayer sync requires a WebSocket server URL — pass ?server=ws://host:port in the URL.");
|
|
1757
|
+
attachDevHooksIfNotHosted();
|
|
1758
|
+
}
|
|
310
1759
|
/**
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
1760
|
+
* 自分の席種別を URL から取り出す。
|
|
1761
|
+
*
|
|
1762
|
+
* `?seatKind=` はホスト (harness / emulator) が観測席の iframe に付ける。付いていない
|
|
1763
|
+
* ホストからの接続は player とみなす — 本番 (mobile / uzutokyo) は player 席しか開かない。
|
|
1764
|
+
*
|
|
1765
|
+
* この値は iframe URL 限定で、WebSocket には出さない。play-server は roster に居ない接続を
|
|
1766
|
+
* 通すだけで、その席が admin か spectator かを知る必要が無い。
|
|
1767
|
+
*/
|
|
319
1768
|
function resolveSeatKind(params) {
|
|
320
|
-
|
|
321
|
-
|
|
1769
|
+
const raw = params.get("seatKind");
|
|
1770
|
+
return raw === "admin" || raw === "spectator" ? raw : "player";
|
|
322
1771
|
}
|
|
323
1772
|
/**
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
1773
|
+
* 自席の ID と roster を URL から取り出す。
|
|
1774
|
+
*
|
|
1775
|
+
* roster (`?players=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
|
|
1776
|
+
* 載らないまま接続してくる。
|
|
1777
|
+
*
|
|
1778
|
+
* 移行期: 旧ホスト (デプロイ前の mobile / uzutokyo / emulator) は `?seats=` で送ってくる。
|
|
1779
|
+
*/
|
|
331
1780
|
function resolveSeatParams(params) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
characterId: p.characterId,
|
|
346
|
-
}));
|
|
347
|
-
return { seatId, players };
|
|
1781
|
+
const seatId = params.get("seatId");
|
|
1782
|
+
if (!seatId) throw new Error("[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.");
|
|
1783
|
+
const json = params.get("players") ?? params.get("seats");
|
|
1784
|
+
if (!json) throw new Error("[UZU SDK] players is required. Pass ?players=[...] in the URL.");
|
|
1785
|
+
return {
|
|
1786
|
+
seatId,
|
|
1787
|
+
players: JSON.parse(json).map((p) => ({
|
|
1788
|
+
id: p.id,
|
|
1789
|
+
nickname: p.name,
|
|
1790
|
+
iconUrl: p.iconUrl,
|
|
1791
|
+
characterId: p.characterId
|
|
1792
|
+
}))
|
|
1793
|
+
};
|
|
348
1794
|
}
|
|
349
1795
|
function requireEndpoint(endpoint, name) {
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
353
|
-
return endpoint;
|
|
1796
|
+
if (!endpoint) throw new Error(`[UZU SDK] ${name} is not configured. Pass ?server=ws://host:port or call init({ wsEndpoint, syncEndpoint }).`);
|
|
1797
|
+
return endpoint;
|
|
354
1798
|
}
|
|
355
1799
|
function connectRoom(roomId) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
}
|
|
378
|
-
else if (parsed.type === '__reconnected') {
|
|
379
|
-
// 再接続: ws は同じ ReconnectableWebSocket インスタンスなので Room 再作成不要
|
|
380
|
-
console.log(`[SDK] 🔄 Room reconnected`);
|
|
381
|
-
}
|
|
382
|
-
});
|
|
1800
|
+
const { seatId } = resolveSeatParams(new URLSearchParams(window.location.search));
|
|
1801
|
+
const wsParams = new URLSearchParams({ playerId: seatId });
|
|
1802
|
+
const wsUrl = `${requireEndpoint(_wsEndpoint, "wsEndpoint")}/${roomId}?${wsParams}`;
|
|
1803
|
+
console.log(`[SDK] 🔗 connectRoom wsUrl=${wsUrl}`);
|
|
1804
|
+
const ws = new ReconnectableWebSocket(wsUrl);
|
|
1805
|
+
ws.addEventListener("message", (ev) => {
|
|
1806
|
+
let parsed;
|
|
1807
|
+
try {
|
|
1808
|
+
parsed = JSON.parse(ev.data);
|
|
1809
|
+
} catch {
|
|
1810
|
+
return;
|
|
1811
|
+
}
|
|
1812
|
+
console.log(`[SDK] ⬅ recv ws type=${parsed.type}`, JSON.stringify(parsed));
|
|
1813
|
+
if (parsed.type === "__room_init") {
|
|
1814
|
+
const myId = parsed.myId;
|
|
1815
|
+
console.log(`[SDK] ✅ Room initialized myId=${myId}`);
|
|
1816
|
+
_room = new Room(ws, myId);
|
|
1817
|
+
_onRoomCallbacks.forEach((cb) => cb(_room));
|
|
1818
|
+
_onRoomCallbacks = [];
|
|
1819
|
+
} else if (parsed.type === "__reconnected") console.log(`[SDK] 🔄 Room reconnected`);
|
|
1820
|
+
});
|
|
383
1821
|
}
|
|
384
1822
|
/** 内部送信関数。 channel + type + payload のエンベロープで送信する。 */
|
|
385
1823
|
function sendRaw(channel, type, payload) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
window.FlutterHost.postMessage(JSON.stringify(msg));
|
|
396
|
-
}
|
|
397
|
-
else if (window.parent !== window) {
|
|
398
|
-
// Web iframe: 親ウィンドウへ postMessage 経由
|
|
399
|
-
window.parent.postMessage(JSON.stringify(msg), '*');
|
|
400
|
-
}
|
|
1824
|
+
const msg = {
|
|
1825
|
+
channel,
|
|
1826
|
+
type,
|
|
1827
|
+
payload,
|
|
1828
|
+
..._playerId ? { playerId: _playerId } : {}
|
|
1829
|
+
};
|
|
1830
|
+
console.log(`[SDK] ➡ send channel=${channel} type=${type}`, JSON.stringify(msg));
|
|
1831
|
+
if (window.FlutterHost) window.FlutterHost.postMessage(JSON.stringify(msg));
|
|
1832
|
+
else if (window.parent !== window) window.parent.postMessage(JSON.stringify(msg), "*");
|
|
401
1833
|
}
|
|
402
1834
|
function handleMessage(msg) {
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
// ─── HUD inset 計算 ─────────────────────────────────────────
|
|
433
|
-
// Flutter 側の HudInsets と同じ定数・ロジック
|
|
434
|
-
const HUD_TOP = 8;
|
|
435
|
-
const HUD_LEFT = 12;
|
|
436
|
-
const UZU_BUTTON_SIZE = 44;
|
|
1835
|
+
const { channel, type, payload } = msg;
|
|
1836
|
+
if (channel === "sdk") {
|
|
1837
|
+
switch (type) {
|
|
1838
|
+
case "getState":
|
|
1839
|
+
console.log(`[SDK] 🔧 handleMessage sdk/getState → responding`);
|
|
1840
|
+
sendRaw("sdk", "stateResponse", { state: _lastStateSnap });
|
|
1841
|
+
return;
|
|
1842
|
+
case "clearState":
|
|
1843
|
+
console.log(`[SDK] 🗑 handleMessage sdk/clearState → forwarding to server`);
|
|
1844
|
+
if (_syncWs && _syncWs.connectionState === "connected") _syncWs.send(JSON.stringify({ type: "__clear_state" }));
|
|
1845
|
+
return;
|
|
1846
|
+
case "playersChanged": {
|
|
1847
|
+
const players = payload?.players ?? {};
|
|
1848
|
+
_playersChangedHandlers.forEach((fn) => fn(players));
|
|
1849
|
+
return;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (channel === "game") {
|
|
1855
|
+
const h = gameHandlers.get(type) || [];
|
|
1856
|
+
console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
|
|
1857
|
+
h.forEach((fn) => fn(payload ?? {}));
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
437
1861
|
const GAP = 6;
|
|
438
1862
|
const ACTION_ITEM_WIDTH = 38;
|
|
439
1863
|
/**
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
1864
|
+
* HUD 回避領域の右下座標を算出する。
|
|
1865
|
+
* クエリパラメータに uzuHudInsetX/Y がない場合のフォールバック値として使用。
|
|
1866
|
+
*
|
|
1867
|
+
* 前提: UZU ボタンは常に表示される (onMenuPressed は常に非 null)。
|
|
1868
|
+
* Dart 側の HudInsets.x() は hasMenu=false を扱えるが、
|
|
1869
|
+
* フォールバック計算では安全側に倒して常に UZU ボタンありで算出する。
|
|
1870
|
+
*
|
|
1871
|
+
* - players が取得できる場合: 2人以上→アクション数2、1人→アクション数0
|
|
1872
|
+
* - players が取得できない場合: アクション数2 (安全側に倒す)
|
|
1873
|
+
*/
|
|
450
1874
|
function calcHudInsets(params) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
let x = HUD_LEFT + UZU_BUTTON_SIZE;
|
|
464
|
-
if (actionCount > 0) {
|
|
465
|
-
x += GAP + ACTION_ITEM_WIDTH * actionCount;
|
|
466
|
-
}
|
|
467
|
-
return { x, y };
|
|
1875
|
+
const y = 52;
|
|
1876
|
+
let actionCount = 2;
|
|
1877
|
+
const json = params.get("players") ?? params.get("seats");
|
|
1878
|
+
if (json) try {
|
|
1879
|
+
actionCount = JSON.parse(json).length >= 2 ? 2 : 0;
|
|
1880
|
+
} catch {}
|
|
1881
|
+
let x = 56;
|
|
1882
|
+
if (actionCount > 0) x += GAP + ACTION_ITEM_WIDTH * actionCount;
|
|
1883
|
+
return {
|
|
1884
|
+
x,
|
|
1885
|
+
y
|
|
1886
|
+
};
|
|
468
1887
|
}
|
|
1888
|
+
//#endregion
|
|
1889
|
+
export { DEFAULT_ICON_URLS, ReconnectableWebSocket, Room, SERVER_TIME, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, getPredictionWarnings, getRoom, init, isHosted, isServerOnlyAction, on, onPlayersChanged, onRoom, playBgm, playSound, run, send, serverNow, serverOnly, setMicEnabled, stopBgm, sync };
|