スコア画像を Canvas で作って共有する
Web Share API で結果を共有する機能。画像つきで送りたいが、対応していない端末も多い。段階的に諦めていく3段構えの実装について。
ゲームの結果を共有できるようにしたい。ただテキストを送るだけでなく、スコアが載った画像も一緒に送りたい。
やってみると、ここには対応状況の壁がいくつもありました。その乗り越え方の記録です。
やりたかったこと
navigator.share() を使うと、OSの共有シートを呼び出せます。スマートフォンで「共有」ボタンを押したときに下から出てくる、あれです。
これに画像を添えたい。テキストのリンクだけより、スコアが見える画像があるほうが圧倒的に伝わるからです。
壁1:画像を作る手段がない
まず、共有用の画像をどう作るか。サーバーで生成する方法もありますが、このサイトは静的サイトとして配信していてサーバー処理がありません。
そこで、ブラウザ上で Canvas に描いて画像を作ることにしました。
export function renderGameShareCard(data: GameShareData): HTMLCanvasElement {
const canvas = document.createElement('canvas');
canvas.width = 600;
canvas.height = 380;
const ctx = canvas.getContext('2d')!;
// 背景のグラデーション、ランク、スコア、サブ情報を描いていく
return canvas;
}
600×380ピクセルのカードを、その場で描画します。画面には表示せず、メモリ上のCanvasに描くだけです。
描く内容は、ゲーム名、ランク、メインの数値、サブの数値が3つまで、そして自己ベスト更新のバッジ。最後にサイト名を入れています。
レイアウトは「カーソル」を下に進める
描画するとき、要素ごとに座標を直書きするとレイアウトの変更が地獄になります。ランクがない場合は詰める、といった条件分岐が入るとなおさらです。
そこで、cursorY という変数を下に進めながら描いていく方式にしました。
let cursorY = 90;
if (data.rank) {
ctx.fillText(data.rank.title, CARD_WIDTH / 2, cursorY);
cursorY += 40; // 描いたぶんだけ下げる
}
ctx.fillText(data.primaryStat.label, CARD_WIDTH / 2, cursorY);
cursorY += 50;
要素があれば描いてカーソルを進め、なければ何もしない。HTMLの縦積みレイアウトを手で再現しているようなものです。単純ですが、要素の増減に強くなりました。
壁2:角丸が描けない環境がある
サブ情報の背景に角丸の四角を描こうとして、ctx.roundRect() を使いました。比較的新しいAPIです。
これが古い環境では存在しません。呼ぶと例外になります。
対処は単純で、あるかどうかを確認してから使うだけです。
if (typeof ctx.roundRect === 'function') {
ctx.beginPath();
ctx.roundRect(statStartX, statBgY, statTotalWidth, 36, 6);
ctx.fill();
} else {
ctx.fillRect(statStartX, statBgY, statTotalWidth, 36);
}
角丸が使えない環境では、普通の四角を描きます。角が丸くないだけで、情報は何ひとつ欠けません。
こういう「劣化しても成立する」代替を用意しておくのは大事だと思っています。見た目が少し違うことと、動かないことの間には大きな差があります。
壁3:画像つき共有に対応していない端末がある
いちばん厄介なのがここでした。navigator.share() は使えても、ファイルを添付できるとは限りません。
そこで、3段階に諦めていく構造にしました。
1. 画像を生成して、画像つきで共有できるか確認 → できれば画像つきで共有
2. できなければ、テキストとURLだけで共有
3. 共有APIそのものがなければ、ボタンを表示しない
判定には navigator.canShare() を使います。これは「このデータを共有できるか」を事前に聞けるAPIです。
if (file && navigator.canShare?.({ title, text, url, files: [file] })) {
try {
await navigator.share({ title, text, url, files: [file] });
return;
} catch (e) {
if (e instanceof Error && e.name === 'AbortError') return;
// 失敗したらテキストのみへ
}
}
await navigator.share({ title, text, url });
canShare で確認してもなお try/catch で囲んでいるのは、確認と実行の間で失敗する可能性があるからです。確認では通ったのに実際には送れない、ということが起こりえます。
そして画像の生成自体も try/catch で囲んであります。Canvas から Blob への変換が失敗しても、テキストだけの共有には進めるようにするためです。
キャンセルはエラーではない
ここで大事な処理がひとつあります。
ユーザーが共有シートを開いて、そのまま閉じた場合。これは AbortError という例外として飛んできます。
これはエラーではありません。 ユーザーが「やめた」と判断しただけです。ここで警告を出したり、フォールバックのテキスト共有を再度呼んだりすると、閉じたはずのシートがもう一度開くという最悪の挙動になります。
if (e instanceof Error && e.name === 'AbortError') return;
この1行で、キャンセルは静かに終わります。「例外が飛んだ=異常」ではないというのは、ブラウザAPIを扱うときに何度も出会うパターンです。
二重起動を防ぐ
もうひとつ、地味ですが必要だった処理があります。
共有ボタンを連打されると、navigator.share() が複数回呼ばれます。多くの環境では2回目以降が例外になりますし、そうでなくてもシートが二重に出るのは望ましくありません。
そこで、モジュールの中にフラグを1つ持たせています。
let sharing = false;
export async function shareGameResult(data: GameShareData): Promise<void> {
if (!canShareGameResult() || sharing) return;
sharing = true;
try {
// ...共有処理...
} finally {
sharing = false;
}
}
finally で必ず戻すのがポイントです。途中でどんな例外が出ても、フラグが立ちっぱなしになって二度と共有できなくなるという事態は避けなければなりません。
ボタンを出すかどうかも分岐する
共有APIが存在しない環境(多くのデスクトップブラウザなど)では、そもそも共有ボタンを表示しません。
export function canShareGameResult(): boolean {
return typeof navigator !== 'undefined'
&& typeof navigator.share === 'function';
}
押しても何も起きないボタンを出すくらいなら、最初から出さないほうがいい。動かない機能を見せないのも、機能のうちだと考えています。
学んだこと
この実装で繰り返し出てきた考え方は、「できたら嬉しいこと」を段階的に諦めていくことでした。
画像つきで共有できたら最高。無理ならテキストだけでいい。それも無理ならボタンを出さない。どの段階で止まっても、プレイヤーから見て壊れていない。
新しいブラウザAPIを使うときは、対応していない環境で何が起きるかを先に決めておくと、あとの実装が素直になります。「対応していたら使う」ではなく、「対応していなかったらどうするか」から書き始めるほうが、結果的に速く終わりました。
実際の挙動は、計算ラッシュなどの結果画面で確かめられます(スマートフォンでどうぞ)。