Skip to content

API リファレンス ​

@uzuhq/code-sdk パッケージの公開 API 一覧です。

パッケージ情報 ​

項目値
パッケージ名@uzuhq/code-sdk
モジュール形式ES Modules
依存関係なし (devDependencies: typescript ^5.7.0 のみ)

関数 ​

init(): void ​

SDK を初期化し、Flutter ホスト / 親ウィンドウに準備完了を通知する。

typescript
import { init } from '@uzuhq/code-sdk';

init();

動作:

  1. Flutter WebView / iframe 環境に応じてメッセージ受信口を登録
  2. { type: '__ps_ready' } メッセージをホストへ送信

TIP

run() が内部で init() を呼ぶため、通常は直接書く必要はありません。 プレイヤー間で state を共有しないゲーム (サウンド・ボイス・SafeArea だけを使うもの) でのみ、 最初に呼び出してください。

run<S>(config: GameConfig<S>): void ​

サーバー権威のゲームループを実行する。

typescript
import { run } from '@uzuhq/code-sdk';

run({
  logic,
  onState(state, myPlayerId, mySeatKind) {
    currentState = state;
    myId = myPlayerId;
    mySeat = mySeatKind;
  },
  inputs(sendAction) {
    canvas.addEventListener('click', () => {
      sendAction('choose', { hand: 'rock' });
    });
  },
  events: {
    sound(data) {
      new Audio(`/sounds/${data.sound}.mp3`).play().catch(() => {});
    },
  },
  playerCount: 2,
});

モードの自動判定:

  • URL に ?roomId=xxx がない → ローカルモード (サーバー不要)
  • URL に ?roomId=xxx&server=xxx がある → オンラインモード
  • URL に ?__dev=N がある → Dev ハーネスモード

send(message: PlayScreenMessage): void ​

Flutter ホスト / 親ウィンドウへ任意のメッセージを送信する。

typescript
import { send } from '@uzuhq/code-sdk';

send({ type: 'playSound', sound: 'sounds/clear.mp3' });
  • Flutter WebView: window.FlutterHost.postMessage() 経由
  • iframe: window.parent.postMessage() 経由
  • ブラウザ単体: console.log にフォールバック

on(type: string, handler: MessageHandler): void ​

Flutter / ホストからのメッセージに対するハンドラを登録する。同一 type に複数登録可能。

typescript
import { on } from '@uzuhq/code-sdk';
import type { PlayersChangedMessage } from '@uzuhq/code-sdk';

on('playersChanged', (msg) => {
  const { players } = msg as unknown as PlayersChangedMessage;

  for (const [id, state] of Object.entries(players)) {
    switch (state.audioStatus) {
      case 'speaking':
        highlightAvatar(id);
        break;
      case 'listening':
        showNormalAvatar(id);
        break;
      case 'muted':
        showMuteIcon(id);
        break;
      case 'unstable':
        showUnstableBadge(id);
        break;
      case null:
        showNoVoice(id);
        break;
    }
  }
});

firstFrameReady(): void ​

scenario が最初の絵 (splash / loading 画面) を画面に描画したことを Flutter ホストに通知する。Flutter は受信時に splash overlay を非表示にし WebView を可視化する。

呼び出しタイミング: scenario が DOM / canvas に最初の絵を描いた直後。paint commit を保証するため requestAnimationFrame を 1 段挟むのが定石。

呼ばないと splash overlay が出っぱなしになるので、Flutter ホスト環境では必ず呼ぶこと。

typescript
import { init, firstFrameReady } from '@uzuhq/code-sdk';

init();
drawSplash();
requestAnimationFrame(() => firstFrameReady());

gameReady(): void ​

scenario が操作受付可能になったことを Flutter ホストに通知する。Flutter は受信時に入力受付を開始し、TTI (Time To Interactive) 計測を終了する。将来の広告タイマー / leaderboard / analytics の発火点としても使われる予定。

