@uzuhq/code-sdk 0.8.11 → 0.9.0

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/index.js CHANGED
@@ -271,6 +271,26 @@ const serverOnly = (handler) => Object.assign(handler, { __serverOnly: true });
271
271
  */
272
272
  const isServerOnlyAction = (handler) => typeof handler === "function" && "__serverOnly" in handler && handler.__serverOnly === true;
273
273
  //#endregion
274
+ //#region ../engine-core/src/presence.ts
275
+ /** `voicePlan` を書かない作品の既定。全員が全体の部屋、マイクは本人の自由。 */
276
+ const DEFAULT_VOICE_PLAN = {
277
+ room: null,
278
+ mic: "free"
279
+ };
280
+ /** WebSocket で伝言を運ぶメッセージの type。`{ type: '__platform', message: PlatformMessage }`。 */
281
+ const PLATFORM_WIRE_TYPE = "__platform";
282
+ /** 伝言の type。 */
283
+ const PLATFORM_MESSAGE_TYPE = {
284
+ /** サーバー → 端末: presence (`PresenceSnapshot`)。 */
285
+ presence: "presence",
286
+ /** 端末 → サーバー: 自分の通話の申告 (`VoiceReport`)。 */
287
+ voiceReport: "voice.report",
288
+ /** 端末 → サーバー: 緊急一時停止。 */
289
+ pauseRequest: "pause.request",
290
+ /** 端末 → サーバー: 緊急一時停止の解除。 */
291
+ pauseResume: "pause.resume"
292
+ };
293
+ //#endregion
274
294
  //#region src/server-clock.ts
275
295
  /**
276
296
  * @docs
@@ -329,65 +349,392 @@ const unfreezeGameTime = () => {
329
349
  frozenAt = null;
330
350
  };
331
351
  //#endregion
332
- //#region src/pause-state.ts
352
+ //#region src/scenario-callback.ts
333
353
  /**
334
354
  * @docs
335
- * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
355
+ * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
336
356
  *
337
- * 緊急停止の状態を SDK 全体で 1 箇所に持つ。
357
+ * SDK からシナリオのコード (描画 / イベント購読 / bridge の購読) を呼ぶときの例外境界。
358
+ * シナリオのコールバックを呼ぶ箇所は、すべてここを通す。
338
359
  *
339
- * 停止は state ではない。シナリオの state に持ち込むと「停止中は state を変えるコードが
340
- * 1 行も走らない」という不変条件が崩れるので、`GameLogic` へは渡さず読み取り専用の
341
- * 関数として出す。用途は演出の停止 (rAF ループを止める等) に限る。
360
+ * これらは state 配信やメッセージ受信のたびに同じ経路を通る。例外をそのまま上へ通すと
361
+ * 呼び出し元の state 適用 (confirmedState 更新 / ack 処理 / seq 前進) が途中で止まり、
362
+ * 以降の配信でも同じ経路で投げ続ける。WebSocket もホストアプリも生きたまま画面だけが
363
+ * 永久に更新されなくなる — 2026-09-09 の UZU TOKYO 公演で実際に起きた壊れ方がこれ。
342
364
  *
343
- * ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
344
- * 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
365
+ * ここで断ち切れば state 適用は最後まで走り、次の配信で描画がやり直される
366
+ * (onState は毎回フル state から再実行されるので冪等)。同じ理由で、購読が複数ある
367
+ * ところでは 1 件の失敗を他の購読へ波及させない。
345
368
  */
346
- let paused = false;
347
- let pausedBy = null;
348
- const listeners$1 = /* @__PURE__ */ new Set();
349
- /** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
350
- const setPauseState = (next, by) => {
351
- pausedBy = next ? by : null;
352
- if (next === paused) return;
353
- paused = next;
354
- if (paused) freezeGameTime();
355
- else unfreezeGameTime();
356
- for (const cb of listeners$1) try {
357
- cb(paused);
369
+ /**
370
+ * シナリオ側のコールバックを実行し、例外を境界で止める。
371
+ *
372
+ * 例外は握り潰さず `console.error` に出す。ホスト側のログ収集がこれを拾う。
373
+ */
374
+ const callScenario = (label, fn) => {
375
+ try {
376
+ fn();
358
377
  } catch (err) {
359
- console.warn("[uzu] onPauseChange listener threw:", err);
378
+ console.error(`[uzu] シナリオの ${label} で例外が発生しました (処理は継続します)`, err);
360
379
  }
361
380
  };
