JEPA4Japan · チュートリアル

第7章:マウス、指、ペンに同じ言葉を話してもらう

5,731文字 16分で読めます #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Pointer、Wheel、Keyboard、Gestureを、ツール層が扱える統一されたWorld座標イベントへ変換します。

コース進捗 コース目次 18レッスン中 18件を公開中

第I部:描く前に描画面を選ぶ——プロダクト、ピクセル、座標

  1. 01 第1章:まだ描かない——Canvasはプロダクト設計ではない 公開中
  2. 02 第2章:すぐに記憶を失うピクセルの紙 公開中
  3. 03 第3章:お絵描きを再現可能なレシピにする 公開中
  4. 04 第4章:4枚の地図と1台のカメラ 公開中

第II部:ピクセル世界に頭脳を与える——モデル、スケジューリング、入力、ツール

  1. 05 第5章:ピクセル世界に台帳を作る 公開中
  2. 06 第6章:ランプが点いたときだけ描き直す——Render SchedulerとReactの境界 公開中
  3. 07 第7章:マウス、指、ペンに同じ言葉を話してもらう 現在のレッスン
  4. 08 第8章:細い縁を調べる前に大きな箱を探す 公開中
  5. 09 第9章:ツールは信号機であり、Booleanの袋ではない 公開中

第III部:「ドラッグできる」から「信頼できる」へ——操作、文字、Asset、復旧

  1. 10 第10章:触って気持ちよいエディターにする 公開中
  2. 11 第11章:描かれた文字は編集できる文字ではない 公開中
  3. 12 第12章:借りた画像を勝手に箱へ詰めてはいけない 公開中
  4. 13 第13章:タイムマシンと古い箱 公開中
  5. 14 第14章:正しく見えることと、本当に正しいことは違う 公開中

第IV部:マスターの判断——Performance、Worker、GPU、SDK、共同編集、AI

  1. 15 第15章:1万人を一人ずつ探さない 公開中
  2. 16 第16章:受付を厨房へ入れない——WorkerとGPUへの更新 公開中
  3. 17 第17章:車を自作するか、実績あるシャーシを買うか 公開中
  4. 18 第18章:人とAIが同じ台帳を編集する 公開中

まずは5歳児にもわかるゲームから

この章で覚える真実は一つだけです。プロダクトのツールはブラウザの生イベントを直接受け取るべきではなく、正規化された入力イベントを受け取るべきです。

おもちゃのマウス、クレヨン、そして指を用意します。テーブルに赤いボタンを置き、その隣に「通訳」を座らせましょう。ルールは一つです。子どもが何を使ってボタンに触れても、通訳は「何番が、どこで、どれくらい強く、始めたのか終えたのか」だけを伝えます。

先に予想してみてください。子どもがクレヨンを押し付けたままテーブルの外まで動かし、そこで離したとき、ボタンは操作が「終わった」とわかるでしょうか。2本目の指が突然触れたら、どちらがどちらだと見分けるのでしょうか。通訳が「マウスが動いた」としか言えなければ、指やペンも参加できるでしょうか。

  1. 違うお客さま触る、押す、滑らせる
  2. 一人の通訳番号、位置、筆圧
  3. 同じ形式のメモ開始、移動、終了、キャンセル
  4. ツールはメモだけを読む誰が来たかを推測しない
まず答えてください。手がテーブルを離れたあとも、通訳が同じ操作だと見分け続けられるのはなぜでしょうか。答えは「IDとキャプチャ」であって、より大きなテーブルではありません。

実際の操作に置き換えると、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)

この章で積み上げる実装

開始地点: 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 時に CapturePlaywright で外までドラッグして離すCancel を発行し一時状態を消す
DevTools で pointercancel を再現未完成物がコミットされるDocument に余分な CommandCancel は Preview だけを戻すCommand 数が 0 と断言汚染レコードを削除し snapshot をリプレイ
途中で2本目のポインターを追加Shape が跳ねてズームもする二つの Tool が同じ pointer を所有Gesture Guard が単一ポインター transaction をキャンセル2本指シーケンスをリプレイactive set を消す
Drag 中に Shift を離す比率が固定されたままイベントの修飾キースナップショットが更新されないmove ごとに modifiers を読むShift を途中変更するテスト次イベントで制約を再計算
トラックパッドが200個の微小 delta を送るZoom が暴走deltaMode と時刻のシーケンス正規化してフレーム単位で集約固定入力ストリームの snapshotaccumulator をリセット
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歳児に説明する

「イベント」「キャプチャ」「座標」という言葉を使わずに答えてください。

  1. 3種類の違う手が、なぜ最初に同じ通訳を訪ねるのでしょうか。
  2. 通訳が番号札を預かったあと、手がテーブルの外へ出てもゲームを終えられるのはなぜでしょうか。
  3. 「次の線をこう描くだろう」という予想は、なぜ一時的に見せるだけで作品に書き込めないのでしょうか。
  4. 新しい問題です。片手で積み木を持っている途中に突然停電したら、積み木の新しい位置を台帳に書きますか。
参考解答を見る 3種類の手は話し方が違い、ツールが一つずつ覚えるとルールを見落とします。通訳がすべてを同じ「開始、移動、終了」のメモに変えます。番号札のおかげでテーブルの外でも同じ操作だとわかりますが、先生はいつでもゲームを止められるため、「まだ終わっていない配置を元に戻す」方法も必要です。予想した点は外れることがあるので薄い鉛筆の影にとどめ、実際に触れた場所が届いたら消します。停電したら途中の配置を共有台帳には書かず、始める前の位置に戻します。