呼び出しタイミング: asset load 完了、ゲーム本体の setup 完了、入力受付可能になった直後。

typescript
import { gameReady } from '@uzuhq/code-sdk';

await loadAssets();
setupGame();
gameReady();

YouTube Playables SDK の firstFrameReady() / gameReady() と思想的に整合する。

finishGame(): void ​

ゲームを終えて、この端末のプレイ画面を閉じるよう Flutter ホストに通知する。ホストはイベントを終了扱いにし、呼んだプレイヤーを退出させてから画面を閉じる。

閉じるのは呼んだ端末だけ。全員を閉じたいときは、終了を表す state を全員へ配り、各端末の onState から呼ぶ。

呼び出しタイミング: 結果や感想戦を見終えて閉じるときなど、シナリオとしてゲームが完全に終わった瞬間。勝敗が決まった瞬間 (gameover など) に呼ぶと、結果を見る前に画面が閉じる。途中退出の導線にも使わない。途中退出は UZU メニューの「退出」が担い、ゲーム内の exit / quit ボタンは認証要件で禁止されている。

state の再描画のたびに呼んでも、ホストは処理中の 2 回目以降を無視する。対応していない古いアプリや uzu dev では何も起きないので、呼んだあとも画面を操作不能にしないこと。

typescript
import { run, finishGame } from '@uzuhq/code-sdk';

run({
  // ...
  onState(state) {
    if (state.debriefClosed) finishGame();
    render(state);
  },
});

playSound(sound: string): void ​

効果音の再生リクエストを送信する。

typescript
import { playSound } from '@uzuhq/code-sdk';

playSound('sounds/clear.mp3');

playBgm(sound: string): void / stopBgm(): void ​

BGM の再生・停止を制御する。

typescript
import { playBgm, stopBgm } from '@uzuhq/code-sdk';

playBgm('bgm/main.mp3'); // ループ再生
stopBgm(); // 停止

鳴り方 ​

音はアプリが鳴らす(通話と音声出力を取り合わないため)。uzu dev とエミュレータも同じ鳴り方にそろえてある。

  • 同時に鳴る BGM は 1 本。playBgm は鳴っている BGM を止めて、新しい曲を頭から鳴らす。クロスフェードはしない
  • 別々の効果音は重ねて鳴る
  • 音量はプレイヤーが端末で決める(初期値は BGM・効果音とも 60%)。シナリオからは指定できない。曲ごとの音量差は音源ファイル側でそろえる
  • sound の相対パスはシナリオの URL を基準に解決する

<audio> などで自前で鳴らさないこと。iOS・Android とも、WebView の音声は通話と音声出力を取り合うおそれがある。

haptic(kind: HapticKind): void ​

端末を短く振動させる。kind は 'light' / 'medium' / 'heavy' の 3 段階。

typescript
import { haptic } from '@uzuhq/code-sdk';

haptic('light'); // 手がかりを入手したとき
haptic('heavy'); // 画面が揺れる演出に合わせて
  • アプリ内では navigator.vibrate が効かない(iOS には Web から振動させる手段が無い)ので、navigator.vibrate を直接呼ばずにこれを使う
  • 長さは指定できない。iOS の触覚が種類でしか選べないため、段階で渡す
  • 端末の設定で触覚を切っている人には効かない
  • ブラウザで単独で開いたときは navigator.vibrate にフォールバックする

getInsets(): UzuInsets ​

ホストが配る inset を数値で読む。canvas / WebGL のように CSS で避けられない描画向け。

typescript
import { getInsets } from '@uzuhq/code-sdk';

const { safeArea, content, hud } = getInsets();
layout(content);

値は初回ペイント前に確定しているので、setup の中で読んでよい。 CSS から使う場合は変数(--uzu-content-inset-* 等)を直接読む。 詳細は SafeArea と HUD 回避。

onInsetsChange(cb: (insets: UzuInsets) => void): () => void ​

回転 / PiP で inset が変わったときに呼ばれる。戻り値を呼ぶと解除できる。

