@uzuhq/code-sdk 0.8.12 → 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: []
389
704
  }
390
- requester(false);
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;
713
+ }
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,15 +1634,15 @@ 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
1643
  //#region src/bridge-envelope.ts
1297
- const isBridgeChannel = (value) => value === "sdk" || value === "game";
1298
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1644
+ const isBridgeChannel = (value) => value === "sdk" || value === "game" || value === "platform";
1645
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1299
1646
  /**
1300
1647
  * 親 frame から postMessage で届く envelope を BridgeMessage に復元する。
1301
1648
  * string でも object でも来るので、形を確かめてから通す。
@@ -1311,45 +1658,119 @@ const parseBridgeEnvelope = (raw) => {
1311
1658
  } catch {
1312
1659
  return null;
1313
1660
  }
1314
- if (!isRecord(value)) return null;
1661
+ if (!isRecord$1(value)) return null;
1315
1662
  const { channel, type, payload, playerId } = value;
1316
1663
  if (!isBridgeChannel(channel) || typeof type !== "string") return null;
1317
1664
  return {
1318
1665
  channel,
1319
1666
  type,
1320
- payload: isRecord(payload) ? payload : {},
1667
+ payload: isRecord$1(payload) ? payload : {},
1321
1668
  ...typeof playerId === "string" ? { playerId } : {}
1322
1669
  };
1323
1670
  };
1324
1671
  //#endregion
1325
- //#region src/scenario-callback.ts
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
1326
1682
  /**
1327
1683
  * @docs
1328
- * - SDK仕様: docs/docs/uzu_code/play-screen-sdk.md
1684
+ * - 緊急停止とゲーム内時計: docs/docs/uzu_code/emergency-stop.md
1329
1685
  *
1330
- * SDK からシナリオのコード (描画 / イベント購読 / bridge の購読) を呼ぶときの例外境界。
1331
- * シナリオのコールバックを呼ぶ箇所は、すべてここを通す。
1686
+ * サーバーが時計を止めているか (wire の `frozen`) を SDK 全体で 1 箇所に持つ。
1332
1687
  *
1333
- * これらは state 配信やメッセージ受信のたびに同じ経路を通る。例外をそのまま上へ通すと
1334
- * 呼び出し元の state 適用 (confirmedState 更新 / ack 処理 / seq 前進) が途中で止まり、
1335
- * 以降の配信でも同じ経路で投げ続ける。WebSocket もホストアプリも生きたまま画面だけが
1336
- * 永久に更新されなくなる — 2026-09-09 の UZU TOKYO 公演で実際に起きた壊れ方がこれ。
1688
+ * 止まった理由は見ない。理由 (緊急一時停止・自動中断) は presence の `pause` で届き、
1689
+ * シナリオには `getPause` / `onPauseChange` で出す。ここは理由を問わず時計と送信を
1690
+ * 止めるだけにしておき、理由が増えても公開済みのシナリオの時計が止まるようにする。
1337
1691
  *
1338
- * ここで断ち切れば state 適用は最後まで走り、次の配信で描画がやり直される
1339
- * (onState は毎回フル state から再実行されるので冪等)。同じ理由で、購読が複数ある
1340
- * ところでは 1 件の失敗を他の購読へ波及させない。
1692
+ * ゲーム内時計の凍結もここから駆動する。停止フラグと時計の凍結が別々に動くと、
1693
+ * 「止まっているのにカウントダウンだけ進む」がいつか必ず起きる。
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
1725
+ /**
1726
+ * @docs
1727
+ * - 接続と通話の状態 (Presence): docs/docs/uzu_code/presence.md
1728
+ * - ブリッジ仕様: docs/docs/uzu_code/bridge.md
1729
+ *
1730
+ * アプリとサーバーのあいだの伝言 (bridge の `platform` チャネル ⇄ WebSocket の `__platform`) を運ぶ。
1731
+ *
1732
+ * 中身は見ない。通話の申告や一時停止の要求の中身は、アプリとサーバーの更新だけで変えられる
1733
+ * ようにしておく。例外は presence で、SDK も読んでシナリオの `Presence` を組み立てる。
1341
1734
  */
1735
+ let serverSink = null;
1342
1736
  /**
1343
- * シナリオ側のコールバックを実行し、例外を境界で止める。
1737
+ * 出口ができる前に届いた伝言。種類ごとに最新の 1 通だけ持つ。
1344
1738
  *
1345
- * 例外は握り潰さず `console.error` に出す。ホスト側のログ収集がこれを拾う。
1739
+ * アプリは SDK の `ready` で通話の申告を送り直すが、`ready` は `run()` (出口を作る) より前に
1740
+ * 出る。捨てると、WebView を読み込み直したときに申告がサーバーへ届かないままになる
1741
+ * (ゲームの接続は online のまま残るので、アプリの送り直しも走らない)。どの種類も「今の状態」を
1742
+ * 伝えるものなので、溜めるのは最新の 1 通で足りる。
1346
1743
  */
