コース進捗 コース目次 18レッスン中 18件を公開中
第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標
第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール
第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧
第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI
まずは5歳児にもわかるゲームから
この章で覚える真実は一つだけです。プロダクトのツールはブラウザの生イベントを直接受け取るべきではなく、正規化された入力イベントを受け取るべきです。
おもちゃのマウス、クレヨン、そして指を用意します。テーブルに赤いボタンを置き、その隣に「通訳」を座らせましょう。ルールは一つです。子どもが何を使ってボタンに触れても、通訳は「何番が、どこで、どれくらい強く、始めたのか終えたのか」だけを伝えます。
先に予想してみてください。子どもがクレヨンを押し付けたままテーブルの外まで動かし、そこで離したとき、ボタンは操作が「終わった」とわかるでしょうか。2本目の指が突然触れたら、どちらがどちらだと見分けるのでしょうか。通訳が「マウスが動いた」としか言えなければ、指やペンも参加できるでしょうか。
- 違うお客さま触る、押す、滑らせる
- 一人の通訳番号、位置、筆圧
- 同じ形式のメモ開始、移動、終了、キャンセル
- ツールはメモだけを読む誰が来たかを推測しない
実際の操作に置き換えると、pointerId は番号札です。Pointer Capture は通訳がその番号札を一時的に預かること、pointercancel は先生がゲームの中断を告げること、lostpointercapture は番号札が本当に回収されたことに相当します。ツールが受け取るメモは常に同じ形式なので、Select Tool がマウス、指、ペンを別々に学ぶ必要はありません。
おもちゃを Canvas に置き換える
| おもちゃの世界 | Canvas Lab | 保証されること |
|---|---|---|
| マウス、指、クレヨン | pointerType の値 mouse、touch、pen | 入力元が違ってもプロトコルは同じ |
| 番号札 | pointerId | 複数の指を同時に追跡できる |
| ボタンを押し続ける | buttons、pressure | 操作状態と筆圧を記録できる |
| クレヨンを傾ける | tiltX/tiltY、altitudeAngle/azimuthAngle | ブラシの姿勢を扱い、未対応ならフォールバックできる |
| 通訳が番号札を預かる | setPointerCapture(pointerId) | ポインターが要素の外へ出ても後続イベントを受け取る |
| 先生がゲームを止める | pointercancel、フォーカス喪失 | 途中の操作をコミットと取り違えない |
| 細かなメモの束 | Coalesced Events | ブラウザが統合する前の自由描画サンプルを保持できる |
| 次のメモを予想する | Predicted Events | 一時プレビューだけに使い、Document には書き込まない |
| ジェスチャーのルール札 | touch-action | どの直接操作をページに任せ、どれをアプリが受け持つかブラウザに伝える |
この比喩には限界があります。番号札はユーザーの本人確認ではなく、Capture も「物理マウスを固定する」ものではありません。システムジェスチャー、ウィンドウ切り替え、デバイス切断、要素の削除によってブラウザが操作をキャンセルすることはあります。予測イベントも未来の事実ではありません。実サンプルが届いたら予測線を置き換える必要があります。キーボードも二次元ポインターではなく、フォーカス、キーリピート、修飾キーに独立したライフサイクルがあります。
まず誤った直感を捨てる
- 「mouse、touch、pen の3系統を別々に監視するのが一番安全だ。」 3系統では互換マウスイベント、重複コミット、3組の境界条件が生まれます。Pointer Events はもともとハードウェアに依存しない統一入口です。
- 「
pointerupだけを監視すれば操作を終えられる。」 システムスクロール、着信、デバイス切り替えでpointercancelが発生します。Capture の喪失もロールバック対象です。 - 「
clientX/clientYはワールド座標だ。」 これはビューポート上の CSS ピクセルにすぎません。要素の境界を差し引き、前章の Camera 行列の逆変換を適用する必要があります。 - 「
wheel.deltaYはいつもピクセル単位だ。」deltaModeはピクセル、行、ページのいずれかを表します。トラックパッドは慣性を伴う小さな delta を大量に送ります。 - 「予測点のほうが滑らかだから、そのまま保存しよう。」 予測値はあとで修正されます。保存するとリプレイ結果がブラウザの推測に依存します。
- 「ドラッグ開始時に Shift を一度読めば十分だ。」 修飾キーはドラッグ中にも変わります。正規化された各メモに、その時点のスナップショットが必要です。
Production backpack
前提となる契約
Canvas Lab には、第4章の Camera.screenToWorld() と、第6章で作った対称な start()/dispose() ライフサイクルを持つ Engine がすでにあります。入力層は行列処理を複製せず Camera を呼び出し、イベントを発行しても Document は変更しません。Canvas 要素はフォーカス可能で、テキスト入力は引き続き DOM Overlay が引き受けられます。
正式な知識
Pointer Events は pointerdown/move/up/cancel を統一します。pointerId がポインターを区別できるのは、現在のページセッションとアクティブなポインターの存続期間だけです。永続化してはいけません。pointerType はペンの筆圧など能力の選択に使いますが、プロダクトの分岐条件にしてはいけません。buttons はボタンのビットマスクです。pressure、tiltX/Y、altitudeAngle、azimuthAngle はデバイスが報告するペン情報です。能力がない場合は精度を捏造せず、中立値と capability flag を使います。
pointerdown のあとに主ポインターを Capture し、pointerup、pointercancel、lostpointercapture では同じ経路でクリーンアップします。2本目のポインターが加わったら、Gesture Recognizer が描画を続けるか、単一ポインターツールをキャンセルするか、pinch に移るかを判断します。二つの Tool が同じ入力を同時に所有してはいけません。getCoalescedEvents() は Freehand に細かなサンプルを渡せます。getPredictedEvents() は Transient Preview にだけ入れます。pointerrawupdate は、計測で低レイテンシが必要だとわかり、かつブラウザが対応している場合に限って有効にします。通常の選択ツールは pointermove を使い続け、不要なイベント洪水を避けます。
Wheel は deltaMode に従って正規化してから、スクロール、パン、ズームの方針を適用します。「マウスホイールかトラックパッドか」を常に証明できる単一の信頼可能なフィールドはありません。デバイス種別を事実として捏造せず、delta シーケンスに対してプロダクトルールを適用します。pinch は2個の active pointer の距離と中心から GestureChanged を生成します。CSS の touch-action: none は要素内のブラウザ標準パン・ズームを禁止するため、本当にジェスチャーを所有する操作面だけに設定します。ページのナビゲーション領域に広げてはいけません。
Keyboard Controller は keydown/keyup と alt/ctrl/meta/shift のスナップショットを追跡し、テキスト編集中の Overlay を除外します。window.blur または visibilitychange では押しっぱなし扱いになったキーを消し、進行中の操作をキャンセルします。Drag Threshold は画面上の CSS ピクセルで測り、Zoom によって操作感が変わらないようにします。Double Click は閾値、時間窓、対象 ID の一致をまとめて判定します。Long Press はキャンセル可能なタイマーで生成し、移動が閾値を超えたとき、2本目のポインターが加わったとき、フォーカス喪失時、キャンセル時には必ず破棄します。
根拠と互換性(確認日:2026-08-29)
- MDN Pointer Events は統一イベント、Pointer Capture、暗黙のタッチキャプチャ、ハードウェア非依存モデルを説明しています。
- MDN
setPointerCapture()とlostpointercaptureは Capture と喪失通知を定義します。それでもpointercancelの処理は必要です。 - MDN
getCoalescedEvents()、getPredictedEvents()、pointerrawupdateはブラウザ対応状況が異なるため、本番コードでは feature detection が必要です。 - MDN WheelEvent、
deltaMode、touch-actionが正規化とジェスチャー所有権の基礎です。
この章で積み上げる実装
開始地点: Engine が DOM イベントを Tool に直接渡し、ポインターが Canvas を離れると時々固まり、Zoom 後のワールド点を信用できません。完了地点: InputController がブラウザ入力の唯一の入口となり、判別可能な NormalizedInput を出力します。ツールはそのイベントだけを受け取ります。
次のファイルと責務を追加します。
src/engine/input/InputController.ts:監視、Capture、正規化、クリーンアップ。src/engine/input/types.ts:統一イベントと capability。src/engine/input/GestureRecognizer.ts:2本指 pinch、long press、double click。src/engine/input/__tests__/InputController.test.ts:キャンセル、座標、修飾キー、クリーンアップ。tests/browser/input-capture.spec.ts:実ブラウザで要素外へ移動してから離すテスト。
以下はそのまま実行できる中核実装です。省略しているのはプロダクト固有の購読コンテナだけで、キャンセルと座標処理は省略していません。
export type Modifiers = Readonly<{
alt: boolean;
ctrl: boolean;
meta: boolean;
shift: boolean;
}>;
export type PointerSample = Readonly<{
pointerId: number;
pointerType: string;
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
buttons: number;
modifiers: Modifiers;
timestamp: number;
pressure: number;
tilt: readonly [number, number];
altitude: number | null;
azimuth: number | null;
captured: boolean;
predicted: boolean;
}>;
export type NormalizedInput =
| (PointerSample & { kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded' })
| (PointerSample & { kind: 'PointerCanceled'; reason: 'browser' | 'capture-lost' | 'focus-lost' })
| {
kind: 'WheelZoomed';
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
deltaPixels: number;
modifiers: Modifiers;
timestamp: number;
}
| { kind: 'KeyChanged'; code: string; down: boolean; modifiers: Modifiers; timestamp: number }
| {
kind: 'GestureChanged';
gesture: 'pinch' | 'long-press' | 'double-click';
phase: 'start' | 'change' | 'end' | 'cancel';
screen: DOMPointReadOnly;
world: DOMPointReadOnly;
scale: number;
rotation: number;
timestamp: number;
};
export interface CameraPort {
screenToWorld(point: DOMPointReadOnly): DOMPointReadOnly;
}
const mods = (event: MouseEvent | KeyboardEvent): Modifiers => ({
alt: event.altKey,
ctrl: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
});
export class InputController {
private readonly active = new Set<number>();
private readonly lastPointers = new Map<number, PointerSample>();
private readonly pressedKeys = new Set<string>();
private readonly previousTouchAction: string;
private readonly previousTabIndex: string | null;
private disposed = false;
constructor(
private readonly element: HTMLElement,
private readonly camera: CameraPort,
private readonly emit: (event: NormalizedInput) => void,
) {
this.previousTouchAction = element.style.touchAction;
this.previousTabIndex = element.getAttribute('tabindex');
element.style.touchAction = 'none';
if (this.previousTabIndex === null) element.tabIndex = 0;
element.addEventListener('pointerdown', this.onDown);
element.addEventListener('pointermove', this.onMove);
element.addEventListener('pointerup', this.onUp);
element.addEventListener('pointercancel', this.onCancel);
element.addEventListener('lostpointercapture', this.onLostCapture);
element.addEventListener('wheel', this.onWheel, { passive: false });
element.addEventListener('keydown', this.onKeyDown);
element.addEventListener('keyup', this.onKeyUp);
window.addEventListener('blur', this.onBlur);
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
private points(event: PointerEvent) {
const rect = this.element.getBoundingClientRect();
const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
return { screen, world: this.camera.screenToWorld(screen) };
}
private pointerSample(event: PointerEvent, predicted = false): PointerSample {
const { screen, world } = this.points(event);
const altitude = 'altitudeAngle' in event ? event.altitudeAngle : null;
const azimuth = 'azimuthAngle' in event ? event.azimuthAngle : null;
return {
pointerId: event.pointerId,
pointerType: event.pointerType,
screen,
world,
buttons: event.buttons,
modifiers: mods(event),
timestamp: event.timeStamp,
pressure: event.pressure,
tilt: [event.tiltX, event.tiltY],
altitude,
azimuth,
captured: this.element.hasPointerCapture(event.pointerId),
predicted,
};
}
private emitPointer(
event: PointerEvent,
kind: 'PointerStarted' | 'PointerMoved' | 'PointerEnded',
predicted = false,
) {
const sample = this.pointerSample(event, predicted);
if (!predicted) this.lastPointers.set(event.pointerId, sample);
this.emit({ ...sample, kind });
}
private onDown = (event: PointerEvent) => {
this.active.add(event.pointerId);
try {
this.element.setPointerCapture(event.pointerId);
} catch {
// 要素が削除された場合や UA が Capture を拒否した場合も Started は発行する。
// captured:false により上位層がフォールバック戦略を選べる。
}
this.emitPointer(event, 'PointerStarted');
};
private onMove = (event: PointerEvent) => {
if (!this.active.has(event.pointerId)) return;
const coalesced =
typeof event.getCoalescedEvents === 'function' ? event.getCoalescedEvents() : [event];
const samples = coalesced.length ? coalesced : [event];
for (const sample of samples) this.emitPointer(sample, 'PointerMoved');
if (typeof event.getPredictedEvents === 'function') {
for (const sample of event.getPredictedEvents())
this.emitPointer(sample, 'PointerMoved', true);
}
};
private onUp = (event: PointerEvent) => {
if (!this.active.delete(event.pointerId)) return;
this.emitPointer(event, 'PointerEnded');
if (this.element.hasPointerCapture(event.pointerId))
this.element.releasePointerCapture(event.pointerId);
this.lastPointers.delete(event.pointerId);
};
private cancel(
pointerId: number,
reason: 'browser' | 'capture-lost' | 'focus-lost',
event?: PointerEvent,
) {
if (!this.active.delete(pointerId)) return;
const previous = event ? this.pointerSample(event) : this.lastPointers.get(pointerId);
if (previous)
this.emit({
...previous,
captured: this.element.hasPointerCapture(pointerId),
predicted: false,
kind: 'PointerCanceled',
reason,
});
this.lastPointers.delete(pointerId);
}
private onCancel = (event: PointerEvent) => this.cancel(event.pointerId, 'browser', event);
private onLostCapture = (event: PointerEvent) =>
this.cancel(event.pointerId, 'capture-lost', event);
private onKeyDown = (event: KeyboardEvent) => {
this.pressedKeys.add(event.code);
this.emit({
kind: 'KeyChanged',
code: event.code,
down: true,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
private onKeyUp = (event: KeyboardEvent) => {
this.pressedKeys.delete(event.code);
this.emit({
kind: 'KeyChanged',
code: event.code,
down: false,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
private onBlur = () => {
for (const id of [...this.active]) this.cancel(id, 'focus-lost');
for (const code of this.pressedKeys)
this.emit({
kind: 'KeyChanged',
code,
down: false,
modifiers: { alt: false, ctrl: false, meta: false, shift: false },
timestamp: performance.now(),
});
this.pressedKeys.clear();
};
private onVisibilityChange = () => {
if (document.visibilityState === 'hidden') this.onBlur();
};
private onWheel = (event: WheelEvent) => {
event.preventDefault();
const unit =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? Math.max(1, this.element.clientHeight)
: 1;
const rect = this.element.getBoundingClientRect();
const screen = new DOMPointReadOnly(event.clientX - rect.left, event.clientY - rect.top);
this.emit({
kind: 'WheelZoomed',
screen,
world: this.camera.screenToWorld(screen),
deltaPixels: event.deltaY * unit,
modifiers: mods(event),
timestamp: event.timeStamp,
});
};
dispose() {
if (this.disposed) return;
this.disposed = true;
this.onBlur();
this.element.removeEventListener('pointerdown', this.onDown);
this.element.removeEventListener('pointermove', this.onMove);
this.element.removeEventListener('pointerup', this.onUp);
this.element.removeEventListener('pointercancel', this.onCancel);
this.element.removeEventListener('lostpointercapture', this.onLostCapture);
this.element.removeEventListener('wheel', this.onWheel);
this.element.removeEventListener('keydown', this.onKeyDown);
this.element.removeEventListener('keyup', this.onKeyUp);
window.removeEventListener('blur', this.onBlur);
document.removeEventListener('visibilitychange', this.onVisibilityChange);
this.element.style.touchAction = this.previousTouchAction;
if (this.previousTabIndex === null) this.element.removeAttribute('tabindex');
else this.element.setAttribute('tabindex', this.previousTabIndex);
}
}
GestureRecognizer は DOM を再度監視せず、これらの実サンプルを購読します。pointerId ごとに2個の active pointer の開始時点の中心、距離、角度を保存します。2本が揃ったら phase:'start' を発行し、その後は「現在距離 ÷ 開始距離」を scale、角度差を rotation として返します。どちらかが End または Cancel になったら end/cancel を一度だけ発行し、session を消去します。Long Press のタイマーは、Drag Threshold を超えたとき、2本目のポインターが加わったとき、フォーカス喪失時、キャンセル時に消します。Double Click は時間窓、画面距離、target ID のすべてを満たす必要があります。Coalesced の実点は Freehand に入れられますが、Predicted 点は置換可能な Preview にだけ入れます。pointerrawupdate は feature-detected な任意のサンプル元に限り、同じ pointermove の点を重複記録してはいけません。この recognizer のシーケンステストと、上記ブラウザアダプタのテストはどちらも必要です。
単体テストではブラウザの殻を最小限にし、ワールド変換、最後の完全なサンプルを伴うキャンセル、冪等なクリーンアップを証明します。
import { describe, expect, it, vi } from 'vitest';
import { InputController } from '../InputController';
describe('InputController', () => {
it('画面点をワールド点へ変換し、Capture 喪失時に一度だけキャンセルする', () => {
const el = document.createElement('canvas');
Object.defineProperty(el, 'getBoundingClientRect', {
value: () => ({ left: 10, top: 20, width: 300, height: 200 }),
});
el.setPointerCapture = vi.fn();
el.hasPointerCapture = vi.fn(() => true);
const events: unknown[] = [];
const input = new InputController(
el,
{
screenToWorld: (p) => new DOMPointReadOnly(p.x / 2, p.y / 2),
},
(e) => events.push(e),
);
el.dispatchEvent(
new PointerEvent('pointerdown', {
pointerId: 7,
clientX: 50,
clientY: 60,
buttons: 1,
pointerType: 'pen',
}),
);
el.dispatchEvent(new PointerEvent('lostpointercapture', { pointerId: 7 }));
el.dispatchEvent(new PointerEvent('pointercancel', { pointerId: 7 }));
expect(events).toMatchObject([
{ kind: 'PointerStarted', screen: { x: 40, y: 40 }, world: { x: 20, y: 20 } },
{
kind: 'PointerCanceled',
pointerId: 7,
reason: 'capture-lost',
screen: { x: -10, y: -20 },
world: { x: -5, y: -10 },
predicted: false,
},
]);
input.dispose();
input.dispose();
expect(events).toHaveLength(2);
});
});
pnpm vitest run src/engine/input を実行し、最終行に 1 passed が含まれることを確認します。pnpm playwright test tests/browser/input-capture.spec.ts を実行し、Canvas の外までドラッグして離したあと状態が Idle に戻り、イベントログに End または Cancel が一度だけ記録されることを確認します。最後にマウス、タッチ画面、筆圧ペンで JSON シーケンスを一つずつ記録し、フィールド構造が同一であることを確かめます。
コードを書いたらテーブルのゲームに戻りましょう。InputController は通訳です。Camera は「テーブルの何センチ目」を「おもちゃの街の何丁目」に変える地図です。ツールは翻訳済みのメモだけを読みます。ペンを交換しても交通ルールを書き直す必要はありません。
わざと壊してみる
| 注入する障害 | 症状 | 集める証拠 | 修正 | 回帰テスト | 復旧 |
|---|---|---|---|---|---|
| 押したまま Canvas 外へ出て離す | 永久に Dragging のまま | イベント列に End がない | down 時に Capture | Playwright で外までドラッグして離す | Cancel を発行し一時状態を消す |
DevTools で pointercancel を再現 | 未完成物がコミットされる | Document に余分な Command | Cancel は Preview だけを戻す | Command 数が 0 と断言 | 汚染レコードを削除し snapshot をリプレイ |
| 途中で2本目のポインターを追加 | Shape が跳ねてズームもする | 二つの Tool が同じ pointer を所有 | Gesture Guard が単一ポインター transaction をキャンセル | 2本指シーケンスをリプレイ | active set を消す |
| Drag 中に Shift を離す | 比率が固定されたまま | イベントの修飾キースナップショットが更新されない | move ごとに modifiers を読む | Shift を途中変更するテスト | 次イベントで制約を再計算 |
| トラックパッドが200個の微小 delta を送る | Zoom が暴走 | deltaMode と時刻のシーケンス | 正規化してフレーム単位で集約 | 固定入力ストリームの snapshot | accumulator をリセット |
| LINE/PAGE wheel を生成 | ブラウザごとに量が違う | 生 delta と pixel delta の比較 | deltaMode に従って換算 | 3種類の mode をパラメータ化 | 既定 Zoom に戻す |
| Capture を明示的に解放 | Tool が固まる | lostpointercapture ログ | 統一 cancel 経路 | Capture 喪失テスト | transient transaction をロールバック |
証拠をもって合格する
| 証明する性質 | 自動化された証拠 | 手動の証拠 |
|---|---|---|
| 3種類のポインターが同じプロトコルに入る | 型テストとイベント snapshot | 実機のマウス、タッチ、ペン |
| 要素外に出ても終了を失わない | ブラウザ Capture テスト | ウィンドウ端の外へドラッグ |
| Camera 変換が一度だけ行われ可逆 | 固定行列の点 assertion | 異なる Zoom で同じ角をクリック |
| Cancel が Document を書かない | Command 数の assertion | システムジェスチャーを中断 |
| 予測点が永続化されない | シリアライズ snapshot に predicted がない | 低速リプレイにゴースト線がない |
| ライフサイクルが対称 | 2回の dispose と listener 数 | Strict Mode でイベントが重複しない |
- Tool のソースに
if mouse ... else if touch ... else if pen ...がない。 - 統一 Pointer イベントがすべて Screen Point、World Point、type、buttons、modifiers、timestamp、pressure、captured state を持つ。
- Pointer Capture、Cancel、Capture Lost、Focus Loss が同じクリーンアップ経路に入る。
- Coalesced/Predicted/Raw Update に feature detection と明示的フォールバックがある。
- Drag、Double Click、Long Press の閾値が画面ピクセル単位で、どの Zoom でも操作感が同じ。
- Wheel の単位差と高頻度の小さな delta に再現可能なテストがある。
5歳児に説明する
「イベント」「キャプチャ」「座標」という言葉を使わずに答えてください。
- 3種類の違う手が、なぜ最初に同じ通訳を訪ねるのでしょうか。
- 通訳が番号札を預かったあと、手がテーブルの外へ出てもゲームを終えられるのはなぜでしょうか。
- 「次の線をこう描くだろう」という予想は、なぜ一時的に見せるだけで作品に書き込めないのでしょうか。
- 新しい問題です。片手で積み木を持っている途中に突然停電したら、積み木の新しい位置を台帳に書きますか。