typescript
import { onInsetsChange } from '@uzuhq/code-sdk';

const off = onInsetsChange((insets) => layout(insets.content));

WARNING

CSS 変数の変化は resize イベントを発火させない。window.addEventListener('resize', …) だけを見ている実装は追従できない。

登録時の即時呼び出しはしない。初期値は getInsets() で読む。

isHosted ​

boolean (読み取り専用)。Flutter WebView または iframe 内で動作しているかを示す。

  • window.FlutterHost が存在 → true (Flutter WebView)
  • window.parent !== window → true (iframe)
  • それ以外 → false

型定義 ​

GameConfig<S> ​

run() に渡す設定オブジェクト。

キー型必須説明
logicGameLogic<S>Yesゲームロジック定義
onState(state: S, myPlayerId: string, mySeatKind: SeatKind) => voidYesstate 更新時のコールバック。mySeatKind は自分の席種別
inputs(sendAction: (action: string, payload: any) => void) => voidYes入力ハンドラ登録
eventsRecord<string, (data: any) => void>Noゲームイベントハンドラ
playerCountnumberYesプレイヤー数

GameLogic<S> ​

ゲームロジック定義。run() で使用する。

キー型必須説明
setup({ players: Seat[], ctx: SetupContext }) => SYes初期 state を生成。players は配役を受け取る参加者だけで、観測者は含まれない
actionsRecord<string, ActionHandler<S>>Yesクライアント先行実行とサーバーの両方で走る handler。決定的でなければならない。ctx は空
serverActionsRecord<string, ServerActionHandler<S>>Noサーバーでのみ走る handler。ctx に { tick, random } が入り、async も使える。actions と同名にすると同じ action の「サーバーだけで走る続き」になる
update(state: S, ctx: UpdateContext) => voidYes毎 tick 実行 (state を直接変更)
deadlinesRecord<string, Deadline<S>>Nostate 由来の締切。at が返したゲーム内時刻にサーバーが自分で起きて handler を呼ぶ
tickRatenumberNo秒間 tick 数 (default: 10)

actions と serverActions ​

2 つは実行モデルが違う。

actionsserverActions
実行回数2 回 (クライアント先読み + サーバー)1 回 (サーバーのみ)
決定性必須不要
async不可可
ctx{ time, after, emit }左記 + { tick, random }

同名のキーを両方に置くと、1 つの action を「即座に反映していい部分」と「サーバーが決める部分」に分けられる。 サーバーでは actions → serverActions の順に走る。

ts
actions: {
  // 先読みされる = 駒は即座に動く
  move: ({ state, payload, playerId }) => {
    movePiece(state, payload);
    state.currentTurn = nextPlayer(state, playerId);
  },
},
serverActions: {
  // サーバーだけ = 持ち時間の減算 (tick に依存する)
  move: ({ state, playerId, ctx }) => {
    state.timeRemaining[playerId] -= (ctx.tick - state.turnStartTick) * 1000;
    state.turnStartTick = ctx.tick;
  },
  // actions に無い名前だけを置けば「先読みしない action」になる
  notifyExternal: async ({ state, payload, playerId }) => {
    await fetch("https://example.com/notify", { ... });
    state.notifiedAt = Date.now();
  },
},

なぜ actions の ctx に tick / 乱数が無いのか

actions はサーバーとクライアント先行実行の両方で走る。クライアントが自力で再現できない値 (tick / 乱数) をここで読むと、サーバーとソロモードでは本物が入るのにオンラインの 先行実行だけ値がズレる。そういう処理は serverActions 側へ分ける。 時刻は例外で、ctx.time がクロックオフセットで補正した推定値を配る (Date.now() は読まない)。 dev では先読み中の実時刻 / 乱数呼び出しが自動検出される。

ゲーム内時計 ​

時刻は Unix epoch ではない。 ctx.time はゲーム開始からの経過 ms で、 緊急一時停止の間は進まない。Date.now() とは桁も意味も違うので混ぜられない。