1347
- const callScenario = (label, fn) => {
1348
- try {
1349
- fn();
1350
- } catch (err) {
1351
- console.error(`[uzu] シナリオの ${label} で例外が発生しました (処理は継続します)`, err);
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;
1352
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);
1353
1774
  };
1354
1775
  //#endregion
1355
1776
  //#region src/run/local-server-action.ts
@@ -1451,7 +1872,7 @@ const runLocalServerAction = (config) => {
1451
1872
  }
1452
1873
  };
1453
1874
  clock.restart();
1454
- const setupTime = gameTime();
1875
+ const setupTime = GAME_START;
1455
1876
  const setupArgs = {
1456
1877
  players,
1457
1878
  ctx: {
@@ -1534,10 +1955,18 @@ const runLocalServerAction = (config) => {
1534
1955
  if (!(paused ? clock.freeze("emergency-stop") : clock.unfreeze("emergency-stop"))) return;
1535
1956
  observeGameTime(gameTime());
1536
1957
  setPauseState(paused, paused ? myId : null);
1958
+ setLocalPause(paused ? {
1959
+ reason: "emergency",
1960
+ by: myId
1961
+ } : null);
1537
1962
  observeGameTime(gameTime());
1538
1963
  syncWakeup();
1539
1964
  };
1540
- 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);
1541
1970
  inputs(dispatchAction);
1542
1971
  syncWakeup();
1543
1972
  onState(state, myId);
@@ -1793,23 +2222,52 @@ const createOptimisticActionClient = (config) => {
1793
2222
  };
1794
2223
  //#endregion
1795
2224
  //#region src/run/server-action.ts
1796
- 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);
1797
2247
  const toWire = (p) => ({
1798
2248
  id: p.id,
1799
2249
  name: p.nickname,
1800
2250
  iconUrl: p.iconUrl,
1801
2251
  characterId: p.characterId
1802
2252
  });
1803
- const wsUrl = `${gameEndpoint}/${roomId}?${new URLSearchParams({
2253
+ const query = new URLSearchParams({
1804
2254
  seatId,
1805
2255
  nickname: "Player",
1806
2256
  players: JSON.stringify(players.map(toWire))
1807
- })}`;
2257
+ });
2258
+ if (monitor) query.set("monitor", "1");
2259
+ const wsUrl = `${gameEndpoint}/${roomId}?${query}`;
1808
2260
  console.log(`[SDK ServerAction] 🔗 Connecting wsUrl=${wsUrl}`);
1809
2261
  const ws = new ReconnectableWebSocket(wsUrl, {
1810
2262
  onConnectionStateChange: (state) => {
1811
2263
  console.log(`[SDK ServerAction] 📡 Connection state: ${state}`);
1812
- 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
+ }
1813
2271
  },
1814
2272
  shouldBuffer: (data) => {
1815
2273
  try {
@@ -1820,9 +2278,11 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1820
2278
  }
1821
2279
  }
1822
2280
  });
