テーマ
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();動作:
- Flutter WebView / iframe 環境に応じてメッセージ受信口を登録
{ 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() に渡す設定オブジェクト。
| キー | 型 | 必須 | 説明 |
|---|---|---|---|
logic | GameLogic<S> | Yes | ゲームロジック定義 |
onState | (state: S, myPlayerId: string, mySeatKind: SeatKind) => void | Yes | state 更新時のコールバック。mySeatKind は自分の席種別 |
inputs | (sendAction: (action: string, payload: any) => void) => void | Yes | 入力ハンドラ登録 |
events | Record<string, (data: any) => void> | No | ゲームイベントハンドラ |
playerCount | number | Yes | プレイヤー数 |
GameLogic<S>
ゲームロジック定義。run() で使用する。
| キー | 型 | 必須 | 説明 |
|---|---|---|---|
setup | ({ players: Seat[], ctx: SetupContext }) => S | Yes | 初期 state を生成。players は配役を受け取る参加者だけで、観測者は含まれない |
actions | Record<string, ActionHandler<S>> | Yes | クライアント先行実行とサーバーの両方で走る handler。決定的でなければならない。ctx は空 |
serverActions | Record<string, ServerActionHandler<S>> | No | サーバーでのみ走る handler。ctx に { tick, random } が入り、async も使える。actions と同名にすると同じ action の「サーバーだけで走る続き」になる |
update | (state: S, ctx: UpdateContext) => void | Yes | 毎 tick 実行 (state を直接変更) |
deadlines | Record<string, Deadline<S>> | No | state 由来の締切。at が返したゲーム内時刻にサーバーが自分で起きて handler を呼ぶ |
tickRate | number | No | 秒間 tick 数 (default: 10) |
actions と serverActions
2 つは実行モデルが違う。
actions | serverActions | |
|---|---|---|
| 実行回数 | 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=N | Dev ハーネスモード (N 画面)。詳細は はじめに を参照 |
?uzuSafeAreaInset{Top,Right,Bottom,Left}= | デバイスの safe area。ホストが配る。SafeArea と HUD 回避 を参照 |
?uzuHudInsetX= / ?uzuHudInsetY= | HUD 矩形の右下座標。ホストが配る |