typescript
/** ゲーム内時刻。ゲーム開始からの ms。停止中は進まない。 */
type GameTime = (number & Tag) | Tag;
/** 長さ (ms)。 */
type Duration = number;

plus(t: GameTime, d: Duration): GameTime   // 時刻に長さを足す
sub(t: GameTime, d: Duration): GameTime    // 時刻から長さを引く
minus(a: GameTime, b: GameTime): Duration  // 2 つの時刻の差
gameTime(): GameTime                       // 描画側で読む現在時刻 (推定値)

GameTime は number として扱えない。算術も比較も number への代入も型で止まるので、 Date.now() と取り違えた式はコンパイルが通らない。

場所入るもの
setup の ctx.time必ず 0 (時計の起点)
actions / serverActions / update / deadlines の ctx.timeそのときのゲーム内時刻
ctx.after(d)今から d ms 後のゲーム内時刻
Deadline.at() の戻り値`GameTime

締切を state に置くときは ctx.after() を使う。

typescript
actions: {
  startPhase: ({ state, ctx }) => {
    state.phaseEndsAt = ctx.after(5 * 60_000); // 5 分後
  },
},
deadlines: {
  phase: {
    at: ({ state }) => state.phaseEndsAt,
    handler: ({ state }) => { state.phase = 'next'; },
  },
},

描画側で残り時間を出すときは gameTime() から引く。

typescript
import { gameTime, minus } from '@uzuhq/code-sdk';

const remain = Math.ceil(minus(state.phaseEndsAt, gameTime()) / 1000);

Date.now() を混ぜない

GameTime は number と混ぜられない。書き込みも読みも型で止まる。

typescript
state.endsAt = Date.now() + 60_000; // ❌
const remain = state.endsAt - Date.now(); // ❌ 実時刻との引き算
const ms: number = state.endsAt; // ❌ number への代入

gameTime() の基準は performance.now() (単調時計) なので、端末の時計を手で動かしても カウントダウンは飛ばない。

緊急一時停止 ​

プレイヤーが UZU メニューから全員のタイマーを止められる。シナリオは停止を知らないし、 知る必要も無い。 停止中はシナリオの state を変えるコードが 1 行も走らない (update() は呼ばれず、action はサーバーが弾き、締切は期限が来ない)。

演出だけを止めたいとき (rAF ループを回しっぱなしにしたくない等) に限り、読み取り専用で参照できる。

typescript
import { isPaused, onPauseChange } from '@uzuhq/code-sdk';

onPauseChange((paused) => {
  if (paused) engine.stop();
  else engine.start();
});

停止を state に持ち込まない

GameLogic からは触れない。停止中に state を書けば「停止中は state が変わらない」という 前提が崩れ、巻き戻しや再接続で辻褄が合わなくなる。

serverOnly(handler) ​

非推奨

serverActions フィールドに置き換わった。actions の型からユニオンが外れたため、 serverOnly() で wrap した handler を actions に入れることはできない。

ts
// before
actions: { notifyExternal: serverOnly(async (state) => { ... }) }
// after
serverActions: { notifyExternal: async (state) => { ... } }

Seat ​

roster に載る席。roster は配役を受け取る参加者だけで構成される。席種別は roster エントリではなく、自分の SeatKind として onState に渡る。

typescript
interface Seat {
  id: string;
  nickname: string;
  iconUrl: string;
  /** ホスト(mobile / emulator)から渡される、選択済みキャラクターの ID。未選択時は undefined。 */
  characterId?: string;
}

SeatKind ​

自分の席種別。ホストが iframe URL の ?seatKind= で伝え、SDK が onState の第 3 引数として渡す。

typescript
type SeatKind = 'player' | 'spectator' | 'admin';

観戦席・進行管理席は roster に載らないまま接続してくる。setup() の players にも現れないので「player か観測者か」は state.players の空振りで分かるが、spectator と admin の区別は state から導けない。そこをこの値で分ける。

typescript
onState(state, myPlayerId, mySeatKind) {
  const me = state.players.find((p) => p.playerId === myPlayerId);
  if (me) return playerView(me);
  // roster に居ない = 観測者
  return mySeatKind === 'admin' ? gmView() : spectatorView();
}

権限の根拠には使えない

seatKind は自己申告なので、表示の分岐にだけ使える。渡るのは自分の席種別だけで、他プレイヤーの席種別は client にもサーバー側 handler (ActionArgs) にも渡らない。他人の自己申告を handler で見ると、権限チェックに見えて何も守らない分岐がコードに残る。

seatId の命名規約に頼らない

admin_0 / spec_0 のような seatId を見て判定すると、命名が変わった瞬間に静かに壊れる。席種別は必ず seatKind から取る。

PlayersChangedMessage ​

Flutter から送信されるプレイヤー状態更新メッセージ。on('playersChanged', handler) で受信する。

typescript
interface PlayersChangedMessage {
  players: Record<string, PlayerVoiceState>;
}

PlayerVoiceState ​

各プレイヤーのリアルタイム状態。

typescript
interface PlayerVoiceState {
  audioStatus: 'speaking' | 'listening' | 'muted' | 'unstable' | null;
}
フィールド説明
audioStatus音声状態。音声通話に未接続の場合は null

audioStatus の値:

値説明典型的な UI
speaking発話中アバターをハイライト、波形表示
listening音声接続済み・聞いている通常表示
muted音声接続済み・ミュート中ミュートアイコン
unstable音声接続に問題がある警告バッジ
null音声通話に未接続未接続アイコン

Operation ​

JSON Patch 操作。

typescript
interface Operation {
  op: 'replace' | 'add' | 'remove';
  path: string; // JSON Pointer パス (例: '/players/alice/score')
  value?: unknown; // 値 ('remove' の場合は不要)
}

ConnectionState ​

接続状態を表す文字列リテラル型。

typescript
type ConnectionState = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';

ConnectionCallbacks ​

接続イベントのコールバック。

typescript
interface ConnectionCallbacks {
  onConnectionStateChange?: (state: ConnectionState) => void;
}

Insets / UzuInsets ​

getInsets() / onInsetsChange() が返す inset (単位は CSS px)。

typescript
interface Insets {
  top: number;
  right: number;
  bottom: number;
  left: number;
}

interface UzuInsets {
  /** デバイスの safe area (ノッチ / 角丸 / ホームインジケータ) */
  safeArea: Insets;
  /** safeArea と HUD を合成済み。迷ったらこれを使う */
  content: Insets;
  /** HUD 矩形の右下座標 (safe area 内側の左上が原点) */
  hud: { x: number; y: number };
}

SeededRandom ​

seed 付き乱数生成器。GameLogic の setup や update の引数で提供される。

メソッド説明
float()0.0〜1.0 の浮動小数点
int(max)0〜max-1 の整数
pick(array)配列からランダムに 1 つ選択
shuffle(array)配列をシャッフル (新しい配列を返す)

URL パラメータ ​

init() / run() は以下の URL パラメータを読み取る。

パラメータ説明
?server=WebSocket サーバーの URL (uzu dev 時は ws://localhost:<port>。本番ではホストが自動注入する)
?roomId=ルーム ID。指定するとオンラインモードになる
?seatId=自分の席 ID
?players=JSON エンコードされた roster (配役を受け取る参加者のみ)
?seatKind=自分の席種別 (player / spectator / admin)。省略時は player。iframe URL 限定で WebSocket には出さない
?__dev=NDev ハーネスモード (N 画面)。詳細は はじめに を参照
?uzuSafeAreaInset{Top,Right,Bottom,Left}=デバイスの safe area。ホストが配る。SafeArea と HUD 回避 を参照
?uzuHudInsetX= / ?uzuHudInsetY=HUD 矩形の右下座標。ホストが配る