@uzuhq/code-sdk 0.8.8 → 0.8.10
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/README.md +18 -0
- package/dist/dev-globals.d.ts +1 -1
- package/dist/{dev-hooks-B9mpXwaP.d.ts → dev-hooks-7UDN8Xac.d.ts} +168 -162
- package/dist/index.d.ts +54 -33
- package/dist/index.js +547 -517
- package/package.json +6 -3
package/dist/index.js
CHANGED
|
@@ -153,19 +153,19 @@ var GameClock = class {
|
|
|
153
153
|
* IMPORTANT: `fn` の中で [GameClock.now] を呼ばないこと。差し替えた `Date.now` を
|
|
154
154
|
* 実時刻として引き算してしまう。ハンドラへ渡す ctx は呼び出す前に組み立てる。
|
|
155
155
|
*/
|
|
156
|
-
|
|
156
|
+
const withGameClock = (time, fn) => {
|
|
157
157
|
const realNow = Date.now;
|
|
158
|
-
const
|
|
158
|
+
const realDate = Date;
|
|
159
159
|
const ms = toMs(time);
|
|
160
160
|
Date.now = () => ms;
|
|
161
|
-
globalThis.Date = new Proxy(
|
|
161
|
+
globalThis.Date = new Proxy(realDate, { construct: (target, args) => Reflect.construct(target, args.length === 0 ? [ms] : args) });
|
|
162
162
|
try {
|
|
163
163
|
return fn();
|
|
164
164
|
} finally {
|
|
165
|
-
globalThis.Date =
|
|
165
|
+
globalThis.Date = realDate;
|
|
166
166
|
Date.now = realNow;
|
|
167
167
|
}
|
|
168
|
-
}
|
|
168
|
+
};
|
|
169
169
|
/**
|
|
170
170
|
* `deadlines[].at()` が返した値を締切として使えるかに正規化する。使えなければ null。
|
|
171
171
|
*
|
|
@@ -180,45 +180,41 @@ function withGameClock(time, fn) {
|
|
|
180
180
|
*
|
|
181
181
|
* @param warned 既に警告した key。呼び出し側がインスタンスごとに持つ。
|
|
182
182
|
*/
|
|
183
|
-
|
|
183
|
+
const resolveDeadline = (at, key, warned, report) => {
|
|
184
184
|
if (at === null || at === void 0) return null;
|
|
185
185
|
if (typeof at === "number" && Number.isFinite(at)) return asGameTime(at);
|
|
186
186
|
if (!warned.has(key)) {
|
|
187
187
|
warned.add(key);
|
|
188
|
-
report(`deadline "${key}" の at() が締切に使えない値を返した: ${String(at)}。この締切は二度と発火しない。廃止された ctx.now を読んでいないか確認すること`);
|
|
188
|
+
report(`deadline "${key}" の at() が締切に使えない値を返した: ${typeof at === "number" ? String(at) : JSON.stringify(at)}。この締切は二度と発火しない。廃止された ctx.now を読んでいないか確認すること`);
|
|
189
189
|
}
|
|
190
190
|
return null;
|
|
191
|
-
}
|
|
191
|
+
};
|
|
192
192
|
//#endregion
|
|
193
193
|
//#region ../engine-core/src/json-patch.ts
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
194
|
+
const isIndexable = (value) => typeof value === "object" && value !== null;
|
|
195
|
+
const unescapePointer = (token) => token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
196
|
+
/** `op.path` の親までを辿る。途中で潜れなくなったら null。 */
|
|
197
|
+
const resolveParent$1 = (doc, tokens) => {
|
|
198
|
+
let target = doc;
|
|
199
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
200
|
+
if (!isIndexable(target)) return null;
|
|
201
|
+
target = target[tokens[i]];
|
|
202
|
+
}
|
|
203
|
+
return isIndexable(target) ? target : null;
|
|
204
|
+
};
|
|
205
|
+
const applyPatch = (doc, ops) => {
|
|
198
206
|
for (const op of ops) {
|
|
199
207
|
const tokens = op.path.split("/").slice(1).map(unescapePointer);
|
|
200
208
|
if (tokens.length === 0) return false;
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const lastKey = tokens[tokens.length - 1];
|
|
208
|
-
target[lastKey] = op.value;
|
|
209
|
-
} else if (op.op === "remove") {
|
|
210
|
-
let target = doc;
|
|
211
|
-
for (let i = 0; i < tokens.length - 1; i++) {
|
|
212
|
-
target = target?.[tokens[i]];
|
|
213
|
-
if (target === void 0 || target === null) return false;
|
|
214
|
-
}
|
|
215
|
-
const lastKey = tokens[tokens.length - 1];
|
|
216
|
-
if (Array.isArray(target)) target.splice(Number(lastKey), 1);
|
|
217
|
-
else delete target[lastKey];
|
|
218
|
-
}
|
|
209
|
+
const target = resolveParent$1(doc, tokens);
|
|
210
|
+
if (target === null) return false;
|
|
211
|
+
const lastKey = tokens[tokens.length - 1];
|
|
212
|
+
if (op.op === "replace" || op.op === "add") target[lastKey] = op.value;
|
|
213
|
+
else if (Array.isArray(target)) target.splice(Number(lastKey), 1);
|
|
214
|
+
else Reflect.deleteProperty(target, lastKey);
|
|
219
215
|
}
|
|
220
216
|
return true;
|
|
221
|
-
}
|
|
217
|
+
};
|
|
222
218
|
//#endregion
|
|
223
219
|
//#region ../engine-core/src/random.ts
|
|
224
220
|
var SeededRandomImpl = class SeededRandomImpl {
|
|
@@ -267,17 +263,13 @@ var SeededRandomImpl = class SeededRandomImpl {
|
|
|
267
263
|
* serverActions: { notifyExternal: async (state) => { ... } }
|
|
268
264
|
* ```
|
|
269
265
|
*/
|
|
270
|
-
|
|
271
|
-
return Object.assign(handler, { __serverOnly: true });
|
|
272
|
-
}
|
|
266
|
+
const serverOnly = (handler) => Object.assign(handler, { __serverOnly: true });
|
|
273
267
|
/**
|
|
274
268
|
* handler が `serverOnly()` で wrap されているか判定する。
|
|
275
269
|
*
|
|
276
270
|
* 移行期の互換用。`serverActions` へ移行済みの logic では常に false になる。
|
|
277
271
|
*/
|
|
278
|
-
|
|
279
|
-
return typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
|
|
280
|
-
}
|
|
272
|
+
const isServerOnlyAction = (handler) => typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
|
|
281
273
|
//#endregion
|
|
282
274
|
//#region src/server-clock.ts
|
|
283
275
|
/**
|
|
@@ -310,19 +302,19 @@ let frozenAt = null;
|
|
|
310
302
|
* 下り片道遅延を無視するので推定は実サーバー時刻より僅かに遅れる。
|
|
311
303
|
* 表示用途では無視できる誤差なので、精度より単純さを取る。
|
|
312
304
|
*/
|
|
313
|
-
|
|
305
|
+
const observeGameTime = (t) => {
|
|
314
306
|
if (typeof t !== "number" || !Number.isFinite(t)) return;
|
|
315
307
|
offset = t - performance.now();
|
|
316
|
-
}
|
|
308
|
+
};
|
|
317
309
|
/**
|
|
318
310
|
* ゲーム内時刻の推定値。停止中は凍る。
|
|
319
311
|
*
|
|
320
312
|
* カウントダウン描画のように毎秒/毎フレーム呼ぶ用途を想定しているので、
|
|
321
313
|
* state の到着とは無関係にいつでも呼べる。
|
|
322
314
|
*/
|
|
323
|
-
|
|
315
|
+
const gameTime = () => {
|
|
324
316
|
return frozenAt ?? asGameTime(performance.now() + offset);
|
|
325
|
-
}
|
|
317
|
+
};
|
|
326
318
|
/**
|
|
327
319
|
* 時計を凍らせる。
|
|
328
320
|
*
|
|
@@ -330,12 +322,12 @@ function gameTime() {
|
|
|
330
322
|
* 時点の値で明示的に止める。解除後は通常の打刻でオフセットが入れ替わるため、
|
|
331
323
|
* 特別な補正は要らない。
|
|
332
324
|
*/
|
|
333
|
-
|
|
325
|
+
const freezeGameTime = () => {
|
|
334
326
|
frozenAt = gameTime();
|
|
335
|
-
}
|
|
336
|
-
|
|
327
|
+
};
|
|
328
|
+
const unfreezeGameTime = () => {
|
|
337
329
|
frozenAt = null;
|
|
338
|
-
}
|
|
330
|
+
};
|
|
339
331
|
//#endregion
|
|
340
332
|
//#region src/pause-state.ts
|
|
341
333
|
/**
|
|
@@ -355,7 +347,7 @@ let paused = false;
|
|
|
355
347
|
let pausedBy = null;
|
|
356
348
|
const listeners$1 = /* @__PURE__ */ new Set();
|
|
357
349
|
/** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
|
|
358
|
-
|
|
350
|
+
const setPauseState = (next, by) => {
|
|
359
351
|
pausedBy = next ? by : null;
|
|
360
352
|
if (next === paused) return;
|
|
361
353
|
paused = next;
|
|
@@ -366,37 +358,37 @@ function setPauseState(next, by) {
|
|
|
366
358
|
} catch (err) {
|
|
367
359
|
console.warn("[uzu] onPauseChange listener threw:", err);
|
|
368
360
|
}
|
|
369
|
-
}
|
|
361
|
+
};
|
|
370
362
|
/** 緊急停止中か。 */
|
|
371
|
-
|
|
363
|
+
const isPaused = () => {
|
|
372
364
|
return paused;
|
|
373
|
-
}
|
|
365
|
+
};
|
|
374
366
|
/** 停止を要求した playerId。停止していなければ null。 */
|
|
375
|
-
|
|
367
|
+
const getPausedBy = () => {
|
|
376
368
|
return pausedBy;
|
|
377
|
-
}
|
|
369
|
+
};
|
|
378
370
|
/** 停止状態の変化を購読する。 */
|
|
379
|
-
|
|
371
|
+
const onPauseChange = (cb) => {
|
|
380
372
|
listeners$1.add(cb);
|
|
381
|
-
}
|
|
373
|
+
};
|
|
382
374
|
let requester = null;
|
|
383
|
-
|
|
375
|
+
const setPauseRequester = (fn) => {
|
|
384
376
|
requester = fn;
|
|
385
|
-
}
|
|
386
|
-
|
|
377
|
+
};
|
|
378
|
+
const requestPause = () => {
|
|
387
379
|
if (!requester) {
|
|
388
380
|
console.warn("[uzu] requestPause: サーバーへ接続していないので停止できません");
|
|
389
381
|
return;
|
|
390
382
|
}
|
|
391
383
|
requester(true);
|
|
392
|
-
}
|
|
393
|
-
|
|
384
|
+
};
|
|
385
|
+
const requestResume = () => {
|
|
394
386
|
if (!requester) {
|
|
395
387
|
console.warn("[uzu] requestResume: サーバーへ接続していないので解除できません");
|
|
396
388
|
return;
|
|
397
389
|
}
|
|
398
390
|
requester(false);
|
|
399
|
-
}
|
|
391
|
+
};
|
|
400
392
|
//#endregion
|
|
401
393
|
//#region src/reconnectable-ws.ts
|
|
402
394
|
const DEFAULTS = {
|
|
@@ -447,14 +439,12 @@ var ReconnectableWebSocket = class {
|
|
|
447
439
|
if (this.messageBuffer.length < this.opts.maxBufferSize) this.messageBuffer.push(data);
|
|
448
440
|
}
|
|
449
441
|
}
|
|
450
|
-
addEventListener(
|
|
451
|
-
|
|
442
|
+
addEventListener(_type, handler) {
|
|
443
|
+
this.messageHandlers.push(handler);
|
|
452
444
|
}
|
|
453
|
-
removeEventListener(
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
if (index !== -1) this.messageHandlers.splice(index, 1);
|
|
457
|
-
}
|
|
445
|
+
removeEventListener(_type, handler) {
|
|
446
|
+
const index = this.messageHandlers.indexOf(handler);
|
|
447
|
+
if (index !== -1) this.messageHandlers.splice(index, 1);
|
|
458
448
|
}
|
|
459
449
|
/** バッファをクリア(権威的 state 受信時に呼ぶ) */
|
|
460
450
|
clearBuffer() {
|
|
@@ -545,7 +535,7 @@ var ReconnectableWebSocket = class {
|
|
|
545
535
|
return Math.max(0, exponentialDelay + jitter);
|
|
546
536
|
}
|
|
547
537
|
flushBuffer() {
|
|
548
|
-
if (
|
|
538
|
+
if (this.ws?.readyState !== WebSocket.OPEN) return;
|
|
549
539
|
const buffer = this.messageBuffer;
|
|
550
540
|
this.messageBuffer = [];
|
|
551
541
|
for (const data of buffer) try {
|
|
@@ -600,7 +590,7 @@ var ReconnectableWebSocket = class {
|
|
|
600
590
|
*
|
|
601
591
|
* 型 mismatch (例: object → primitive) は overwrite で許可する。
|
|
602
592
|
*/
|
|
603
|
-
|
|
593
|
+
const applyJsonMergePatch = (target, patch) => {
|
|
604
594
|
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.");
|
|
605
595
|
for (const key of Object.keys(patch)) {
|
|
606
596
|
const value = patch[key];
|
|
@@ -615,7 +605,7 @@ function applyJsonMergePatch(target, patch) {
|
|
|
615
605
|
applyJsonMergePatch(current, value);
|
|
616
606
|
} else target[key] = value;
|
|
617
607
|
}
|
|
618
|
-
}
|
|
608
|
+
};
|
|
619
609
|
/**
|
|
620
610
|
* JSON Pointer (RFC 6901) を tokens に分解する。
|
|
621
611
|
* `/board/1/~04` → `['board', '1', '~4']`
|
|
@@ -626,16 +616,16 @@ function applyJsonMergePatch(target, patch) {
|
|
|
626
616
|
*
|
|
627
617
|
* 復号順は ~1 → ~0。逆順で解くと `~01` が `/` に化けるので注意。
|
|
628
618
|
*/
|
|
629
|
-
|
|
619
|
+
const parseJsonPointer = (pointer) => {
|
|
630
620
|
if (pointer === "") return [];
|
|
631
621
|
if (!pointer.startsWith("/")) throw new TypeError(`[uzu_dev] invalid JSON Pointer: ${pointer} (must start with "/")`);
|
|
632
622
|
return pointer.slice(1).split("/").map((token) => token.replace(/~1/g, "/").replace(/~0/g, "~"));
|
|
633
|
-
}
|
|
623
|
+
};
|
|
634
624
|
/**
|
|
635
625
|
* JSON Pointer の **親 container** と **末端 key** に解決する。
|
|
636
626
|
* root pointer (`''`) は対応外: 呼び出し側で別途扱う。
|
|
637
627
|
*/
|
|
638
|
-
|
|
628
|
+
const resolveParent = (doc, tokens) => {
|
|
639
629
|
if (tokens.length === 0) throw new TypeError("[uzu_dev] root pointer (\"\") is not addressable by this helper");
|
|
640
630
|
let current = doc;
|
|
641
631
|
for (let i = 0; i < tokens.length - 1; i++) {
|
|
@@ -651,12 +641,12 @@ function resolveParent(doc, tokens) {
|
|
|
651
641
|
parent: current,
|
|
652
642
|
key: tokens[tokens.length - 1]
|
|
653
643
|
};
|
|
654
|
-
}
|
|
644
|
+
};
|
|
655
645
|
/**
|
|
656
646
|
* JSON Pointer から値を取得する。root pointer は doc 自体。
|
|
657
647
|
* 存在しない path は throw する。
|
|
658
648
|
*/
|
|
659
|
-
|
|
649
|
+
const getByPointer = (doc, pointer) => {
|
|
660
650
|
const tokens = parseJsonPointer(pointer);
|
|
661
651
|
let current = doc;
|
|
662
652
|
for (const token of tokens) {
|
|
@@ -671,12 +661,12 @@ function getByPointer(doc, pointer) {
|
|
|
671
661
|
}
|
|
672
662
|
}
|
|
673
663
|
return current;
|
|
674
|
-
}
|
|
664
|
+
};
|
|
675
665
|
/**
|
|
676
666
|
* Array index token を numeric index に解決する。
|
|
677
667
|
* `'-'` は append (RFC 6902 仕様、`add` 操作のみ許可) で `length` を返す。
|
|
678
668
|
*/
|
|
679
|
-
|
|
669
|
+
const parseArrayIndex = (token, length, allowAppend) => {
|
|
680
670
|
if (token === "-") {
|
|
681
671
|
if (!allowAppend) throw new TypeError("[uzu_dev] array append (\"-\") is only valid for `add` operations");
|
|
682
672
|
return length;
|
|
@@ -685,19 +675,19 @@ function parseArrayIndex(token, length, allowAppend) {
|
|
|
685
675
|
const idx = Number(token);
|
|
686
676
|
if (idx < 0 || idx > length) throw new RangeError(`[uzu_dev] array index ${idx} out of range (length=${length})`);
|
|
687
677
|
return idx;
|
|
688
|
-
}
|
|
678
|
+
};
|
|
689
679
|
/**
|
|
690
680
|
* 値の深い等価判定 (RFC 6902 `test` 用)。`JSON.stringify` ベース。
|
|
691
681
|
* `undefined` を含む値や key 順序差まで真面目に見るほどには使わないので十分。
|
|
692
682
|
*/
|
|
693
|
-
|
|
683
|
+
const deepEqual = (a, b) => {
|
|
694
684
|
if (a === b) return true;
|
|
695
685
|
try {
|
|
696
686
|
return JSON.stringify(a) === JSON.stringify(b);
|
|
697
687
|
} catch {
|
|
698
688
|
return false;
|
|
699
689
|
}
|
|
700
|
-
}
|
|
690
|
+
};
|
|
701
691
|
/**
|
|
702
692
|
* RFC 6902 (JSON Patch) operations を target に in-place 適用する。
|
|
703
693
|
*
|
|
@@ -709,10 +699,10 @@ function deepEqual(a, b) {
|
|
|
709
699
|
* doc のフィールドを丸ごと差し替える形で動く (= 結局 `setRawState` で十分な
|
|
710
700
|
* ケースなので、わざわざ `patchRawState` で使うことはほぼない)。
|
|
711
701
|
*/
|
|
712
|
-
|
|
702
|
+
const applyJsonPatch = (target, ops) => {
|
|
713
703
|
for (const op of ops) applyOne(target, op);
|
|
714
|
-
}
|
|
715
|
-
|
|
704
|
+
};
|
|
705
|
+
const applyOne = (doc, op) => {
|
|
716
706
|
switch (op.op) {
|
|
717
707
|
case "add": {
|
|
718
708
|
const tokens = parseJsonPointer(op.path);
|
|
@@ -736,7 +726,7 @@ function applyOne(doc, op) {
|
|
|
736
726
|
parent.splice(idx, 1);
|
|
737
727
|
} else {
|
|
738
728
|
if (!(key in parent)) throw new TypeError(`[uzu_dev] cannot remove non-existent path "${op.path}"`);
|
|
739
|
-
|
|
729
|
+
Reflect.deleteProperty(parent, key);
|
|
740
730
|
}
|
|
741
731
|
return;
|
|
742
732
|
}
|
|
@@ -781,36 +771,38 @@ function applyOne(doc, op) {
|
|
|
781
771
|
});
|
|
782
772
|
return;
|
|
783
773
|
}
|
|
784
|
-
case "test":
|
|
785
|
-
|
|
774
|
+
case "test": {
|
|
775
|
+
const value = getByPointer(doc, op.path);
|
|
776
|
+
if (!deepEqual(value, op.value)) throw new Error(`[uzu_dev] test failed at "${op.path}"`);
|
|
786
777
|
return;
|
|
778
|
+
}
|
|
787
779
|
default: throw new TypeError(`[uzu_dev] unknown JSON Patch op: ${JSON.stringify(op)}`);
|
|
788
780
|
}
|
|
789
|
-
}
|
|
790
|
-
|
|
781
|
+
};
|
|
782
|
+
const isProperPrefixPointer = (prefix, path) => {
|
|
791
783
|
if (prefix === "") return path !== "";
|
|
792
784
|
return path === prefix || path.startsWith(`${prefix}/`);
|
|
793
|
-
}
|
|
785
|
+
};
|
|
794
786
|
/**
|
|
795
787
|
* root pointer (`''`) への add / replace 用。doc を in-place で空にして
|
|
796
788
|
* value の中身を流し込む。value が object でない場合は Record の形に
|
|
797
789
|
* 入らないので throw する (root を primitive / array に置換はサポート対象外、
|
|
798
790
|
* setRawState を使うべき)。
|
|
799
791
|
*/
|
|
800
|
-
|
|
792
|
+
const replaceRoot = (doc, value) => {
|
|
801
793
|
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");
|
|
802
|
-
for (const key of Object.keys(doc))
|
|
794
|
+
for (const key of Object.keys(doc)) Reflect.deleteProperty(doc, key);
|
|
803
795
|
Object.assign(doc, value);
|
|
804
|
-
}
|
|
796
|
+
};
|
|
805
797
|
//#endregion
|
|
806
798
|
//#region src/dev-prediction-traps.ts
|
|
807
799
|
/** 描画経路の警告に使う擬似 action 名。 */
|
|
808
800
|
const RENDER_CONTEXT = "(render)";
|
|
809
801
|
/** 実時刻。先読みでも描画でもサーバーとズレる。 */
|
|
810
802
|
const WALL_CLOCK_TRAPS = [{ install: (report) => {
|
|
811
|
-
const
|
|
803
|
+
const realDate = Date;
|
|
812
804
|
const realNow = Date.now;
|
|
813
|
-
globalThis.Date = new Proxy(
|
|
805
|
+
globalThis.Date = new Proxy(realDate, { construct: (target, args) => {
|
|
814
806
|
if (args.length === 0) report("new Date()");
|
|
815
807
|
return Reflect.construct(target, args);
|
|
816
808
|
} });
|
|
@@ -820,7 +812,7 @@ const WALL_CLOCK_TRAPS = [{ install: (report) => {
|
|
|
820
812
|
};
|
|
821
813
|
return () => {
|
|
822
814
|
Date.now = realNow;
|
|
823
|
-
globalThis.Date =
|
|
815
|
+
globalThis.Date = realDate;
|
|
824
816
|
};
|
|
825
817
|
} }];
|
|
826
818
|
/**
|
|
@@ -838,8 +830,8 @@ const WALL_CLOCK_TRAPS = [{ install: (report) => {
|
|
|
838
830
|
* これは正しい検出になる。
|
|
839
831
|
*/
|
|
840
832
|
const MONOTONIC_TRAPS = [{ install: (report) => {
|
|
841
|
-
const real = globalThis.performance
|
|
842
|
-
if (
|
|
833
|
+
const real = globalThis.performance.now;
|
|
834
|
+
if (typeof real !== "function") return () => {};
|
|
843
835
|
const bound = real.bind(globalThis.performance);
|
|
844
836
|
globalThis.performance.now = () => {
|
|
845
837
|
report("performance.now()");
|
|
@@ -860,8 +852,8 @@ const RANDOM_TRAPS = [{ install: (report) => {
|
|
|
860
852
|
Math.random = real;
|
|
861
853
|
};
|
|
862
854
|
} }, { install: (report) => {
|
|
863
|
-
const real = globalThis.crypto
|
|
864
|
-
if (
|
|
855
|
+
const real = globalThis.crypto.randomUUID;
|
|
856
|
+
if (typeof real !== "function") return () => {};
|
|
865
857
|
const bound = real.bind(globalThis.crypto);
|
|
866
858
|
globalThis.crypto.randomUUID = () => {
|
|
867
859
|
report("crypto.randomUUID()");
|
|
@@ -970,7 +962,7 @@ const runInstrumented = (action, run) => {
|
|
|
970
962
|
const getPredictionWarnings = () => warnings;
|
|
971
963
|
//#endregion
|
|
972
964
|
//#region src/dev-hooks.ts
|
|
973
|
-
|
|
965
|
+
const createDevHooks = (ctx) => {
|
|
974
966
|
const getRawState = () => ctx.getRawState ? ctx.getRawState() : null;
|
|
975
967
|
const waitForSnapshot = (predicate, options) => {
|
|
976
968
|
const timeoutMs = options?.timeoutMs ?? 1e4;
|
|
@@ -981,7 +973,7 @@ function createDevHooks(ctx) {
|
|
|
981
973
|
try {
|
|
982
974
|
ok = predicate(current);
|
|
983
975
|
} catch (err) {
|
|
984
|
-
reject(err);
|
|
976
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
985
977
|
return;
|
|
986
978
|
}
|
|
987
979
|
if (ok) {
|
|
@@ -1000,7 +992,7 @@ function createDevHooks(ctx) {
|
|
|
1000
992
|
} catch (err) {
|
|
1001
993
|
clearTimeout(timer);
|
|
1002
994
|
unsubscribe();
|
|
1003
|
-
reject(err);
|
|
995
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
1004
996
|
return;
|
|
1005
997
|
}
|
|
1006
998
|
if (ok) {
|
|
@@ -1068,10 +1060,10 @@ function createDevHooks(ctx) {
|
|
|
1068
1060
|
hooks.subscribeEvents = (cb) => subscribeEvents(cb);
|
|
1069
1061
|
}
|
|
1070
1062
|
return hooks;
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1063
|
+
};
|
|
1064
|
+
const attachDevHooks = (ctx) => {
|
|
1073
1065
|
window.__uzu_dev = createDevHooks(ctx);
|
|
1074
|
-
}
|
|
1066
|
+
};
|
|
1075
1067
|
//#endregion
|
|
1076
1068
|
//#region src/insets.ts
|
|
1077
1069
|
const SIDES = [
|
|
@@ -1118,7 +1110,7 @@ const toFinite = (value) => {
|
|
|
1118
1110
|
* `env()` は JS から直接読めず、CSS 変数へ入れても `getComputedStyle` は代入前の
|
|
1119
1111
|
* トークン列を返す。padding に置いて算出値を読むのが唯一の手。
|
|
1120
1112
|
*/
|
|
1121
|
-
|
|
1113
|
+
const measureEnvInsets = () => {
|
|
1122
1114
|
if (typeof document === "undefined") return zero();
|
|
1123
1115
|
if (!envProbe?.isConnected) {
|
|
1124
1116
|
const el = document.createElement("div");
|
|
@@ -1138,18 +1130,18 @@ function measureEnvInsets() {
|
|
|
1138
1130
|
bottom: toFinite(style.paddingBottom) ?? 0,
|
|
1139
1131
|
left: toFinite(style.paddingLeft) ?? 0
|
|
1140
1132
|
};
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1133
|
+
};
|
|
1134
|
+
const setVar = (name, value) => {
|
|
1143
1135
|
if (typeof document === "undefined") return;
|
|
1144
1136
|
document.documentElement.style.setProperty(name, value);
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1137
|
+
};
|
|
1138
|
+
const writeSafeAreaVars = () => {
|
|
1147
1139
|
for (const side of SIDES) setVar(`--uzu-safe-area-inset-${side}`, hostSafeArea ? `${hostSafeArea[side]}px` : envExpr(side));
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1140
|
+
};
|
|
1141
|
+
const writeHudVars = () => {
|
|
1150
1142
|
setVar("--uzu-hud-inset-x", `${hud.x}px`);
|
|
1151
1143
|
setVar("--uzu-hud-inset-y", `${hud.y}px`);
|
|
1152
|
-
}
|
|
1144
|
+
};
|
|
1153
1145
|
/**
|
|
1154
1146
|
* 合成済みの層。下 2 層を参照する式で書くので、safe area が実数でも `env()` でも
|
|
1155
1147
|
* 同じ 1 本で成立する。
|
|
@@ -1158,13 +1150,13 @@ function writeHudVars() {
|
|
|
1158
1150
|
* HUD の高さ分だけ空ければその下は全域が安全で、`left` にも足すと L 字全体を避ける
|
|
1159
1151
|
* ことになり過剰に削れる。精密に避けたいシナリオは下 2 層を自分で合成する。
|
|
1160
1152
|
*/
|
|
1161
|
-
|
|
1153
|
+
const writeContentVars = () => {
|
|
1162
1154
|
setVar("--uzu-content-inset-top", "calc(var(--uzu-safe-area-inset-top, 0px) + var(--uzu-hud-inset-y, 0px))");
|
|
1163
1155
|
setVar("--uzu-content-inset-right", "var(--uzu-safe-area-inset-right, 0px)");
|
|
1164
1156
|
setVar("--uzu-content-inset-bottom", "var(--uzu-safe-area-inset-bottom, 0px)");
|
|
1165
1157
|
setVar("--uzu-content-inset-left", "var(--uzu-safe-area-inset-left, 0px)");
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1158
|
+
};
|
|
1159
|
+
const compute = () => {
|
|
1168
1160
|
const safeArea = hostSafeArea ?? measureEnvInsets();
|
|
1169
1161
|
return {
|
|
1170
1162
|
safeArea,
|
|
@@ -1176,11 +1168,11 @@ function compute() {
|
|
|
1176
1168
|
},
|
|
1177
1169
|
hud: { ...hud }
|
|
1178
1170
|
};
|
|
1179
|
-
}
|
|
1171
|
+
};
|
|
1180
1172
|
const sameInsets = (a, b) => SIDES.every((side) => a[side] === b[side]);
|
|
1181
1173
|
const same = (a, b) => sameInsets(a.safeArea, b.safeArea) && a.hud.x === b.hud.x && a.hud.y === b.hud.y;
|
|
1182
1174
|
/** 値が動いたときだけ購読者へ流す。 */
|
|
1183
|
-
|
|
1175
|
+
const refresh = () => {
|
|
1184
1176
|
const next = compute();
|
|
1185
1177
|
if (current && same(current, next)) return;
|
|
1186
1178
|
current = next;
|
|
@@ -1189,7 +1181,7 @@ function refresh() {
|
|
|
1189
1181
|
} catch (err) {
|
|
1190
1182
|
console.warn("[UZU SDK] onInsetsChange listener threw:", err);
|
|
1191
1183
|
}
|
|
1192
|
-
}
|
|
1184
|
+
};
|
|
1193
1185
|
/**
|
|
1194
1186
|
* HUD 回避領域の右下座標を算出する。
|
|
1195
1187
|
* クエリパラメータに uzuHudInsetX/Y がない場合のフォールバック値として使用。
|
|
@@ -1201,7 +1193,7 @@ function refresh() {
|
|
|
1201
1193
|
* - players が取得できる場合: 2人以上→アクション数2、1人→アクション数0
|
|
1202
1194
|
* - players が取得できない場合: アクション数2 (安全側に倒す)
|
|
1203
1195
|
*/
|
|
1204
|
-
|
|
1196
|
+
const fallbackHudInsets = (params) => {
|
|
1205
1197
|
let actionCount = 2;
|
|
1206
1198
|
const json = params.get("players");
|
|
1207
1199
|
if (json) try {
|
|
@@ -1213,8 +1205,8 @@ function fallbackHudInsets(params) {
|
|
|
1213
1205
|
x,
|
|
1214
1206
|
y: 52
|
|
1215
1207
|
};
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1208
|
+
};
|
|
1209
|
+
const readSafeAreaParams = (params) => {
|
|
1218
1210
|
const values = zero();
|
|
1219
1211
|
let supplied = false;
|
|
1220
1212
|
for (const side of SIDES) {
|
|
@@ -1226,7 +1218,7 @@ function readSafeAreaParams(params) {
|
|
|
1226
1218
|
supplied = true;
|
|
1227
1219
|
}
|
|
1228
1220
|
return supplied ? values : null;
|
|
1229
|
-
}
|
|
1221
|
+
};
|
|
1230
1222
|
/**
|
|
1231
1223
|
* URL パラメータから初期値を確定させる。`init()` が 1 度だけ呼ぶ。
|
|
1232
1224
|
*
|
|
@@ -1236,7 +1228,7 @@ function readSafeAreaParams(params) {
|
|
|
1236
1228
|
*
|
|
1237
1229
|
* [discord/embedded-app-sdk#304]: https://github.com/discord/embedded-app-sdk/issues/304
|
|
1238
1230
|
*/
|
|
1239
|
-
|
|
1231
|
+
const applyInsets = (params) => {
|
|
1240
1232
|
const hudX = toFinite(params.get("uzuHudInsetX"));
|
|
1241
1233
|
const hudY = toFinite(params.get("uzuHudInsetY"));
|
|
1242
1234
|
const fallback = fallbackHudInsets(params);
|
|
@@ -1253,12 +1245,12 @@ function applyInsets(params) {
|
|
|
1253
1245
|
window.addEventListener("resize", refresh);
|
|
1254
1246
|
window.addEventListener("orientationchange", refresh);
|
|
1255
1247
|
}
|
|
1256
|
-
}
|
|
1248
|
+
};
|
|
1257
1249
|
/**
|
|
1258
1250
|
* ホストからの `insetsChanged` を反映する。回転 / PiP で値が変わったときに届く。
|
|
1259
1251
|
* 内部用 (bridge の受信口から呼ばれる)。
|
|
1260
1252
|
*/
|
|
1261
|
-
|
|
1253
|
+
const handleInsetsChanged = (payload) => {
|
|
1262
1254
|
const safeArea = payload.safeArea;
|
|
1263
1255
|
if (safeArea) {
|
|
1264
1256
|
const next = zero();
|
|
@@ -1275,17 +1267,17 @@ function handleInsetsChanged(payload) {
|
|
|
1275
1267
|
writeHudVars();
|
|
1276
1268
|
}
|
|
1277
1269
|
refresh();
|
|
1278
|
-
}
|
|
1270
|
+
};
|
|
1279
1271
|
/**
|
|
1280
1272
|
* 現在の inset を返す。
|
|
1281
1273
|
*
|
|
1282
1274
|
* canvas / WebGL は CSS 変数で避けられないので、そこから読む口。値は初回ペイント前に
|
|
1283
1275
|
* 確定しているので、setup の中で読んでよい。
|
|
1284
1276
|
*/
|
|
1285
|
-
|
|
1277
|
+
const getInsets = () => {
|
|
1286
1278
|
if (!current) refresh();
|
|
1287
1279
|
return current ?? compute();
|
|
1288
|
-
}
|
|
1280
|
+
};
|
|
1289
1281
|
/**
|
|
1290
1282
|
* inset の変化を購読する。戻り値を呼ぶと解除できる。
|
|
1291
1283
|
*
|
|
@@ -1294,12 +1286,12 @@ function getInsets() {
|
|
|
1294
1286
|
*
|
|
1295
1287
|
* 登録時に即時呼び出しはしない。初期値は [getInsets] で読むこと。
|
|
1296
1288
|
*/
|
|
1297
|
-
|
|
1289
|
+
const onInsetsChange = (cb) => {
|
|
1298
1290
|
listeners.add(cb);
|
|
1299
1291
|
return () => {
|
|
1300
1292
|
listeners.delete(cb);
|
|
1301
1293
|
};
|
|
1302
|
-
}
|
|
1294
|
+
};
|
|
1303
1295
|
//#endregion
|
|
1304
1296
|
//#region src/scenario-callback.ts
|
|
1305
1297
|
/**
|
|
@@ -1331,44 +1323,97 @@ const callScenario = (label, fn) => {
|
|
|
1331
1323
|
}
|
|
1332
1324
|
};
|
|
1333
1325
|
//#endregion
|
|
1334
|
-
//#region src/run/
|
|
1335
|
-
|
|
1336
|
-
const { logic,
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
callScenario("描画 (onState)", () => config.onState(
|
|
1326
|
+
//#region src/run/local-server-action.ts
|
|
1327
|
+
const runLocalServerAction = (config) => {
|
|
1328
|
+
const { logic, inputs, events } = config;
|
|
1329
|
+
const onState = (next, id) => {
|
|
1330
|
+
observeGameTime(gameTime());
|
|
1331
|
+
callScenario("描画 (onState)", () => config.onState(next, id, "player"));
|
|
1332
|
+
};
|
|
1333
|
+
const tickRate = logic.tickRate ?? 0;
|
|
1334
|
+
const clock = new GameClock();
|
|
1335
|
+
const gameTime = () => clock.now();
|
|
1336
|
+
const frozen = () => clock.frozen;
|
|
1337
|
+
/** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
|
|
1338
|
+
const timeCtx = (t) => ({
|
|
1339
|
+
time: t,
|
|
1340
|
+
after: (d) => plus(t, d)
|
|
1341
|
+
});
|
|
1342
|
+
/** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
|
|
1343
|
+
const warnedDeadlines = /* @__PURE__ */ new Set();
|
|
1344
|
+
const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
|
|
1345
|
+
const players = Array.from({ length: config.playerCount }, (_, i) => ({
|
|
1346
|
+
id: `local_${i}`,
|
|
1347
|
+
nickname: `Player ${i + 1}`,
|
|
1348
|
+
iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
|
|
1349
|
+
}));
|
|
1350
|
+
const myId = players[0].id;
|
|
1351
|
+
let wakeupTimer = null;
|
|
1352
|
+
let wakeupAt = null;
|
|
1353
|
+
const nextDeadline = () => {
|
|
1354
|
+
if (!logic.deadlines) return null;
|
|
1355
|
+
let earliest = null;
|
|
1356
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1357
|
+
let raw;
|
|
1358
|
+
try {
|
|
1359
|
+
raw = deadline.at({ state });
|
|
1360
|
+
} catch (err) {
|
|
1361
|
+
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
1362
|
+
continue;
|
|
1363
|
+
}
|
|
1364
|
+
const at = resolveDeadline(raw, key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1365
|
+
if (at === null) continue;
|
|
1366
|
+
if (earliest === null || at < earliest) earliest = at;
|
|
1367
|
+
}
|
|
1368
|
+
return earliest;
|
|
1369
|
+
};
|
|
1370
|
+
const fireDue = () => {
|
|
1371
|
+
wakeupTimer = null;
|
|
1372
|
+
wakeupAt = null;
|
|
1373
|
+
if (!logic.deadlines || frozen()) return;
|
|
1374
|
+
const time = gameTime();
|
|
1375
|
+
const evts = [];
|
|
1376
|
+
const firedKeys = [];
|
|
1377
|
+
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1378
|
+
const pending = [];
|
|
1379
|
+
let snapshot = null;
|
|
1380
|
+
try {
|
|
1381
|
+
const at = resolveDeadline(deadline.at({ state }), key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1382
|
+
if (at === null || at > time) continue;
|
|
1383
|
+
snapshot = structuredClone(state);
|
|
1384
|
+
withGameClock(time, () => deadline.handler({
|
|
1385
|
+
state,
|
|
1386
|
+
ctx: {
|
|
1387
|
+
...timeCtx(time),
|
|
1388
|
+
random,
|
|
1389
|
+
emit: (name, data) => pending.push({
|
|
1390
|
+
name,
|
|
1391
|
+
data: data ?? {}
|
|
1392
|
+
})
|
|
1393
|
+
}
|
|
1394
|
+
}));
|
|
1395
|
+
evts.push(...pending);
|
|
1396
|
+
firedKeys.push(key);
|
|
1397
|
+
} catch (err) {
|
|
1398
|
+
if (snapshot !== null) state = snapshot;
|
|
1399
|
+
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
syncWakeup();
|
|
1403
|
+
if (firedKeys.length === 0) return;
|
|
1404
|
+
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(", ")}`);
|
|
1405
|
+
dispatchEvents(evts);
|
|
1406
|
+
onState(state, myId);
|
|
1407
|
+
};
|
|
1408
|
+
const syncWakeup = () => {
|
|
1409
|
+
const next = frozen() ? null : nextDeadline();
|
|
1410
|
+
if (next === wakeupAt) return;
|
|
1411
|
+
if (wakeupTimer) clearTimeout(wakeupTimer);
|
|
1412
|
+
wakeupTimer = null;
|
|
1413
|
+
wakeupAt = next;
|
|
1414
|
+
if (next === null) return;
|
|
1415
|
+
wakeupTimer = setTimeout(fireDue, Math.max(0, clock.toWall(next) - Date.now()));
|
|
1340
1416
|
};
|
|
1341
|
-
/** サーバー確定 state (楽観的更新のベース) */
|
|
1342
|
-
let confirmedState = null;
|
|
1343
|
-
/** 表示用 state (pending actions 適用済み) */
|
|
1344
|
-
let displayState = null;
|
|
1345
|
-
/** クライアント側の action 通番 */
|
|
1346
|
-
let actionSeq = 0;
|
|
1347
|
-
/**
|
|
1348
|
-
* 送信済みだがサーバー未確認の action キュー。
|
|
1349
|
-
* `time` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
|
|
1350
|
-
*/
|
|
1351
|
-
const pendingActions = [];
|
|
1352
|
-
/**
|
|
1353
|
-
* 先読みで実行済みの events。
|
|
1354
|
-
*
|
|
1355
|
-
* IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
|
|
1356
|
-
* action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
|
|
1357
|
-
* (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
|
|
1358
|
-
* 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
|
|
1359
|
-
*
|
|
1360
|
-
* 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
|
|
1361
|
-
* された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
|
|
1362
|
-
* 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
|
|
1363
|
-
* 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
|
|
1364
|
-
* 握り潰してしまう。
|
|
1365
|
-
*
|
|
1366
|
-
* サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
|
|
1367
|
-
* 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
|
|
1368
|
-
*/
|
|
1369
|
-
let firedPredictions = [];
|
|
1370
|
-
/** 再適用時はイベントを発火しない (送信時に既に発火済み) */
|
|
1371
|
-
const noopEmit = () => {};
|
|
1372
1417
|
const dispatchEvents = (evts) => {
|
|
1373
1418
|
for (const e of evts) {
|
|
1374
1419
|
const handler = events?.[e.name]?.handler;
|
|
@@ -1376,63 +1421,250 @@ function createOptimisticActionClient(config) {
|
|
|
1376
1421
|
callScenario(`event ハンドラ "${e.name}"`, () => handler(e.data));
|
|
1377
1422
|
}
|
|
1378
1423
|
};
|
|
1379
|
-
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
* - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
|
|
1387
|
-
* → そのまま残って実行される
|
|
1388
|
-
*/
|
|
1389
|
-
const subtractFired = (serverEvts) => {
|
|
1390
|
-
if (firedPredictions.length === 0) return serverEvts;
|
|
1391
|
-
const out = [];
|
|
1392
|
-
for (const e of serverEvts) {
|
|
1393
|
-
const k = eventKey(e);
|
|
1394
|
-
const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
|
|
1395
|
-
if (idx >= 0) firedPredictions.splice(idx, 1);
|
|
1396
|
-
else out.push(e);
|
|
1424
|
+
clock.restart();
|
|
1425
|
+
const setupTime = gameTime();
|
|
1426
|
+
const setupArgs = {
|
|
1427
|
+
players,
|
|
1428
|
+
ctx: {
|
|
1429
|
+
random,
|
|
1430
|
+
...timeCtx(setupTime)
|
|
1397
1431
|
}
|
|
1398
|
-
return out;
|
|
1399
1432
|
};
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1433
|
+
let state = withGameClock(setupTime, () => logic.setup(setupArgs));
|
|
1434
|
+
let tick = 0;
|
|
1435
|
+
const playerInputs = {};
|
|
1436
|
+
const dispatchAction = (type, payload) => {
|
|
1437
|
+
if (frozen()) {
|
|
1438
|
+
console.warn(`[SDK LocalServerAction] 緊急停止中は action を実行しません (action="${type}")`);
|
|
1439
|
+
return;
|
|
1440
|
+
}
|
|
1441
|
+
const plain = type in logic.actions ? logic.actions[type] : void 0;
|
|
1442
|
+
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
1443
|
+
const server = logic.serverActions?.[type] ?? legacyServerOnly;
|
|
1444
|
+
if (!plain && !server) return;
|
|
1445
|
+
const plainEvents = [];
|
|
1446
|
+
const serverEvents = [];
|
|
1447
|
+
const plainEmit = (name, data) => plainEvents.push({
|
|
1448
|
+
name,
|
|
1449
|
+
data: data ?? {}
|
|
1450
|
+
});
|
|
1451
|
+
const serverEmit = (name, data) => serverEvents.push({
|
|
1452
|
+
name,
|
|
1453
|
+
data: data ?? {}
|
|
1454
|
+
});
|
|
1455
|
+
const time = gameTime();
|
|
1456
|
+
if (plain && !legacyServerOnly) {
|
|
1416
1457
|
try {
|
|
1417
|
-
|
|
1418
|
-
state
|
|
1419
|
-
payload,
|
|
1420
|
-
playerId,
|
|
1458
|
+
withGameClock(time, () => plain({
|
|
1459
|
+
state,
|
|
1460
|
+
payload: payload ?? {},
|
|
1461
|
+
playerId: myId,
|
|
1421
1462
|
ctx: {
|
|
1422
|
-
time,
|
|
1423
|
-
|
|
1424
|
-
emit: noopEmit
|
|
1463
|
+
...timeCtx(time),
|
|
1464
|
+
emit: plainEmit
|
|
1425
1465
|
}
|
|
1426
1466
|
}));
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
pendingActions.splice(i, 1);
|
|
1467
|
+
} catch (err) {
|
|
1468
|
+
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1469
|
+
return;
|
|
1431
1470
|
}
|
|
1471
|
+
dispatchEvents(plainEvents);
|
|
1472
|
+
syncWakeup();
|
|
1473
|
+
onState(state, myId);
|
|
1432
1474
|
}
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1475
|
+
if (!server) return;
|
|
1476
|
+
(async () => {
|
|
1477
|
+
try {
|
|
1478
|
+
await server({
|
|
1479
|
+
state,
|
|
1480
|
+
payload: payload ?? {},
|
|
1481
|
+
playerId: myId,
|
|
1482
|
+
ctx: {
|
|
1483
|
+
tick,
|
|
1484
|
+
random,
|
|
1485
|
+
...timeCtx(time),
|
|
1486
|
+
emit: serverEmit
|
|
1487
|
+
}
|
|
1488
|
+
});
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1491
|
+
return;
|
|
1492
|
+
}
|
|
1493
|
+
dispatchEvents(serverEvents);
|
|
1494
|
+
syncWakeup();
|
|
1495
|
+
onState(state, myId);
|
|
1496
|
+
})();
|
|
1497
|
+
};
|
|
1498
|
+
/**
|
|
1499
|
+
* ソロの停止 / 解除。サーバーが居ないので自分の時計を直接触る。
|
|
1500
|
+
*
|
|
1501
|
+
* `__uzu_dev.pauseTick()` とは別物。あちらは tick を止めるだけの開発者ツールで、
|
|
1502
|
+
* ゲーム内時計も action ゲートも持たない。流用しない。
|
|
1503
|
+
*/
|
|
1504
|
+
const applyPause = (paused) => {
|
|
1505
|
+
if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
|
|
1506
|
+
observeGameTime(gameTime());
|
|
1507
|
+
setPauseState(paused, paused ? myId : null);
|
|
1508
|
+
observeGameTime(gameTime());
|
|
1509
|
+
syncWakeup();
|
|
1510
|
+
};
|
|
1511
|
+
setPauseRequester(applyPause);
|
|
1512
|
+
inputs(dispatchAction);
|
|
1513
|
+
syncWakeup();
|
|
1514
|
+
onState(state, myId);
|
|
1515
|
+
if (tickRate > 0) setInterval(() => {
|
|
1516
|
+
if (frozen()) return;
|
|
1517
|
+
const tickEvents = [];
|
|
1518
|
+
const tickEmit = (name, data) => tickEvents.push({
|
|
1519
|
+
name,
|
|
1520
|
+
data: data ?? {}
|
|
1521
|
+
});
|
|
1522
|
+
const time = gameTime();
|
|
1523
|
+
try {
|
|
1524
|
+
withGameClock(time, () => logic.update({
|
|
1525
|
+
state,
|
|
1526
|
+
ctx: {
|
|
1527
|
+
random,
|
|
1528
|
+
tick,
|
|
1529
|
+
...timeCtx(time),
|
|
1530
|
+
emit: tickEmit,
|
|
1531
|
+
playerInputs
|
|
1532
|
+
}
|
|
1533
|
+
}));
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
|
|
1536
|
+
tick++;
|
|
1537
|
+
return;
|
|
1538
|
+
}
|
|
1539
|
+
tick++;
|
|
1540
|
+
dispatchEvents(tickEvents);
|
|
1541
|
+
syncWakeup();
|
|
1542
|
+
onState(state, myId);
|
|
1543
|
+
}, 1e3 / tickRate);
|
|
1544
|
+
return {
|
|
1545
|
+
getRawState: () => state,
|
|
1546
|
+
setRawState: async (next) => {
|
|
1547
|
+
state = next;
|
|
1548
|
+
syncWakeup();
|
|
1549
|
+
onState(state, myId);
|
|
1550
|
+
},
|
|
1551
|
+
mergeRawState: async (patch) => {
|
|
1552
|
+
applyJsonMergePatch(state, patch);
|
|
1553
|
+
onState(state, myId);
|
|
1554
|
+
},
|
|
1555
|
+
patchRawState: async (ops) => {
|
|
1556
|
+
applyJsonPatch(state, ops);
|
|
1557
|
+
onState(state, myId);
|
|
1558
|
+
},
|
|
1559
|
+
sendAction: dispatchAction
|
|
1560
|
+
};
|
|
1561
|
+
};
|
|
1562
|
+
//#endregion
|
|
1563
|
+
//#region src/run/optimistic-action-client.ts
|
|
1564
|
+
const createOptimisticActionClient = (config) => {
|
|
1565
|
+
const { logic, playerId, events, sendAction } = config;
|
|
1566
|
+
/** Record の添字は型の上では常に値を返すので、存在確認を通してから引く。 */
|
|
1567
|
+
const actionHandler = (type) => type in logic.actions ? logic.actions[type] : void 0;
|
|
1568
|
+
/** 呼び出し箇所ごとに守らず、シナリオの描画へ出る唯一の口をここで塞ぐ。 */
|
|
1569
|
+
const onState = (state, id) => {
|
|
1570
|
+
callScenario("描画 (onState)", () => config.onState(state, id));
|
|
1571
|
+
};
|
|
1572
|
+
/** サーバー確定 state (楽観的更新のベース) */
|
|
1573
|
+
let confirmedState = null;
|
|
1574
|
+
/** 表示用 state (pending actions 適用済み) */
|
|
1575
|
+
let displayState = null;
|
|
1576
|
+
/** クライアント側の action 通番 */
|
|
1577
|
+
let actionSeq = 0;
|
|
1578
|
+
/**
|
|
1579
|
+
* 送信済みだがサーバー未確認の action キュー。
|
|
1580
|
+
* `time` は送信時に推定した値。再適用でも同じ値を使う (取り直すと表示がガタつく)。
|
|
1581
|
+
*/
|
|
1582
|
+
const pendingActions = [];
|
|
1583
|
+
/**
|
|
1584
|
+
* 先読みで実行済みの events。
|
|
1585
|
+
*
|
|
1586
|
+
* IMPORTANT: pending action ごとではなくクライアント単位で持つ。二重実行は「自分の
|
|
1587
|
+
* action の ack」だけでなく「他プレイヤーの action のブロードキャスト」でも起きるため
|
|
1588
|
+
* (A と B が同時に同じ行送りを撃つと、B は自分の先読みで実行済みなのに A 由来の
|
|
1589
|
+
* 配信でもう一度実行してしまう)。どの配信が来ても、まずここと突き合わせる。
|
|
1590
|
+
*
|
|
1591
|
+
* 記録には「どの action の先読みで実行したか」(seq) を持たせる。その action が ack
|
|
1592
|
+
* された時点で引き当てられていない記録は「予測したが実際には起きなかった出来事」なので
|
|
1593
|
+
* 捨てる。seq を持たせずに「pending が空になったら捨てる」だけにすると、別の action が
|
|
1594
|
+
* 未確定な間ずっと外れた記録が生き残り、その後に本当に起きた同名イベントを 1 回
|
|
1595
|
+
* 握り潰してしまう。
|
|
1596
|
+
*
|
|
1597
|
+
* サーバーは 1 本の接続へ処理順どおりに送るので、他プレイヤーの配信は自分の ack より
|
|
1598
|
+
* 必ず先に届く = 引き当てのチャンスは記録が生きている間に必ず来る。
|
|
1599
|
+
*/
|
|
1600
|
+
let firedPredictions = [];
|
|
1601
|
+
/** 再適用時はイベントを発火しない (送信時に既に発火済み) */
|
|
1602
|
+
const noopEmit = () => {};
|
|
1603
|
+
const dispatchEvents = (evts) => {
|
|
1604
|
+
for (const e of evts) {
|
|
1605
|
+
const handler = events?.[e.name]?.handler;
|
|
1606
|
+
if (!handler) continue;
|
|
1607
|
+
callScenario(`event ハンドラ "${e.name}"`, () => handler(e.data));
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
/** events の同一性キー。name と data が一致すれば「同じ出来事」とみなす。 */
|
|
1611
|
+
const eventKey = (e) => `${e.name}\u0000${JSON.stringify(e.data)}`;
|
|
1612
|
+
/**
|
|
1613
|
+
* サーバーの events から、先読みで実行済みのものを差し引く (多重集合の差)。
|
|
1614
|
+
*
|
|
1615
|
+
* - 予測が当たった → 差が空。二重に実行しない
|
|
1616
|
+
* - 予測が外れた → サーバー側の正しい event が残り、確定として実行される
|
|
1617
|
+
* - 先読みで実行していない (predict: false / serverActions 由来 / 他プレイヤー由来)
|
|
1618
|
+
* → そのまま残って実行される
|
|
1619
|
+
*/
|
|
1620
|
+
const subtractFired = (serverEvts) => {
|
|
1621
|
+
if (firedPredictions.length === 0) return serverEvts;
|
|
1622
|
+
const out = [];
|
|
1623
|
+
for (const e of serverEvts) {
|
|
1624
|
+
const k = eventKey(e);
|
|
1625
|
+
const idx = firedPredictions.findIndex((f) => eventKey(f) === k);
|
|
1626
|
+
if (idx >= 0) firedPredictions.splice(idx, 1);
|
|
1627
|
+
else out.push(e);
|
|
1628
|
+
}
|
|
1629
|
+
return out;
|
|
1630
|
+
};
|
|
1631
|
+
/**
|
|
1632
|
+
* confirmedState をベースに pending actions を再適用して displayState を更新する。
|
|
1633
|
+
* 再適用失敗した action はキューから除去する。
|
|
1634
|
+
*/
|
|
1635
|
+
const reapplyPendingActions = () => {
|
|
1636
|
+
if (confirmedState === null) return;
|
|
1637
|
+
let display = structuredClone(confirmedState);
|
|
1638
|
+
let i = 0;
|
|
1639
|
+
while (i < pendingActions.length) {
|
|
1640
|
+
const { action, payload, time } = pendingActions[i];
|
|
1641
|
+
const handler = actionHandler(action);
|
|
1642
|
+
if (!handler || isServerOnlyAction(handler)) {
|
|
1643
|
+
pendingActions.splice(i, 1);
|
|
1644
|
+
continue;
|
|
1645
|
+
}
|
|
1646
|
+
const base = structuredClone(display);
|
|
1647
|
+
try {
|
|
1648
|
+
runPredicted(action, () => handler({
|
|
1649
|
+
state: display,
|
|
1650
|
+
payload,
|
|
1651
|
+
playerId,
|
|
1652
|
+
ctx: {
|
|
1653
|
+
time,
|
|
1654
|
+
after: (d) => plus(time, d),
|
|
1655
|
+
emit: noopEmit
|
|
1656
|
+
}
|
|
1657
|
+
}));
|
|
1658
|
+
i++;
|
|
1659
|
+
} catch {
|
|
1660
|
+
display = base;
|
|
1661
|
+
pendingActions.splice(i, 1);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
displayState = display;
|
|
1665
|
+
onState(display, playerId);
|
|
1666
|
+
};
|
|
1667
|
+
/**
|
|
1436
1668
|
* サーバーからの配信を処理する。
|
|
1437
1669
|
*
|
|
1438
1670
|
* 送信元が誰であれ、まず先読みの実行記録と突き合わせる。自分の ack だけを見ていると、
|
|
@@ -1453,7 +1685,8 @@ function createOptimisticActionClient(config) {
|
|
|
1453
1685
|
}
|
|
1454
1686
|
actionSeq++;
|
|
1455
1687
|
const seq = actionSeq;
|
|
1456
|
-
const
|
|
1688
|
+
const actionPayload = payload ?? {};
|
|
1689
|
+
const handler = actionHandler(type);
|
|
1457
1690
|
if (!handler && !logic.serverActions?.[type]) console.error(`[uzu] unknown action: "${type}". logic.actions / logic.serverActions のどちらにも登録されていません。`);
|
|
1458
1691
|
const target = displayState;
|
|
1459
1692
|
const time = gameTime();
|
|
@@ -1471,7 +1704,7 @@ function createOptimisticActionClient(config) {
|
|
|
1471
1704
|
if (handler && !isServerOnlyAction(handler) && target !== null) try {
|
|
1472
1705
|
runPredicted(type, () => handler({
|
|
1473
1706
|
state: target,
|
|
1474
|
-
payload:
|
|
1707
|
+
payload: actionPayload,
|
|
1475
1708
|
playerId,
|
|
1476
1709
|
ctx: {
|
|
1477
1710
|
time,
|
|
@@ -1482,7 +1715,7 @@ function createOptimisticActionClient(config) {
|
|
|
1482
1715
|
pendingActions.push({
|
|
1483
1716
|
seq,
|
|
1484
1717
|
action: type,
|
|
1485
|
-
payload:
|
|
1718
|
+
payload: actionPayload,
|
|
1486
1719
|
time
|
|
1487
1720
|
});
|
|
1488
1721
|
onState(target, playerId);
|
|
@@ -1491,7 +1724,7 @@ function createOptimisticActionClient(config) {
|
|
|
1491
1724
|
}
|
|
1492
1725
|
sendAction({
|
|
1493
1726
|
action: type,
|
|
1494
|
-
payload:
|
|
1727
|
+
payload: actionPayload,
|
|
1495
1728
|
seq
|
|
1496
1729
|
});
|
|
1497
1730
|
},
|
|
@@ -1528,10 +1761,10 @@ function createOptimisticActionClient(config) {
|
|
|
1528
1761
|
onState(displayState, playerId);
|
|
1529
1762
|
}
|
|
1530
1763
|
};
|
|
1531
|
-
}
|
|
1764
|
+
};
|
|
1532
1765
|
//#endregion
|
|
1533
1766
|
//#region src/run/server-action.ts
|
|
1534
|
-
|
|
1767
|
+
const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, seatKind) => {
|
|
1535
1768
|
const toWire = (p) => ({
|
|
1536
1769
|
id: p.id,
|
|
1537
1770
|
name: p.nickname,
|
|
@@ -1598,12 +1831,12 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1598
1831
|
client.observeGameTime(parsed.gameTime);
|
|
1599
1832
|
if (msgType === "__pause_state") {
|
|
1600
1833
|
const frozen = parsed.frozen === true;
|
|
1601
|
-
console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${frozen}`);
|
|
1834
|
+
console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${String(frozen)}`);
|
|
1602
1835
|
setPauseState(frozen, parsed.by ?? null);
|
|
1603
1836
|
return;
|
|
1604
1837
|
}
|
|
1605
1838
|
if (msgType === "__room_init") {
|
|
1606
|
-
console.log(`[SDK ServerAction] ✅ Room init myId=${parsed.myId}`);
|
|
1839
|
+
console.log(`[SDK ServerAction] ✅ Room init myId=${String(parsed.myId)}`);
|
|
1607
1840
|
return;
|
|
1608
1841
|
}
|
|
1609
1842
|
if (msgType === "__game_start") {
|
|
@@ -1671,7 +1904,7 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1671
1904
|
}
|
|
1672
1905
|
if (msgType === "__action_error") {
|
|
1673
1906
|
const errorSeq = parsed.seq;
|
|
1674
|
-
console.warn(`[SDK ServerAction] ⚠️ Action error: ${parsed.error} (seq=${errorSeq})`);
|
|
1907
|
+
console.warn(`[SDK ServerAction] ⚠️ Action error: ${String(parsed.error)} (seq=${errorSeq ?? "-"})`);
|
|
1675
1908
|
if (errorSeq !== void 0) client.rollback(errorSeq);
|
|
1676
1909
|
return;
|
|
1677
1910
|
}
|
|
@@ -1699,244 +1932,7 @@ function runOnlineServerAction(config, gameEndpoint, roomId, seatId, players, se
|
|
|
1699
1932
|
console.error(`[SDK ServerAction] ❌ message 処理で例外 type=${String(msg.type)}`, err);
|
|
1700
1933
|
}
|
|
1701
1934
|
});
|
|
1702
|
-
}
|
|
1703
|
-
//#endregion
|
|
1704
|
-
//#region src/run/local-server-action.ts
|
|
1705
|
-
function runLocalServerAction(config) {
|
|
1706
|
-
const { logic, inputs, events } = config;
|
|
1707
|
-
const onState = (next, id) => {
|
|
1708
|
-
observeGameTime(gameTime());
|
|
1709
|
-
callScenario("描画 (onState)", () => config.onState(next, id, "player"));
|
|
1710
|
-
};
|
|
1711
|
-
const tickRate = logic.tickRate ?? 0;
|
|
1712
|
-
const clock = new GameClock();
|
|
1713
|
-
const gameTime = () => clock.now();
|
|
1714
|
-
const frozen = () => clock.frozen;
|
|
1715
|
-
/** ハンドラへ渡す ctx の時刻部分。1 回の呼び出し内で値が動かないよう束ねる。 */
|
|
1716
|
-
const timeCtx = (t) => ({
|
|
1717
|
-
time: t,
|
|
1718
|
-
after: (d) => plus(t, d)
|
|
1719
|
-
});
|
|
1720
|
-
/** at() が壊れた値を返したと既に記録した締切。ログを 1 回に絞るため。 */
|
|
1721
|
-
const warnedDeadlines = /* @__PURE__ */ new Set();
|
|
1722
|
-
const random = new SeededRandomImpl(Math.floor(Math.random() * 4294967295));
|
|
1723
|
-
const players = Array.from({ length: config.playerCount }, (_, i) => ({
|
|
1724
|
-
id: `local_${i}`,
|
|
1725
|
-
nickname: `Player ${i + 1}`,
|
|
1726
|
-
iconUrl: DEFAULT_ICON_URLS[i % DEFAULT_ICON_URLS.length]
|
|
1727
|
-
}));
|
|
1728
|
-
const myId = players[0].id;
|
|
1729
|
-
let wakeupTimer = null;
|
|
1730
|
-
let wakeupAt = null;
|
|
1731
|
-
const nextDeadline = () => {
|
|
1732
|
-
if (!logic.deadlines) return null;
|
|
1733
|
-
let earliest = null;
|
|
1734
|
-
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1735
|
-
let raw;
|
|
1736
|
-
try {
|
|
1737
|
-
raw = deadline.at({ state });
|
|
1738
|
-
} catch (err) {
|
|
1739
|
-
console.error(`[Deadline] ❌ ${key}.at() で例外`, err);
|
|
1740
|
-
continue;
|
|
1741
|
-
}
|
|
1742
|
-
const at = resolveDeadline(raw, key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1743
|
-
if (at === null) continue;
|
|
1744
|
-
if (earliest === null || at < earliest) earliest = at;
|
|
1745
|
-
}
|
|
1746
|
-
return earliest;
|
|
1747
|
-
};
|
|
1748
|
-
const fireDue = () => {
|
|
1749
|
-
wakeupTimer = null;
|
|
1750
|
-
wakeupAt = null;
|
|
1751
|
-
if (!logic.deadlines || frozen()) return;
|
|
1752
|
-
const time = gameTime();
|
|
1753
|
-
const evts = [];
|
|
1754
|
-
const firedKeys = [];
|
|
1755
|
-
for (const [key, deadline] of Object.entries(logic.deadlines)) {
|
|
1756
|
-
const pending = [];
|
|
1757
|
-
let snapshot = null;
|
|
1758
|
-
try {
|
|
1759
|
-
const at = resolveDeadline(deadline.at({ state }), key, warnedDeadlines, (m) => console.error(`[Deadline] ❌ ${m}`));
|
|
1760
|
-
if (at === null || at > time) continue;
|
|
1761
|
-
snapshot = structuredClone(state);
|
|
1762
|
-
withGameClock(time, () => deadline.handler({
|
|
1763
|
-
state,
|
|
1764
|
-
ctx: {
|
|
1765
|
-
...timeCtx(time),
|
|
1766
|
-
random,
|
|
1767
|
-
emit: (name, data) => pending.push({
|
|
1768
|
-
name,
|
|
1769
|
-
data: data ?? {}
|
|
1770
|
-
})
|
|
1771
|
-
}
|
|
1772
|
-
}));
|
|
1773
|
-
evts.push(...pending);
|
|
1774
|
-
firedKeys.push(key);
|
|
1775
|
-
} catch (err) {
|
|
1776
|
-
if (snapshot !== null) state = snapshot;
|
|
1777
|
-
console.error(`[Deadline] ❌ ${key} で例外`, err);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
syncWakeup();
|
|
1781
|
-
if (firedKeys.length === 0) return;
|
|
1782
|
-
console.log(`[Deadline] ⏰ ${firedKeys.length} 件発火: ${firedKeys.join(", ")}`);
|
|
1783
|
-
dispatchEvents(evts);
|
|
1784
|
-
onState(state, myId);
|
|
1785
|
-
};
|
|
1786
|
-
const syncWakeup = () => {
|
|
1787
|
-
const next = frozen() ? null : nextDeadline();
|
|
1788
|
-
if (next === wakeupAt) return;
|
|
1789
|
-
if (wakeupTimer) clearTimeout(wakeupTimer);
|
|
1790
|
-
wakeupTimer = null;
|
|
1791
|
-
wakeupAt = next;
|
|
1792
|
-
if (next === null) return;
|
|
1793
|
-
wakeupTimer = setTimeout(fireDue, Math.max(0, clock.toWall(next) - Date.now()));
|
|
1794
|
-
};
|
|
1795
|
-
const dispatchEvents = (evts) => {
|
|
1796
|
-
for (const e of evts) {
|
|
1797
|
-
const handler = events?.[e.name]?.handler;
|
|
1798
|
-
if (!handler) continue;
|
|
1799
|
-
callScenario(`event ハンドラ "${e.name}"`, () => handler(e.data));
|
|
1800
|
-
}
|
|
1801
|
-
};
|
|
1802
|
-
clock.restart();
|
|
1803
|
-
const setupTime = gameTime();
|
|
1804
|
-
const setupArgs = {
|
|
1805
|
-
players,
|
|
1806
|
-
ctx: {
|
|
1807
|
-
random,
|
|
1808
|
-
...timeCtx(setupTime)
|
|
1809
|
-
}
|
|
1810
|
-
};
|
|
1811
|
-
let state = withGameClock(setupTime, () => logic.setup(setupArgs));
|
|
1812
|
-
let tick = 0;
|
|
1813
|
-
const playerInputs = {};
|
|
1814
|
-
const dispatchAction = (type, payload) => {
|
|
1815
|
-
if (frozen()) {
|
|
1816
|
-
console.warn(`[SDK LocalServerAction] 緊急停止中は action を実行しません (action="${type}")`);
|
|
1817
|
-
return;
|
|
1818
|
-
}
|
|
1819
|
-
const plain = logic.actions[type];
|
|
1820
|
-
const legacyServerOnly = isServerOnlyAction(plain) ? plain : null;
|
|
1821
|
-
const server = logic.serverActions?.[type] ?? legacyServerOnly;
|
|
1822
|
-
if (!plain && !server) return;
|
|
1823
|
-
const plainEvents = [];
|
|
1824
|
-
const serverEvents = [];
|
|
1825
|
-
const plainEmit = (name, data) => plainEvents.push({
|
|
1826
|
-
name,
|
|
1827
|
-
data: data ?? {}
|
|
1828
|
-
});
|
|
1829
|
-
const serverEmit = (name, data) => serverEvents.push({
|
|
1830
|
-
name,
|
|
1831
|
-
data: data ?? {}
|
|
1832
|
-
});
|
|
1833
|
-
const time = gameTime();
|
|
1834
|
-
if (plain && !legacyServerOnly) {
|
|
1835
|
-
try {
|
|
1836
|
-
withGameClock(time, () => plain({
|
|
1837
|
-
state,
|
|
1838
|
-
payload: payload ?? {},
|
|
1839
|
-
playerId: myId,
|
|
1840
|
-
ctx: {
|
|
1841
|
-
...timeCtx(time),
|
|
1842
|
-
emit: plainEmit
|
|
1843
|
-
}
|
|
1844
|
-
}));
|
|
1845
|
-
} catch (err) {
|
|
1846
|
-
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1847
|
-
return;
|
|
1848
|
-
}
|
|
1849
|
-
dispatchEvents(plainEvents);
|
|
1850
|
-
syncWakeup();
|
|
1851
|
-
onState(state, myId);
|
|
1852
|
-
}
|
|
1853
|
-
if (!server) return;
|
|
1854
|
-
(async () => {
|
|
1855
|
-
try {
|
|
1856
|
-
await server({
|
|
1857
|
-
state,
|
|
1858
|
-
payload: payload ?? {},
|
|
1859
|
-
playerId: myId,
|
|
1860
|
-
ctx: {
|
|
1861
|
-
tick,
|
|
1862
|
-
random,
|
|
1863
|
-
...timeCtx(time),
|
|
1864
|
-
emit: serverEmit
|
|
1865
|
-
}
|
|
1866
|
-
});
|
|
1867
|
-
} catch (err) {
|
|
1868
|
-
console.warn("[SDK LocalServerAction] Action error:", err);
|
|
1869
|
-
return;
|
|
1870
|
-
}
|
|
1871
|
-
dispatchEvents(serverEvents);
|
|
1872
|
-
syncWakeup();
|
|
1873
|
-
onState(state, myId);
|
|
1874
|
-
})();
|
|
1875
|
-
};
|
|
1876
|
-
/**
|
|
1877
|
-
* ソロの停止 / 解除。サーバーが居ないので自分の時計を直接触る。
|
|
1878
|
-
*
|
|
1879
|
-
* `__uzu_dev.pauseTick()` とは別物。あちらは tick を止めるだけの開発者ツールで、
|
|
1880
|
-
* ゲーム内時計も action ゲートも持たない。流用しない。
|
|
1881
|
-
*/
|
|
1882
|
-
const applyPause = (paused) => {
|
|
1883
|
-
if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
|
|
1884
|
-
observeGameTime(gameTime());
|
|
1885
|
-
setPauseState(paused, paused ? myId : null);
|
|
1886
|
-
observeGameTime(gameTime());
|
|
1887
|
-
syncWakeup();
|
|
1888
|
-
};
|
|
1889
|
-
setPauseRequester(applyPause);
|
|
1890
|
-
inputs(dispatchAction);
|
|
1891
|
-
syncWakeup();
|
|
1892
|
-
onState(state, myId);
|
|
1893
|
-
if (tickRate > 0) setInterval(() => {
|
|
1894
|
-
if (frozen()) return;
|
|
1895
|
-
const tickEvents = [];
|
|
1896
|
-
const tickEmit = (name, data) => tickEvents.push({
|
|
1897
|
-
name,
|
|
1898
|
-
data: data ?? {}
|
|
1899
|
-
});
|
|
1900
|
-
const time = gameTime();
|
|
1901
|
-
try {
|
|
1902
|
-
withGameClock(time, () => logic.update({
|
|
1903
|
-
state,
|
|
1904
|
-
ctx: {
|
|
1905
|
-
random,
|
|
1906
|
-
tick,
|
|
1907
|
-
...timeCtx(time),
|
|
1908
|
-
emit: tickEmit,
|
|
1909
|
-
playerInputs
|
|
1910
|
-
}
|
|
1911
|
-
}));
|
|
1912
|
-
} catch (err) {
|
|
1913
|
-
console.error(`[SDK LocalServerAction] tick error at tick=${tick}:`, err);
|
|
1914
|
-
tick++;
|
|
1915
|
-
return;
|
|
1916
|
-
}
|
|
1917
|
-
tick++;
|
|
1918
|
-
dispatchEvents(tickEvents);
|
|
1919
|
-
syncWakeup();
|
|
1920
|
-
onState(state, myId);
|
|
1921
|
-
}, 1e3 / tickRate);
|
|
1922
|
-
return {
|
|
1923
|
-
getRawState: () => state,
|
|
1924
|
-
setRawState: async (next) => {
|
|
1925
|
-
state = next;
|
|
1926
|
-
syncWakeup();
|
|
1927
|
-
onState(state, myId);
|
|
1928
|
-
},
|
|
1929
|
-
mergeRawState: async (patch) => {
|
|
1930
|
-
applyJsonMergePatch(state, patch);
|
|
1931
|
-
onState(state, myId);
|
|
1932
|
-
},
|
|
1933
|
-
patchRawState: async (ops) => {
|
|
1934
|
-
applyJsonPatch(state, ops);
|
|
1935
|
-
onState(state, myId);
|
|
1936
|
-
},
|
|
1937
|
-
sendAction: dispatchAction
|
|
1938
|
-
};
|
|
1939
|
-
}
|
|
1935
|
+
};
|
|
1940
1936
|
//#endregion
|
|
1941
1937
|
//#region src/index.ts
|
|
1942
1938
|
const gameHandlers = /* @__PURE__ */ new Map();
|
|
@@ -1948,7 +1944,7 @@ let _usesRun = false;
|
|
|
1948
1944
|
let _playerId = null;
|
|
1949
1945
|
let _runHandle = null;
|
|
1950
1946
|
const _snapshotListeners = /* @__PURE__ */ new Set();
|
|
1951
|
-
|
|
1947
|
+
const notifyDevSnapshot = (snap) => {
|
|
1952
1948
|
_snapshotListeners.forEach((cb) => {
|
|
1953
1949
|
try {
|
|
1954
1950
|
cb(snap);
|
|
@@ -1956,10 +1952,10 @@ function notifyDevSnapshot(snap) {
|
|
|
1956
1952
|
console.warn("[uzu_dev] snapshot listener threw:", err);
|
|
1957
1953
|
}
|
|
1958
1954
|
});
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1955
|
+
};
|
|
1956
|
+
const attachDevHooksIfNotHosted = () => {
|
|
1961
1957
|
if (window.FlutterHost) return;
|
|
1962
|
-
|
|
1958
|
+
const ctx = {
|
|
1963
1959
|
getSnapshot: () => _lastStateSnap?.state ?? null,
|
|
1964
1960
|
getRawState: _runHandle?.getRawState,
|
|
1965
1961
|
setRawState: _runHandle?.setRawState,
|
|
@@ -1972,10 +1968,11 @@ function attachDevHooksIfNotHosted() {
|
|
|
1972
1968
|
_snapshotListeners.delete(cb);
|
|
1973
1969
|
};
|
|
1974
1970
|
}
|
|
1975
|
-
}
|
|
1976
|
-
|
|
1971
|
+
};
|
|
1972
|
+
attachDevHooks(ctx);
|
|
1973
|
+
};
|
|
1977
1974
|
const isHosted = typeof window !== "undefined" && (!!window.FlutterHost || window.parent !== window);
|
|
1978
|
-
|
|
1975
|
+
const init = (_opts) => {
|
|
1979
1976
|
const params = new URLSearchParams(window.location.search);
|
|
1980
1977
|
_playerId = params.get("seatId");
|
|
1981
1978
|
if (isHosted && !_playerId) console.warn("[UZU SDK] playerId is missing in the URL. Host-side sender filtering will fall back to legacy behavior.");
|
|
@@ -1993,13 +1990,10 @@ function init(opts) {
|
|
|
1993
1990
|
handleMessage(msg);
|
|
1994
1991
|
} };
|
|
1995
1992
|
window.addEventListener("message", (event) => {
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
handleMessage(data);
|
|
2001
|
-
}
|
|
2002
|
-
} catch {}
|
|
1993
|
+
const data = parseBridgeEnvelope(event.data);
|
|
1994
|
+
if (data === null) return;
|
|
1995
|
+
console.log(`[SDK] ⬅ recv postMessage channel=${data.channel} type=${data.type}`, JSON.stringify(data));
|
|
1996
|
+
handleMessage(data);
|
|
2003
1997
|
});
|
|
2004
1998
|
applyInsets(params);
|
|
2005
1999
|
onPauseChange((paused) => {
|
|
@@ -2012,31 +2006,31 @@ function init(opts) {
|
|
|
2012
2006
|
_initialized = true;
|
|
2013
2007
|
}
|
|
2014
2008
|
if (!_usesRun) attachDevHooksIfNotHosted();
|
|
2015
|
-
}
|
|
2009
|
+
};
|
|
2016
2010
|
/** game channel でカスタムメッセージを送信する。 native コマンドには使用不可。 */
|
|
2017
|
-
|
|
2011
|
+
const send = (type, payload) => {
|
|
2018
2012
|
sendRaw("game", type, payload ?? {});
|
|
2019
|
-
}
|
|
2013
|
+
};
|
|
2020
2014
|
/** game channel のカスタムメッセージを受信する。 */
|
|
2021
|
-
|
|
2015
|
+
const on = (type, handler) => {
|
|
2022
2016
|
if (!gameHandlers.has(type)) gameHandlers.set(type, []);
|
|
2023
2017
|
gameHandlers.get(type).push(handler);
|
|
2024
|
-
}
|
|
2025
|
-
|
|
2018
|
+
};
|
|
2019
|
+
const playSound = (sound) => {
|
|
2026
2020
|
sendRaw("sdk", "playSound", { sound });
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2021
|
+
};
|
|
2022
|
+
const playBgm = (sound) => {
|
|
2029
2023
|
sendRaw("sdk", "playBgm", { sound });
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2024
|
+
};
|
|
2025
|
+
const stopBgm = () => {
|
|
2032
2026
|
sendRaw("sdk", "stopBgm", {});
|
|
2033
|
-
}
|
|
2034
|
-
|
|
2027
|
+
};
|
|
2028
|
+
const setMicEnabled = (enabled) => {
|
|
2035
2029
|
sendRaw("sdk", "setMicEnabled", { enabled });
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2030
|
+
};
|
|
2031
|
+
const changeRoom = (roomId) => {
|
|
2038
2032
|
sendRaw("sdk", "changeRoom", { roomId });
|
|
2039
|
-
}
|
|
2033
|
+
};
|
|
2040
2034
|
/**
|
|
2041
2035
|
* splash 描画完了を Flutter ホストに通知する。
|
|
2042
2036
|
*
|
|
@@ -2053,9 +2047,9 @@ function changeRoom(roomId) {
|
|
|
2053
2047
|
* requestAnimationFrame(() => firstFrameReady());
|
|
2054
2048
|
* ```
|
|
2055
2049
|
*/
|
|
2056
|
-
|
|
2050
|
+
const firstFrameReady = () => {
|
|
2057
2051
|
sendRaw("sdk", "firstFrameReady", {});
|
|
2058
|
-
}
|
|
2052
|
+
};
|
|
2059
2053
|
/**
|
|
2060
2054
|
* ユーザー操作受付可能を Flutter ホストに通知する。
|
|
2061
2055
|
*
|
|
@@ -2072,13 +2066,36 @@ function firstFrameReady() {
|
|
|
2072
2066
|
* gameReady();
|
|
2073
2067
|
* ```
|
|
2074
2068
|
*/
|
|
2075
|
-
|
|
2069
|
+
const gameReady = () => {
|
|
2076
2070
|
sendRaw("sdk", "gameReady", {});
|
|
2077
|
-
}
|
|
2071
|
+
};
|
|
2072
|
+
/**
|
|
2073
|
+
* ゲームを終えてプレイ画面を閉じるよう Flutter ホストに通知する。
|
|
2074
|
+
*
|
|
2075
|
+
* 閉じるのは**呼んだ端末だけ**。全員を閉じたいときは、終了を表す state を全員へ配り、
|
|
2076
|
+
* 各端末の `onState` から呼ぶ。ホストはイベントを終了扱いにし (finishedAt を立てるのは
|
|
2077
|
+
* 最初の 1 人だけ)、呼んだプレイヤーを退出させてから画面を閉じる。
|
|
2078
|
+
*
|
|
2079
|
+
* 呼び出しタイミング: 結果や感想戦を見終えて閉じるときなど、シナリオとしてゲームが完全に
|
|
2080
|
+
* 終わった瞬間。勝敗が決まった瞬間 (`gameover` など) に呼ぶと、結果を見る前に画面が閉じる。
|
|
2081
|
+
*
|
|
2082
|
+
* 対応していない古いアプリや `uzu dev` harness では何も起きない。呼んだあとも画面を
|
|
2083
|
+
* 操作不能にしないこと。
|
|
2084
|
+
*
|
|
2085
|
+
* @example
|
|
2086
|
+
* ```ts
|
|
2087
|
+
* onState(state) {
|
|
2088
|
+
* if (state.debriefClosed) finishGame();
|
|
2089
|
+
* }
|
|
2090
|
+
* ```
|
|
2091
|
+
*/
|
|
2092
|
+
const finishGame = () => {
|
|
2093
|
+
sendRaw("sdk", "finishGame", {});
|
|
2094
|
+
};
|
|
2078
2095
|
/** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
|
|
2079
|
-
|
|
2096
|
+
const onPlayersChanged = (handler) => {
|
|
2080
2097
|
_playersChangedHandlers.push(handler);
|
|
2081
|
-
}
|
|
2098
|
+
};
|
|
2082
2099
|
/**
|
|
2083
2100
|
* `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
|
|
2084
2101
|
* 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
|
|
@@ -2088,7 +2105,7 @@ function onPlayersChanged(handler) {
|
|
|
2088
2105
|
* `keyof (A & SA)` が `string` へ潰れ、action 名の検査が丸ごと消える。
|
|
2089
2106
|
* `serverActions` を持たない logic でもキーが残るよう空 map を既定にする。
|
|
2090
2107
|
*/
|
|
2091
|
-
|
|
2108
|
+
const run = (config) => {
|
|
2092
2109
|
_usesRun = true;
|
|
2093
2110
|
const params = new URLSearchParams(window.location.search);
|
|
2094
2111
|
if (!isHosted) return;
|
|
@@ -2114,7 +2131,7 @@ function run(config) {
|
|
|
2114
2131
|
runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
|
|
2115
2132
|
} 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.");
|
|
2116
2133
|
attachDevHooksIfNotHosted();
|
|
2117
|
-
}
|
|
2134
|
+
};
|
|
2118
2135
|
/**
|
|
2119
2136
|
* 自分の席種別を URL から取り出す。
|
|
2120
2137
|
*
|
|
@@ -2124,17 +2141,17 @@ function run(config) {
|
|
|
2124
2141
|
* この値は iframe URL 限定で、WebSocket には出さない。play-server は roster に居ない接続を
|
|
2125
2142
|
* 通すだけで、その席が admin か spectator かを知る必要が無い。
|
|
2126
2143
|
*/
|
|
2127
|
-
|
|
2144
|
+
const resolveSeatKind = (params) => {
|
|
2128
2145
|
const raw = params.get("seatKind");
|
|
2129
2146
|
return raw === "admin" || raw === "spectator" ? raw : "player";
|
|
2130
|
-
}
|
|
2147
|
+
};
|
|
2131
2148
|
/**
|
|
2132
2149
|
* 自席の ID と roster を URL から取り出す。
|
|
2133
2150
|
*
|
|
2134
2151
|
* roster (`?players=`) に自席が居ないことは異常ではない。観測者はそもそも roster に
|
|
2135
2152
|
* 載らないまま接続してくる。
|
|
2136
2153
|
*/
|
|
2137
|
-
|
|
2154
|
+
const resolveSeatParams = (params) => {
|
|
2138
2155
|
const seatId = params.get("seatId");
|
|
2139
2156
|
if (!seatId) throw new Error("[UZU SDK] seatId is required. Pass ?seatId=xxx in the URL.");
|
|
2140
2157
|
const json = params.get("players");
|
|
@@ -2148,9 +2165,9 @@ function resolveSeatParams(params) {
|
|
|
2148
2165
|
characterId: p.characterId
|
|
2149
2166
|
}))
|
|
2150
2167
|
};
|
|
2151
|
-
}
|
|
2168
|
+
};
|
|
2152
2169
|
/** 内部送信関数。 channel + type + payload のエンベロープで送信する。 */
|
|
2153
|
-
|
|
2170
|
+
const sendRaw = (channel, type, payload) => {
|
|
2154
2171
|
const msg = {
|
|
2155
2172
|
channel,
|
|
2156
2173
|
type,
|
|
@@ -2160,8 +2177,24 @@ function sendRaw(channel, type, payload) {
|
|
|
2160
2177
|
console.log(`[SDK] ➡ send channel=${channel} type=${type}`, JSON.stringify(msg));
|
|
2161
2178
|
if (window.FlutterHost) window.FlutterHost.postMessage(JSON.stringify(msg));
|
|
2162
2179
|
else if (window.parent !== window) window.parent.postMessage(JSON.stringify(msg), "*");
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2180
|
+
};
|
|
2181
|
+
/** 親 frame から届く envelope。string でも object でも来るので、形を確かめてから通す。 */
|
|
2182
|
+
const parseBridgeEnvelope = (raw) => {
|
|
2183
|
+
let value = raw;
|
|
2184
|
+
if (typeof value === "string") try {
|
|
2185
|
+
value = JSON.parse(value);
|
|
2186
|
+
} catch {
|
|
2187
|
+
return null;
|
|
2188
|
+
}
|
|
2189
|
+
if (typeof value !== "object" || value === null) return null;
|
|
2190
|
+
const envelope = value;
|
|
2191
|
+
if (typeof envelope.channel !== "string" || typeof envelope.type !== "string") return null;
|
|
2192
|
+
return {
|
|
2193
|
+
channel: envelope.channel,
|
|
2194
|
+
type: envelope.type
|
|
2195
|
+
};
|
|
2196
|
+
};
|
|
2197
|
+
const handleMessage = (msg) => {
|
|
2165
2198
|
const { channel, type, payload } = msg;
|
|
2166
2199
|
if (channel === "sdk") {
|
|
2167
2200
|
switch (type) {
|
|
@@ -2170,7 +2203,7 @@ function handleMessage(msg) {
|
|
|
2170
2203
|
sendRaw("sdk", "stateResponse", { state: _lastStateSnap });
|
|
2171
2204
|
return;
|
|
2172
2205
|
case "playersChanged": {
|
|
2173
|
-
const players = payload
|
|
2206
|
+
const players = payload.players ?? {};
|
|
2174
2207
|
for (const fn of _playersChangedHandlers) callScenario("playersChanged ハンドラ", () => fn(players));
|
|
2175
2208
|
return;
|
|
2176
2209
|
}
|
|
@@ -2183,17 +2216,14 @@ function handleMessage(msg) {
|
|
|
2183
2216
|
requestResume();
|
|
2184
2217
|
return;
|
|
2185
2218
|
case "insetsChanged":
|
|
2186
|
-
handleInsetsChanged(payload
|
|
2219
|
+
handleInsetsChanged(payload);
|
|
2187
2220
|
return;
|
|
2188
2221
|
}
|
|
2189
2222
|
return;
|
|
2190
2223
|
}
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
return;
|
|
2196
|
-
}
|
|
2197
|
-
}
|
|
2224
|
+
const h = gameHandlers.get(type) ?? [];
|
|
2225
|
+
console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
|
|
2226
|
+
for (const fn of h) callScenario(`game/${type} ハンドラ`, () => fn(payload));
|
|
2227
|
+
};
|
|
2198
2228
|
//#endregion
|
|
2199
|
-
export { DEFAULT_ICON_URLS, GAME_START, ReconnectableWebSocket, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, firstFrameReady, gameReady, gameTime, getInsets, getPredictionWarnings, init, isHosted, isPaused, isServerOnlyAction, minus, on, onInsetsChange, onPauseChange, onPlayersChanged, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sub };
|
|
2229
|
+
export { DEFAULT_ICON_URLS, GAME_START, ReconnectableWebSocket, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, finishGame, firstFrameReady, gameReady, gameTime, getInsets, getPredictionWarnings, init, isHosted, isPaused, isServerOnlyAction, minus, on, onInsetsChange, onPauseChange, onPlayersChanged, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sub };
|