362
- /** 緊急停止中か。 */
363
- const isPaused = () => {
364
- return paused;
381
+ //#endregion
382
+ //#region src/presence.ts
383
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
384
+ const OPEN_MIC = { status: "open" };
385
+ const micOf = (value) => isRecord$2(value) && typeof value.status === "string" ? value : OPEN_MIC;
386
+ const numberOr = (value, fallback) => typeof value === "number" && Number.isFinite(value) ? value : fallback;
387
+ const roomOf = (value) => typeof value === "string" ? value : null;
388
+ const meConnection = (connection) => connection.status === "online" ? { status: "online" } : {
389
+ status: connection.status,
390
+ since: connection.since
391
+ };
392
+ const meVoice = (input) => {
393
+ const self = input.voice?.self;
394
+ if (!self) return {
395
+ status: "connecting",
396
+ since: input.startedAt
397
+ };
398
+ const since = self.since ?? input.startedAt;
399
+ switch (self.session) {
400
+ case "off": return {
401
+ status: "off",
402
+ reason: self.reason === "table" ? "table" : "this-device"
403
+ };
404
+ case "reconnecting": return {
405
+ status: "reconnecting",
406
+ since
407
+ };
408
+ case "failed": return {
409
+ status: "failed",
410
+ reason: self.reason === "mic-permission" ? "mic-permission" : "network",
411
+ since
412
+ };
413
+ case "connected": return {
414
+ status: "here",
415
+ room: self.room,
416
+ mic: micOf(self.mic),
417
+ speaking: self.speaking,
418
+ quality: self.quality
419
+ };
420
+ default: return {
421
+ status: "connecting",
422
+ since
423
+ };
424
+ }
365
425
  };
366
- /** 停止を要求した playerId。停止していなければ null。 */
367
- const getPausedBy = () => {
368
- return pausedBy;
426
+ /** アプリが聞こえていると言う相手。形が崩れていても既定の値で埋める。 */
427
+ const heardOf = (value) => {
428
+ const heard = isRecord$2(value) ? value : {};
429
+ return {
430
+ status: "here",
431
+ mic: micOf(heard.mic),
432
+ speaking: heard.speaking === true,
433
+ quality: heard.quality === "poor" ? "poor" : "good"
434
+ };
369
435
  };
370
- /** 停止状態の変化を購読する。 */
371
- const onPauseChange = (cb) => {
372
- listeners$1.add(cb);
436
+ const otherVoice = (seatId, seat, me, input) => {
437
+ const voice = isRecord$2(seat) && isRecord$2(seat.voice) ? seat.voice : {};
438
+ const status = typeof voice.status === "string" ? voice.status : "unknown";
439
+ if (status === "lost") return {
440
+ status: "lost",
441
+ since: numberOr(voice.since, Date.now() - input.clockOffset) + input.clockOffset
442
+ };
443
+ if (me.status === "here") {
444
+ const heard = input.voice?.heard[seatId];
445
+ if (heard !== void 0) return heardOf(heard);
446
+ const missing = input.voice?.missing[seatId];
447
+ if (missing !== void 0) return {
448
+ status: "lost",
449
+ since: numberOr(isRecord$2(missing) ? missing.since : void 0, Date.now())
450
+ };
451
+ }
452
+ switch (status) {
453
+ case "off": return { status: "off" };
454
+ case "connected": {
455
+ if (me.status !== "here") return {
456
+ status: "unknown",
457
+ reason: "me-not-connected"
458
+ };
459
+ const room = roomOf(voice.room);
460
+ if (room !== me.room) return {
461
+ status: "elsewhere",
462
+ room
463
+ };
464
+ return {
465
+ status: "here",
466
+ mic: OPEN_MIC,
467
+ speaking: false,
468
+ quality: "good"
469
+ };
470
+ }
471
+ default: return {
472
+ status: "unknown",
473
+ reason: "no-report"
474
+ };
475
+ }
373
476
  };
374
- let requester = null;
375
- const setPauseRequester = (fn) => {
376
- requester = fn;
477
+ /** 3 つの入力から、シナリオに出す値を組み立てる。 */
478
+ const buildPresence = (input) => {
479
+ const me = {
480
+ connection: meConnection(input.connection),
481
+ voice: meVoice(input)
482
+ };
483
+ const others = {};
484
+ const seats = isRecord$2(input.snapshot?.seats) ? input.snapshot.seats : {};
485
+ for (const [seatId, seat] of Object.entries(seats)) {
486
+ if (seatId === input.mySeatId) continue;
487
+ others[seatId] = { voice: otherVoice(seatId, seat, me.voice, input) };
488
+ }
489
+ const heardAll = isRecord$2(input.voice?.heard) ? input.voice.heard : {};
490
+ const speaking = Object.entries(heardAll).filter(([, heard]) => isRecord$2(heard) && heard.speaking === true).filter(([id]) => id !== input.mySeatId && !(id in seats)).map(([id, heard]) => {
491
+ const nickname = isRecord$2(heard) ? heard.nickname : void 0;
492
+ return {
493
+ id,
494
+ nickname: typeof nickname === "string" ? nickname : id
495
+ };
496
+ });
497
+ const observers = input.snapshot?.observers;
498
+ return {
499
+ me,
500
+ others,
501
+ observers: {
502
+ count: numberOr(isRecord$2(observers) ? observers.count : void 0, 0),
503
+ speaking
504
+ }
505
+ };
377
506
  };
378
- const requestPause = () => {
379
- if (!requester) {
380
- console.warn("[uzu] requestPause: サーバーへ接続していないので停止できません");
381
- return;
507
+ /** 前回との差分。席が初めて現れたこと自体は変化として扱わない。 */
508
+ const diffPresence = (mySeatId, prev, next) => {
509
+ const changes = [];
510
+ const pushVoice = (seatId, before, after) => {
511
+ if (before && before.voice.status !== after.voice.status) changes.push({
512
+ kind: "voice",
513
+ seatId,
514
+ from: before.voice.status,
515
+ to: after.voice.status
516
+ });
517
+ };
518
+ if (prev.me.connection.status !== next.me.connection.status) changes.push({
519
+ kind: "connection",
520
+ seatId: mySeatId,
521
+ from: prev.me.connection.status,
522
+ to: next.me.connection.status
523
+ });
524
+ pushVoice(mySeatId, prev.me, next.me);
525
+ for (const [seatId, other] of Object.entries(next.others)) pushVoice(seatId, prev.others[seatId], other);
526
+ if (prev.observers.count !== next.observers.count) changes.push({
527
+ kind: "observers",
528
+ from: prev.observers.count,
529
+ to: next.observers.count
530
+ });
531
+ return changes;
532
+ };
533
+ const SESSIONS = [
534
+ "off",
535
+ "connecting",
536
+ "connected",
537
+ "reconnecting",
538
+ "failed"
539
+ ];
540
+ /**
541
+ * アプリから届いた `voiceChanged` を検める。形が合わなければ null。
542
+ * 新しいアプリが項目を足しても落ちないよう、知っている項目だけを見る。
543
+ *
544
+ * 知らない通話の状態は connecting として受ける。アプリは SDK より先に新しくなるので、
545
+ * 捨てると聞こえている相手 (heard) まで止まってしまう。
546
+ */
547
+ const parseVoiceChanged = (payload) => {
548
+ const { self, heard, missing } = payload;
549
+ if (!isRecord$2(self) || typeof self.session !== "string") return null;
550
+ const session = SESSIONS.includes(self.session) ? self.session : "connecting";
551
+ const room = typeof self.room === "string" ? self.room : null;
552
+ return {
553
+ self: {
554
+ session,
555
+ ...typeof self.reason === "string" ? { reason: self.reason } : {},
556
+ ...typeof self.since === "number" ? { since: self.since } : {},
557
+ room,
558
+ mic: micOf(self.mic),
559
+ speaking: self.speaking === true,
560
+ quality: self.quality === "poor" ? "poor" : "good"
561
+ },
562
+ heard: isRecord$2(heard) ? heard : {},
563
+ missing: isRecord$2(missing) ? missing : {}
564
+ };
565
+ };
566
+ const initialInput = () => ({
567
+ mySeatId: "",
568
+ snapshot: null,
569
+ clockOffset: 0,
570
+ voice: null,
571
+ connection: {
572
+ status: "connecting",
573
+ since: Date.now()
574
+ },
575
+ startedAt: Date.now()
576
+ });
577
+ const initialState = () => ({
578
+ input: initialInput(),
579
+ fixed: null,
580
+ current: null,
581
+ pause: null,
582
+ pendingChanges: [],
583
+ flushScheduled: false,
584
+ lastNotified: ""
585
+ });
586
+ let state = initialState();
587
+ const presenceListeners = /* @__PURE__ */ new Set();
588
+ const pauseListeners = /* @__PURE__ */ new Set();
589
+ /** 発話は 1 秒に数回変わるので、描画のフレームごとにまとめて届ける。 */
590
+ const scheduleFlush = () => {
591
+ if (state.flushScheduled) return;
592
+ state.flushScheduled = true;
593
+ const run = () => {
594
+ state.flushScheduled = false;
595
+ flush();
596
+ };
597
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
598
+ else setTimeout(run, 0);
599
+ };
600
+ const flush = () => {
601
+ const presence = getPresence();
602
+ const serialized = JSON.stringify(presence);
603
+ if (serialized === state.lastNotified && state.pendingChanges.length === 0) return;
604
+ state.lastNotified = serialized;
605
+ const changes = state.pendingChanges;
606
+ state.pendingChanges = [];
607
+ for (const listener of presenceListeners) callScenario("onPresenceChange ハンドラ", () => listener(presence, changes));
608
+ };
609
+ /**
610
+ * 入力が変わったら組み立て直す。
611
+ *
612
+ * ここで例外を出すと、呼んだ側 (WebSocket の状態の処理) まで抜けて再接続の予約が消える。
613
+ * 組み立てに失敗したら直前の値を出し続ける。
614
+ */
615
+ const recompute = () => {
616
+ try {
617
+ const prev = getPresence();
618
+ const next = state.fixed ?? buildPresence(state.input);
619
+ state.current = next;
620
+ state.pendingChanges.push(...diffPresence(state.input.mySeatId, prev, next));
621
+ scheduleFlush();
622
+ } catch (err) {
623
+ console.error("[uzu] presence を組み立てられなかったので、直前の値を使い続けます", err);
382
624
  }
383
- requester(true);
384
625
  };
385
- const requestResume = () => {
386
- if (!requester) {
387
- console.warn("[uzu] requestResume: サーバーへ接続していないので解除できません");
388
- return;
626
+ const setPause = (pause) => {
627
+ if (JSON.stringify(pause) === JSON.stringify(state.pause)) return;
628
+ state.pause = pause;
629
+ for (const listener of pauseListeners) callScenario("onPauseChange ハンドラ", () => listener(pause));
630
+ };
631
+ const configurePresence = (mySeatId) => {
632
+ state.input = {
633
+ ...state.input,
634
+ mySeatId
635
+ };
636
+ recompute();
637
+ };
638
+ /** ローカル実行。サーバーが無いので、自分はつながっていて通話は無いものとして固定する。 */
639
+ const useFixedPresence = (mySeatId) => {
640
+ state.input = {
641
+ ...state.input,
642
+ mySeatId
643
+ };
644
+ state.fixed = {
645
+ me: {
646
+ connection: { status: "online" },
647
+ voice: {
648
+ status: "off",
649
+ reason: "this-device"
650
+ }
651
+ },
652
+ others: {},
653
+ observers: {
654
+ count: 0,
655
+ speaking: []
656
+ }
657
+ };
658
+ recompute();
659
+ };
660
+ /** ローカル実行の一時停止。サーバーが居ないので presence ではなく SDK 自身が決める。 */
661
+ const setLocalPause = (pause) => {
662
+ setPause(pause);
663
+ };
664
+ const applyPresenceSnapshot = (snapshot) => {
665
+ state.input = {
666
+ ...state.input,
667
+ snapshot,
668
+ clockOffset: Date.now() - snapshot.serverNow
669
+ };
670
+ const pause = snapshot.pause;
671
+ setPause(isRecord$2(pause) ? pause : null);
672
+ recompute();
673
+ };
674
+ const applyVoiceChanged = (voice) => {
675
+ state.input = {
676
+ ...state.input,
677
+ voice
678
+ };
679
+ recompute();
680
+ };
681
+ const setWireStatus = (status) => {
682
+ if (state.input.connection.status === status) return;
683
+ state.input = {
684
+ ...state.input,
685
+ connection: {
686
+ status,
687
+ since: Date.now()
688
+ }
689
+ };
690
+ recompute();
691
+ };
692
+ const EMPTY_PRESENCE = {
693
+ me: {
694
+ connection: { status: "online" },
695
+ voice: {
696
+ status: "off",
697
+ reason: "this-device"
698
+ }
699
+ },
700
+ others: {},
701
+ observers: {
702
+ count: 0,
703
+ speaking: []
704
+ }
705
+ };
706
+ /** 今の接続と通話の状態。 */
707
+ const getPresence = () => {
708
+ if (state.current === null) try {
709
+ state.current = state.fixed ?? buildPresence(state.input);
710
+ } catch (err) {
711
+ console.error("[uzu] presence を組み立てられませんでした", err);
712
+ return EMPTY_PRESENCE;
389
713
  }
390
- requester(false);
714
+ return state.current;
715
+ };
716
+ /**
717
+ * 接続と通話の状態が変わったときに呼ばれる。値全体と、変わったところの一覧を渡す。
718
+ * 発話やミュートの変化では `changes` は空で、値だけが新しくなる。
719
+ * 戻り値を呼ぶと、呼ばれなくなる。
720
+ */
721
+ const onPresenceChange = (listener) => {
722
+ presenceListeners.add(listener);
723
+ return () => {
724
+ presenceListeners.delete(listener);
725
+ };
726
+ };
727
+ /** 今止まっているか、止まっていればその理由。止まっていなければ null。 */
728
+ const getPause = () => state.pause;
729
+ /**
730
+ * 止まった・再開したときに呼ばれる。理由は今後増えることがあるので、知らない理由でも止まって
731
+ * いるものとして扱う。戻り値を呼ぶと、呼ばれなくなる。
732
+ */
733
+ const onPauseChange = (listener) => {
734
+ pauseListeners.add(listener);
735
+ return () => {
736
+ pauseListeners.delete(listener);
737
+ };
391
738
  };
392
739
  //#endregion
393
740
  //#region src/reconnectable-ws.ts
@@ -1093,7 +1440,7 @@ let hud = {
1093
1440
  };
1094
1441
  let current = null;
1095
1442
  let envProbe = null;
1096
- const listeners = /* @__PURE__ */ new Set();
1443
+ const listeners$1 = /* @__PURE__ */ new Set();
1097
1444
  const zero = () => ({
1098
1445
  top: 0,
1099
1446
  right: 0,
@@ -1176,7 +1523,7 @@ const refresh = () => {
1176
1523
  const next = compute();
1177
1524
  if (current && same(current, next)) return;
1178
1525
  current = next;
1179
- for (const cb of listeners) try {
1526
+ for (const cb of listeners$1) try {
1180
1527
  cb(next);
1181
1528
  } catch (err) {
1182
1529
  console.warn("[UZU SDK] onInsetsChange listener threw:", err);
@@ -1287,40 +1634,143 @@ const getInsets = () => {
1287
1634
  * 登録時に即時呼び出しはしない。初期値は [getInsets] で読むこと。
1288
1635
  */
1289
1636
  const onInsetsChange = (cb) => {
1290
- listeners.add(cb);
1637
+ listeners$1.add(cb);
1291
1638
  return () => {
1292
- listeners.delete(cb);
1639
+ listeners$1.delete(cb);
1293
1640
  };
1294
1641
  };
1295
1642
  //#endregion
1296
- //#region src/scenario-callback.ts
1643
+ //#region src/bridge-envelope.ts
1644
+ const isBridgeChannel = (value) => value === "sdk" || value === "game" || value === "platform";
1645
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1646
+ /**
1647
+ * 親 frame から postMessage で届く envelope を BridgeMessage に復元する。
1648
+ * string でも object でも来るので、形を確かめてから通す。
1649
+ *
1650
+ * payload を欠いた envelope は `{}` で埋める。受け手の handleMessage は
1651
+ * `payload.players` のように payload を直に読むので、ここで落とすと
1652
+ * 例外やハンドラへの `undefined` 渡しになる。
1653
+ */
1654
+ const parseBridgeEnvelope = (raw) => {
1655
+ let value = raw;
1656
+ if (typeof value === "string") try {
1657
+ value = JSON.parse(value);
1658
+ } catch {
1659
+ return null;
1660
+ }
1661
+ if (!isRecord$1(value)) return null;
1662
+ const { channel, type, payload, playerId } = value;
1663
+ if (!isBridgeChannel(channel) || typeof type !== "string") return null;
1664
+ return {
1665
+ channel,
1666
+ type,
1667
+ payload: isRecord$1(payload) ? payload : {},
1668
+ ...typeof playerId === "string" ? { playerId } : {}
1669
+ };
1670
+ };
1671
+ //#endregion
1672
+ //#region src/host-bridge.ts
1673
+ let sender = null;
1674
+ const setHostSender = (fn) => {
1675
+ sender = fn;
1676
+ };
1677
+ const sendToHost = (channel, type, payload) => {
1678
+ sender?.(channel, type, payload);
1679
+ };
1680
+ //#endregion
1681
+ //#region src/pause-state.ts
1297
1682
  /**
1298
1683
  * @docs
1299
- * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
1684
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
1300
1685
  *
1301
- * SDK からシナリオのコード (描画 / イベント購読 / bridge の購読) を呼ぶときの例外境界。
1302
- * シナリオのコールバックを呼ぶ箇所は、すべてここを通す。
1686
+ * サーバーが時計を止めているか (wire の `frozen`) を SDK 全体で 1 箇所に持つ。
1303
1687
  *
1304
- * これらは state 配信やメッセージ受信のたびに同じ経路を通る。例外をそのまま上へ通すと
1305
- * 呼び出し元の state 適用 (confirmedState 更新 / ack 処理 / seq 前進) が途中で止まり、
1306
- * 以降の配信でも同じ経路で投げ続ける。WebSocket もホストアプリも生きたまま画面だけが
1307
- * 永久に更新されなくなる — 2026-09-09 の UZU TOKYO 公演で実際に起きた壊れ方がこれ。
1688
+ * 止まった理由は見ない。理由 (緊急一時停止・自動中断) は presence の `pause` で届き、
1689
+ * シナリオには `getPause` / `onPauseChange` で出す。ここは理由を問わず時計と送信を
1690
+ * 止めるだけにしておき、理由が増えても公開済みのシナリオの時計が止まるようにする。
1308
1691
  *
1309
- * ここで断ち切れば state 適用は最後まで走り、次の配信で描画がやり直される
1310
- * (onState は毎回フル state から再実行されるので冪等)。同じ理由で、購読が複数ある
1311
- * ところでは 1 件の失敗を他の購読へ波及させない。
1692
+ * ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
1693
+ * 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
1312
1694
  */
1695
+ let paused = false;
1696
+ let pausedBy = null;
1697
+ const listeners = /* @__PURE__ */ new Set();
1698
+ /** transport から呼ぶ内部関数。停止状態が変わったときだけ通知する。 */
1699
+ const setPauseState = (next, by) => {
1700
+ pausedBy = next ? by : null;
1701
+ if (next === paused) return;
1702
+ paused = next;
1703
+ if (paused) freezeGameTime();
1704
+ else unfreezeGameTime();
1705
+ for (const cb of listeners) try {
1706
+ cb(paused);
1707
+ } catch (err) {
1708
+ console.warn("[uzu] frozen listener threw:", err);
1709
+ }
1710
+ };
1711
+ /** 時計が止まっているか。送信の先読みを止めるのに使う。 */
1712
+ const isPaused = () => {
1713
+ return paused;
1714
+ };
1715
+ /** 停止を要求した playerId。停止していなければ null。 */
1716
+ const getPausedBy = () => {
1717
+ return pausedBy;
1718
+ };
1719
+ /** 時計の停止の変化を購読する (ホストへの中継用)。 */
1720
+ const onFrozenChange = (cb) => {
1721
+ listeners.add(cb);
1722
+ };
1723
+ //#endregion
1724
+ //#region src/platform.ts
1313
1725
  /**
1314
- * シナリオ側のコールバックを実行し、例外を境界で止める。
1726
+ * @docs
1727
+ * - 接続と通話の状態 (Presence): docs/docs/uzu_code/presence.md
1728
+ * - ブリッジ仕様: docs/docs/uzu_code/bridge.md
1315
1729
  *
1316
- * 例外は握り潰さず `console.error` に出す。ホスト側のログ収集がこれを拾う。
1730
+ * アプリとサーバーのあいだの伝言 (bridge の `platform` チャネル ⇄ WebSocket の `__platform`) を運ぶ。
1731
+ *
1732
+ * 中身は見ない。通話の申告や一時停止の要求の中身は、アプリとサーバーの更新だけで変えられる
1733
+ * ようにしておく。例外は presence で、SDK も読んでシナリオの `Presence` を組み立てる。
1317
1734
  */
1318
- const callScenario = (label, fn) => {
1319
- try {
1320
- fn();
1321
- } catch (err) {
1322
- console.error(`[uzu] シナリオの ${label} で例外が発生しました (処理は継続します)`, err);
1735
+ let serverSink = null;
1736
+ /**
1737
+ * 出口ができる前に届いた伝言。種類ごとに最新の 1 通だけ持つ。
1738
+ *
1739
+ * アプリは SDK の `ready` で通話の申告を送り直すが、`ready` は `run()` (出口を作る) より前に
1740
+ * 出る。捨てると、WebView を読み込み直したときに申告がサーバーへ届かないままになる
1741
+ * (ゲームの接続は online のまま残るので、アプリの送り直しも走らない)。どの種類も「今の状態」を
1742
+ * 伝えるものなので、溜めるのは最新の 1 通で足りる。
1743
+ */
1744
+ const pending = /* @__PURE__ */ new Map();
1745
+ /**
1746
+ * サーバーへの出口を登録する。オンラインは WebSocket、ローカル実行は SDK 自身
1747
+ * (サーバーが無いので、一時停止の要求だけをその場で処理する)。
1748
+ */
1749
+ const setPlatformServerSink = (sink) => {
1750
+ serverSink = sink;
1751
+ if (!sink) return;
1752
+ const held = [...pending.values()];
1753
+ pending.clear();
1754
+ for (const message of held) sink(message);
1755
+ };
1756
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1757
+ /** アプリから届いた伝言をサーバーへ運ぶ。つながる前・切れている間は WebSocket 側が溜める。 */
1758
+ const forwardToServer = (message) => {
1759
+ if (!serverSink) {
1760
+ pending.delete(message.type);
1761
+ pending.set(message.type, message);
1762
+ return;
1323
1763
  }
1764
+ serverSink(message);
1765
+ };
1766
+ /** 組み立てに要る最低限だけを見る。足りない項目は組み立てる側が既定の値で埋める。 */
1767
+ const isPresenceSnapshot = (value) => isRecord(value) && typeof value.serverNow === "number" && isRecord(value.seats);
1768
+ /** サーバーから届いた伝言をアプリへ運ぶ。 */
1769
+ const forwardToHost = (raw) => {
1770
+ if (!isRecord(raw) || typeof raw.type !== "string") return;
1771
+ const payload = isRecord(raw.payload) ? raw.payload : {};
1772
+ sendToHost("platform", raw.type, payload);
1773
+ if (raw.type === PLATFORM_MESSAGE_TYPE.presence && isPresenceSnapshot(raw.payload)) applyPresenceSnapshot(raw.payload);
1324
1774
  };
1325
1775
  //#endregion
1326
1776
  //#region src/run/local-server-action.ts
@@ -1422,7 +1872,7 @@ const runLocalServerAction = (config) => {
1422
1872
  }
1423
1873
  };
1424
1874
  clock.restart();
1425
- const setupTime = gameTime();
1875
+ const setupTime = GAME_START;
1426
1876
  const setupArgs = {
1427
1877
  players,
1428
1878
  ctx: {
@@ -1505,10 +1955,18 @@ const runLocalServerAction = (config) => {
1505
1955
  if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
1506
1956
  observeGameTime(gameTime());
1507
1957
  setPauseState(paused, paused ? myId : null);
1958
+ setLocalPause(paused ? {
1959
+ reason: "emergency",
1960
+ by: myId
1961
+ } : null);
1508
1962
  observeGameTime(gameTime());
1509
1963
  syncWakeup();
1510
1964
  };
1511
- setPauseRequester(applyPause);
1965
+ setPlatformServerSink((message) => {
1966
+ if (message.type === PLATFORM_MESSAGE_TYPE.pauseRequest) applyPause(true);
1967
+ else if (message.type === PLATFORM_MESSAGE_TYPE.pauseResume) applyPause(false);
1968
+ });
1969
+ useFixedPresence(myId);
1512
1970
  inputs(dispatchAction);
1513
1971
  syncWakeup();
1514
1972
  onState(state, myId);
@@ -1764,23 +2222,52 @@ const createOptimisticActionClient = (config) => {
1764
2222
  };
1765
2223
  //#endregion
1766
2224
  //#region src/run/server-action.ts
1767
- const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, seatKind) => {
2225
+ /**
2226
+ * @docs
2227
+ * - play-server 仕様: docs/docs/uzu_code/play-server.md
2228
+ * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
2229
+ *
2230
+ * ServerAction モードのオンライン接続 (WebSocket トランスポート)。
2231
+ *
2232
+ * ホスト/ゲストの区別なし。全クライアントがサーバーに対して同じ立場で action を送信し、
2233
+ * サーバーが reducer を実行して state を遷移させる。
2234
+ *
2235
+ * 楽観的更新ロジック (pending actions / reapply / ack 重複排除) は
2236
+ * `optimistic-action-client.ts` に集約。本ファイルは WebSocket 固有の責務
2237
+ * (接続管理 / メッセージ振り分け / delta seq の連続性チェック / フル state 再要求) のみ持つ。
2238
+ */
2239
+ const WIRE_STATUS = {
2240
+ connecting: "connecting",
2241
+ connected: "online",
2242
+ reconnecting: "reconnecting",
2243
+ disconnected: "offline"
2244
+ };
2245
+ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, seatKind, monitor) => {
2246
+ configurePresence(seatId);
1768
2247
  const toWire = (p) => ({
1769
2248
  id: p.id,
1770
2249
  name: p.nickname,
1771
2250
  iconUrl: p.iconUrl,
1772
2251
  characterId: p.characterId
1773
2252
  });
1774
- const wsUrl = `${gameEndpoint}/${roomId}?${new URLSearchParams({
2253
+ const query = new URLSearchParams({
1775
2254
  seatId,
1776
2255
  nickname: "Player",
1777
2256
  players: JSON.stringify(players.map(toWire))
1778
- })}`;
2257
+ });
2258
+ if (monitor) query.set("monitor", "1");
2259
+ const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
1779
2260
  console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
1780
2261
  const ws = new ReconnectableWebSocket(wsUrl, {
1781
2262
  onConnectionStateChange: (state) => {
1782
2263
  console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
1783
- config.onConnectionStateChange?.(state);
2264
+ try {
2265
+ const status = WIRE_STATUS[state];
2266
+ setWireStatus(status);
2267
+ sendToHost("sdk", "connectionChanged", { status });
2268
+ } catch (err) {
2269
+ console.error("[SDK ServerAction] 接続の状態を伝えられませんでした", err);
2270
+ }
1784
2271
  },
1785
2272
  shouldBuffer: (data) => {
1786
2273
  try {
@@ -1791,9 +2278,11 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1791
2278
  }
1792
2279
  }
1793
2280
  });
1794
- setPauseRequester((paused) => {
1795
- console.log(`[SDK ServerAction] ➡ send ${paused ? "__pause" : "__resume"}`);
1796
- ws.send(JSON.stringify({ type: paused ? "__pause" : "__resume" }));
2281
+ setPlatformServerSink((message) => {
2282
+ ws.send(JSON.stringify({
2283
+ type: PLATFORM_WIRE_TYPE,
2284
+ message
2285
+ }));
1797
2286
  });
1798
2287
  /** サーバー seq (delta の連続性チェック用) */
1799
2288
  let serverSeq = 0;
@@ -1829,6 +2318,10 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1829
2318
  const msgType = parsed.type;
1830
2319
  console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
1831
2320
  client.observeGameTime(parsed.gameTime);
2321
+ if (msgType === "__platform") {
2322
+ forwardToHost(parsed.message);
2323
+ return;
2324
+ }
1832
2325
  if (msgType === "__pause_state") {
1833
2326
  const frozen = parsed.frozen === true;
1834
2327
  console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${String(frozen)}`);
@@ -1936,7 +2429,6 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1936
2429
  //#endregion
1937
2430
  //#region src/index.ts
1938
2431
  const gameHandlers = /* @__PURE__ */ new Map();
1939
- const _playersChangedHandlers = [];
1940
2432
  let _lastStateSnap = null;
1941
2433
  let _gameEndpoint = null;
1942
2434
  let _initialized = false;
@@ -1996,7 +2488,7 @@ const init = (_opts) => {
1996
2488
  handleMessage(data);
1997
2489
  });
1998
2490
  applyInsets(params);
1999
- onPauseChange((paused) => {
2491
+ onFrozenChange((paused) => {
2000
2492
  sendRaw("sdk", "pauseState", {
2001
2493
  paused,
2002
2494
  by: getPausedBy()
@@ -2025,11 +2517,23 @@ const playBgm = (sound) => {
2025
2517
  const stopBgm = () => {
2026
2518
  sendRaw("sdk", "stopBgm", {});
2027
2519
  };
2028
- const setMicEnabled = (enabled) => {
2029
- sendRaw("sdk", "setMicEnabled", { enabled });
2520
+ /**
2521
+ * 観測席 (観戦・進行管理) が聴く通話の部屋を選ぶ。null は全体の部屋。
2522
+ *
2523
+ * プレイヤーの部屋はロジックの `voicePlan` が決めるので、プレイヤー席では何も起きない。
2524
+ */
2525
+ const setListeningRoom = (room) => {
2526
+ sendRaw("sdk", "setListeningRoom", { room });
2030
2527
  };
2031
- const changeRoom = (roomId) => {
2032
- sendRaw("sdk", "changeRoom", { roomId });
2528
+ /**
2529
+ * 本人のマイクをミュートする。
2530
+ *
2531
+ * 作品からできるのはミュートだけで、外すことはできない。外すのは本人が HUD のマイクボタンで
2532
+ * 行う。勝手にマイクを開くと、本人の知らないうちに声が他の人へ届いてしまうため。
2533
+ * 場面ごとに決まったミュートは `voicePlan` の `mic` で宣言する。
2534
+ */
2535
+ const muteMic = () => {
2536
+ sendRaw("sdk", "setMicMuted", { muted: true });
2033
2537
  };
2034
2538
  const HAPTIC_FALLBACK_MS = {
2035
2539
  light: 10,
@@ -2110,10 +2614,6 @@ const gameReady = () => {
2110
2614
  const finishGame = () => {
2111
2615
  sendRaw("sdk", "finishGame", {});
2112
2616
  };
2113
- /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
2114
- const onPlayersChanged = (handler) => {
2115
- _playersChangedHandlers.push(handler);
2116
- };
2117
2617
  /**
2118
2618
  * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
2119
2619
  * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
@@ -2146,7 +2646,8 @@ const run = (config) => {
2146
2646
  else if (_gameEndpoint) {
2147
2647
  const { seatId, players } = resolveSeatParams(params);
2148
2648
  const seatKind = resolveSeatKind(params);
2149
- runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
2649
+ const monitor = params.get("monitor") === "1";
2650
+ runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind, monitor);
2150
2651
  } 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.");
2151
2652
  attachDevHooksIfNotHosted();
2152
2653
  };
@@ -2196,22 +2697,7 @@ const sendRaw = (channel, type, payload) => {
2196
2697
  if (window.FlutterHost) window.FlutterHost.postMessage(JSON.stringify(msg));
2197
2698
  else if (window.parent !== window) window.parent.postMessage(JSON.stringify(msg), "*");
2198
2699
  };
2199
- /** 親 frame から届く envelope。string でも object でも来るので、形を確かめてから通す。 */
2200
- const parseBridgeEnvelope = (raw) => {
2201
- let value = raw;
2202
- if (typeof value === "string") try {
2203
- value = JSON.parse(value);
2204
- } catch {
2205
- return null;
2206
- }
2207
- if (typeof value !== "object" || value === null) return null;
2208
- const envelope = value;
2209
- if (typeof envelope.channel !== "string" || typeof envelope.type !== "string") return null;
2210
- return {
2211
- channel: envelope.channel,
2212
- type: envelope.type
2213
- };
2214
- };
2700
+ setHostSender(sendRaw);
2215
2701
  const handleMessage = (msg) => {
2216
2702
  const { channel, type, payload } = msg;
2217
2703
  if (channel === "sdk") {
@@ -2220,28 +2706,27 @@ const handleMessage = (msg) => {
2220
2706
  console.log(`[SDK] 🔧 handleMessage sdk/getState → responding`);
2221
2707
  sendRaw("sdk", "stateResponse", { state: _lastStateSnap });
2222
2708
  return;
2223
- case "playersChanged": {
2224
- const players = payload.players ?? {};
2225
- for (const fn of _playersChangedHandlers) callScenario("playersChanged ハンドラ", () => fn(players));
2709
+ case "voiceChanged": {
2710
+ const voice = parseVoiceChanged(payload);
2711
+ if (voice) applyVoiceChanged(voice);
2226
2712
  return;
2227
2713
  }
2228
- case "requestPause":
2229
- console.log(`[SDK] ⏸ handleMessage sdk/requestPause`);
2230
- requestPause();
2231
- return;
2232
- case "requestResume":
2233
- console.log(`[SDK] ▶️ handleMessage sdk/requestResume`);
2234
- requestResume();
2235
- return;
2236
2714
  case "insetsChanged":
2237
2715
  handleInsetsChanged(payload);
2238
2716
  return;
2239
2717
  }
2240
2718
  return;
2241
2719
  }
2720
+ if (channel === "platform") {
2721
+ forwardToServer({
2722
+ type,
2723
+ payload
2724
+ });
2725
+ return;
2726
+ }
2242
2727
  const h = gameHandlers.get(type) ?? [];
2243
2728
  console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
2244
2729
  for (const fn of h) callScenario(`game/${type} ハンドラ`, () => fn(payload));
2245
2730
  };
2246
2731
  //#endregion
2247
- export { DEFAULT_ICON_URLS, GAME_START, ReconnectableWebSocket, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, changeRoom, createDevHooks, finishGame, firstFrameReady, gameReady, gameTime, getInsets, getPredictionWarnings, haptic, init, isHosted, isPaused, isServerOnlyAction, minus, on, onInsetsChange, onPauseChange, onPlayersChanged, playBgm, playSound, plus, run, send, serverOnly, setMicEnabled, stopBgm, sub };
2732
+ export { DEFAULT_ICON_URLS, DEFAULT_VOICE_PLAN, GAME_START, ReconnectableWebSocket, SeededRandomImpl, applyJsonMergePatch, applyJsonPatch, attachDevHooks, createDevHooks, finishGame, firstFrameReady, gameReady, gameTime, getInsets, getPause, getPredictionWarnings, getPresence, haptic, init, isHosted, isServerOnlyAction, minus, muteMic, on, onInsetsChange, onPauseChange, onPresenceChange, playBgm, playSound, plus, run, send, serverOnly, setListeningRoom, stopBgm, sub };