お絵描き×空飛ばし機能の実装メモ
俺のブログ(ファミマには売ってない) に「お絵描きして空に飛ばす」機能を追加した話。 背景に時刻と天気に連動した空の canvas アニメーションがあるんだけど、そこにユーザーが描いた絵を雲と一緒に流せるようにした。
↑こんなの
どんな機能?
- スタートメニューから
oekaki.exeを起動するとお絵描きウィンドウが開く - ペンと消しゴムで自由に描く
- 「☁️ 空に飛ばす」ボタンを押すと、描いた絵がシュッと空に飛んでいく
- 飛ばした絵は背景の空に合流して、雲と同じレイヤーで左から右にふわふわ流れ続ける
- 何度でも飛ばせるし、過去に飛ばした絵も全部流れてる
- ページをリロードすると消える。儚い
モバイルは最大5個、PCは最大15個の落書き飛ばせるよん
落書きしてみてね
以下は込み入った話
アーキテクチャ
DrawingCanvas (お絵描き UI)
↓ canvas.toDataURL() でスナップショット
SkyDrawingsContext (React Context で dataURL 配列を共有)
↓ SkyBackground が context を読む
SkyCanvas (rAF ループで雲と同じ層にドリフト描画)要はお絵描き側と空の描画側を React Context でつないでるだけ。意外とシンプル。
Context: お絵描きデータの橋渡し
サイトにはもともと天気オーバーライド用の WeatherOverrideContext があったので、同じパターンで SkyDrawingsContext を作った。
(このノリで機能盛っていくとContextすごいことになりそうな気がする)
// app/contexts/SkyDrawings.tsx
export type SkyDrawing = {
id: string; // crypto.randomUUID()
dataURL: string; // canvas.toDataURL("image/png")
width: number;
height: number;
};
type SkyDrawingsContextType = {
drawings: SkyDrawing[];
addDrawing: (drawing: SkyDrawing) => void;
};useState で配列を持って、addDrawing で追加するだけ。永続化なし、セッション限り。
layout.tsx で SkyDrawingsProvider をアプリ全体に被せてる。
お絵描きウィンドウ
ウィンドウ登録
サイトのウィンドウシステムは windowRegistry.ts に定義を追加するだけで動く。
windowの管理を一元化しててよかった〜
{
id: "drawing-canvas",
title: "oekaki.exe",
icon: "🎨",
color: "blue",
initialOpen: false, // スタートメニューから起動
initialPosition: { x: 100, y: 120 },
}描画の仕組み
320×240px の HTML Canvas に Pointer Events で線を引く方式。
// ペンモード
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = "#232946";
ctx.lineWidth = 3;
// 消しゴムモード
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = 20;消しゴムは destination-out を使って「透明に戻す」方式。背景が白じゃなくて透明になるので、空に飛ばした時に背景が透けて見えるのがポイント。
座標変換は表示サイズと canvas の内部解像度のズレを吸収してる:
const getPos = (e: React.PointerEvent) => {
const rect = canvas.getBoundingClientRect();
return {
x: ((e.clientX - rect.left) / rect.width) * CANVAS_W,
y: ((e.clientY - rect.top) / rect.height) * CANVAS_H,
};
};モバイルでは setPointerCapture + touch-action: none で安定させてる。
飛び立ちアニメーション
これ一番雑なのでそのうち直すぞ
ボタンを押した瞬間、canvas の上にスナップショット画像を重ねて CSS アニメーションで飛ばす:
@keyframes fly-to-sky {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-100vh) scale(0.3); opacity: 0; }
}Tailwind の extend で animate-fly-to-sky として登録。800ms の ease-in で上に縮みながら消えていく。
空側: SkyCanvas へのドリフト描画
データの流れ
SkyDrawingsContext
→ SkyBackground (useSkyDrawings() で読む)
→ SkyCanvas (skyDrawings prop として渡す)
→ useEffect で HTMLImageElement に変換
→ driftingDrawingsRef に push
→ render ループで毎フレーム drawImageDriftingDrawing 型
type DriftingDrawing = {
id: string;
image: HTMLImageElement; // dataURL からデコード済み
x: number; // 現在位置
y: number; // 空の上部帯に配置
displayWidth: number; // max 120px に縮小
displayHeight: number;
speed: number; // 0.2〜0.6 px/frame
opacity: number; // 0.4〜0.7(半透明)
};dataURL → HTMLImageElement の変換は new Image() + img.onload で1回だけ。以降のフレームでは ctx.drawImage(img, ...) で高速に描画。
同期ロジック
skyDrawings prop が変わるたびに、まだ driftingDrawingsRef にない id のものだけ新規追加する:
useEffect(() => {
const currentIds = new Set(driftingDrawingsRef.current.map(d => d.id));
for (const drawing of skyDrawings) {
if (currentIds.has(drawing.id)) continue;
const img = new Image();
img.onload = () => {
// 縮小してランダム配置
driftingDrawingsRef.current.push({ ... });
};
img.src = drawing.dataURL;
}
}, [skyDrawings]);モバイルは最大5個、PCは最大15個。超えたら古いのを shift() で除去。
雲の直後、天気パーティクルの前に配置。雨や雪は絵の上に降る形になる。
ドリフト描画関数
function drawDriftingDrawings(ctx, drawings, w) {
for (const d of drawings) {
d.x += d.speed;
if (d.x > w + d.displayWidth) d.x = -d.displayWidth; // ラップアラウンド
ctx.save();
ctx.globalAlpha = d.opacity;
ctx.drawImage(d.image, d.x, d.y, d.displayWidth, d.displayHeight);
ctx.restore();
}
}雲と同じで、右端まで行ったら左端に戻ってまた流れてくる。速度と透明度はランダムなので、絵ごとに違う「浮遊感」が出る。
設計で意識したこと
描画パフォーマンス
dataURL→HTMLImageElementの変換は初回1回のみ。ctx.drawImageは GPU アクセラレーションが効くので毎フレーム呼んでも軽い- 320×240 の PNG は 10〜50KB 程度。メモリの心配なし
- 上限を設けて古いのを自動削除
消しゴム = 透明
destination-out で消すことで、飛ばした時に背景の空が透けて見える。白で塗りつぶす方式だと空に白い四角が浮くことになるので、これが正解。
セッション限り
あえて永続化しない選択。気軽に描いて飛ばす「遊び」であって、作品を残す場所じゃないという割り切り。useState だけで完結するのでコードも簡素。