1823
- setPauseRequester((paused) => {
1824
- console.log(`[SDK ServerAction] ➡ send ${paused ? "__pause" : "__resume"}`);
1825
- ws.send(JSON.stringify({ type: paused ? "__pause" : "__resume" }));
2281
+ setPlatformServerSink((message) => {
2282
+ ws.send(JSON.stringify({
2283
+ type: PLATFORM_WIRE_TYPE,
2284
+ message
2285
+ }));
1826
2286
  });
1827
2287
  /** サーバー seq (delta の連続性チェック用) */
1828
2288
  let serverSeq = 0;
@@ -1858,6 +2318,10 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1858
2318
  const msgType = parsed.type;
1859
2319
  console.log(`[SDK ServerAction] ⬅ recv type=${msgType}`);
1860
2320
  client.observeGameTime(parsed.gameTime);
2321
+ if (msgType === "__platform") {
2322
+ forwardToHost(parsed.message);
2323
+ return;
2324
+ }
1861
2325
  if (msgType === "__pause_state") {
1862
2326
  const frozen = parsed.frozen === true;
1863
2327
  console.log(`[SDK ServerAction] ${frozen ? "⏸" : "▶️"} pause_state frozen=${String(frozen)}`);
@@ -1965,7 +2429,6 @@ const runOnlineServerAction = (config, gameEndpoint, roomId, seatId, players, se
1965
2429
  //#endregion
1966
2430
  //#region src/index.ts
1967
2431
  const gameHandlers = /* @__PURE__ */ new Map();
1968
- const _playersChangedHandlers = [];
1969
2432
  let _lastStateSnap = null;
1970
2433
  let _gameEndpoint = null;
1971
2434
  let _initialized = false;
@@ -2025,7 +2488,7 @@ const init = (_opts) => {
2025
2488
  handleMessage(data);
2026
2489
  });
2027
2490
  applyInsets(params);
2028
- onPauseChange((paused) => {
2491
+ onFrozenChange((paused) => {
2029
2492
  sendRaw("sdk", "pauseState", {
2030
2493
  paused,
2031
2494
  by: getPausedBy()
@@ -2054,11 +2517,23 @@ const playBgm = (sound) => {
2054
2517
  const stopBgm = () => {
2055
2518
  sendRaw("sdk", "stopBgm", {});
2056
2519
  };
2057
- const setMicEnabled = (enabled) => {
2058
- sendRaw("sdk", "setMicEnabled", { enabled });
2520
+ /**
2521
+ * 観測席 (観戦・進行管理) が聴く通話の部屋を選ぶ。null は全体の部屋。
2522
+ *
2523
+ * プレイヤーの部屋はロジックの `voicePlan` が決めるので、プレイヤー席では何も起きない。
2524
+ */
2525
+ const setListeningRoom = (room) => {
2526
+ sendRaw("sdk", "setListeningRoom", { room });
2059
2527
  };
2060
- const changeRoom = (roomId) => {
2061
- sendRaw("sdk", "changeRoom", { roomId });
2528
+ /**
2529
+ * 本人のマイクをミュートする。
2530
+ *
2531
+ * 作品からできるのはミュートだけで、外すことはできない。外すのは本人が HUD のマイクボタンで
2532
+ * 行う。勝手にマイクを開くと、本人の知らないうちに声が他の人へ届いてしまうため。
2533
+ * 場面ごとに決まったミュートは `voicePlan` の `mic` で宣言する。
2534
+ */
2535
+ const muteMic = () => {
2536
+ sendRaw("sdk", "setMicMuted", { muted: true });
2062
2537
  };
2063
2538
  const HAPTIC_FALLBACK_MS = {
2064
2539
  light: 10,
@@ -2139,10 +2614,6 @@ const gameReady = () => {
2139
2614
  const finishGame = () => {
2140
2615
  sendRaw("sdk", "finishGame", {});
2141
2616
  };
2142
- /** Flutter からの playersChanged メッセージを受信するハンドラを登録する。 */
2143
- const onPlayersChanged = (handler) => {
2144
- _playersChangedHandlers.push(handler);
2145
- };
2146
2617
  /**
2147
2618
  * `A` に既定値が要る。 TypeScript は型引数の部分推論ができないので、 既定が無いと
2148
2619
  * 既存の `run<State>({…})` が「2 つ必要」で落ちる。 既定を置けば型引数を書かない
@@ -2175,7 +2646,8 @@ const run = (config) => {
2175
2646
  else if (_gameEndpoint) {
2176
2647
  const { seatId, players } = resolveSeatParams(params);
2177
2648
  const seatKind = resolveSeatKind(params);
2178
- runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind);
2649
+ const monitor = params.get("monitor") === "1";
2650
+ runOnlineServerAction(wrappedConfig, _gameEndpoint, roomId, seatId, players, seatKind, monitor);
2179
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.");
2180
2652
  attachDevHooksIfNotHosted();
2181
2653
  };
@@ -2225,6 +2697,7 @@ const sendRaw = (channel, type, payload) => {
2225
2697
  if (window.FlutterHost) window.FlutterHost.postMessage(JSON.stringify(msg));
2226
2698
  else if (window.parent !== window) window.parent.postMessage(JSON.stringify(msg), "*");
2227
2699
  };
2700
+ setHostSender(sendRaw);
2228
2701
  const handleMessage = (msg) => {
2229
2702
  const { channel, type, payload } = msg;
2230
2703
  if (channel === "sdk") {
@@ -2233,28 +2706,27 @@ const handleMessage = (msg) => {
2233
2706
  console.log(`[SDK] 🔧 handleMessage sdk/getState → responding`);
2234
2707
  sendRaw("sdk", "stateResponse", { state: _lastStateSnap });
2235
2708
  return;
2236
- case "playersChanged": {
2237
- const players = payload.players ?? {};
2238
- for (const fn of _playersChangedHandlers) callScenario("playersChanged ハンドラ", () => fn(players));
2709
+ case "voiceChanged": {
2710
+ const voice = parseVoiceChanged(payload);
2711
+ if (voice) applyVoiceChanged(voice);
2239
2712
  return;
2240
2713
  }
2241
- case "requestPause":
2242
- console.log(`[SDK] ⏸ handleMessage sdk/requestPause`);
2243
- requestPause();
2244
- return;
2245
- case "requestResume":
2246
- console.log(`[SDK] ▶️ handleMessage sdk/requestResume`);
2247
- requestResume();
2248
- return;
2249
2714
  case "insetsChanged":
2250
2715
  handleInsetsChanged(payload);
2251
2716
  return;
2252
2717
  }
2253
2718
  return;
2254
2719
  }
2720
+ if (channel === "platform") {
2721
+ forwardToServer({
2722
+ type,
2723
+ payload
2724
+ });
2725
+ return;
2726
+ }
2255
2727
  const h = gameHandlers.get(type) ?? [];
2256
2728
  console.log(`[SDK] 🔧 handleMessage game/${type} handlers=${h.length}`);
2257
2729
  for (const fn of h) callScenario(`game/${type} ハンドラ`, () => fn(payload));
2258
2730
  };
2259
2731
  //#endregion
2260
- 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 };