🖱

VueUseのusePointerLockからPointer Lock APIを学ぼう

2024/12/15に公開

この記事は、Vue Advent Calendar 2024 の15日目の記事です。


VueUseはVue3とVue2で扱えるComposition Utilitiesが集まったライブラリでです。
例えば手軽にオブジェクトのドラッグが扱えるuseDraggableや、長押しを判定するonLongPressなど、200以上の関数がそろっています。

https://vueuse.org/

その中には、様々なWeb APIを扱う関数があります。モダンブラウザでの実装が追い付いていないような実験的なものもあります。
今回はWeb APIのPointer Lock APIを扱うusePointerLockからPointer Lock APIについて学ぶ記事です。

usePointerLockのリンクはこちらです。
https://vueuse.org/core/usePointerLock/

Pointer Lock APIとは?あるいはusePointerLockとは

▽Pointer Lock APIについて

日本語のMDNの記事がこちらです。

https://developer.mozilla.org/ja/docs/Web/API/Pointer_Lock_API

ほとんどのブラウザでは実装されていますが、iOSのSafariやWebviewでは実装が追い付いていない形です。

下記の説明を紐解くために、本題のVueUseのusePointerLockを見てみましょう。
いまはざっと読み飛ばしていただいて大丈夫です。

ポインターロック API (以前は マウスロック API と呼ばれていました)は、ビューポート内のマウスカーソルの絶対位置だけでなく、時間の経過に伴うマウスの動き(すなわち、デルタ)に基づく入力方法を提供します。これにより、マウスの生の移動量を知ることができ、マウスイベントのターゲットをひとつの要素にロックでき、マウスが一方向へどれだけ移動できるかの制限を除去でき、視野からカーソルを取り除くことができます。これらは、本人視点の 3D ゲームなどで理想的です。

さらにこの API は、動きのコントロールやオブジェクトの回転、エントリーの変更にかなりのマウス操作が必要になるアプリケーションで役立ちます。例えばなんらかのボタンをクリックすることなく、マウスを動かすだけで視野角を制御できます。ボタンは他の操作のために使用できます。また、地図や衛星画像を見るアプリでも役に立ちます。

ポインターロックでは、カーソルがブラウザーやスクリーンの境界を通り過ぎるときでもマウスイベントにアクセスできます。例えばユーザーは限りなくマウスを動かすことで、3D モデルの回転や操作を続けることができます。ポインターロックがなければ、ポインターがブラウザーまたはスクリーンの端に達したときに回転や操作が止まります。ゲームのプレイヤーはマウスカーソルがゲームのプレイ領域から外れて、別のアプリケーションを意図せずクリックしてゲームからマウスのフォーカスが外れてしまうことを心配せずに、熱中してボタンをクリックしたり、マウスカーソルをあちこちに動かしたりすることができるようになります。

▽usePointerLockについて

usePointerLockのType Declarationsはこのようになっています。

type MaybeHTMLElement = HTMLElement | undefined | null
export interface UsePointerLockOptions extends ConfigurableDocument {}
/**
 * Reactive pointer lock.
 *
 * @see https://vueuse.org/usePointerLock
 * @param target
 * @param options
 */
export declare function usePointerLock(
  target?: MaybeElementRef<MaybeHTMLElement>,
  options?: UsePointerLockOptions,
): {
  isSupported: ComputedRef<boolean>
  element: Ref<MaybeHTMLElement, MaybeHTMLElement>
  triggerElement: Ref<MaybeHTMLElement, MaybeHTMLElement>
  lock: (e: MaybeElementRef<MaybeHTMLElement> | Event) => Promise<HTMLElement>
  unlock: () => Promise<boolean>
}
export type UsePointerLockReturn = ReturnType<typeof usePointerLock>

デモを見てみると、何やら3Dのオブジェクトをマウスで回転させることが出来るようです。

下記のデモのソースコードを見て、何を行っているか見てみましょう。

▽デモのソースコード

https://github.com/vueuse/vueuse/blob/main/packages/core/usePointerLock/demo.vue

まず、ドラッグを開始するとusePointerLockのlock関数を発火させます。ここでポインターロックが始まります。

<div cube @mousedown.capture="lock" @mouseup="unlock">

lock中になると、usePointerLockのelementにターゲットオブジェクトが入ります。
そこで、useMouseのx, yを元に計算している下記のwatchが動き始めます。

watch([x, y], ([x, y]) => {
  if (!element.value)
    return
  rotY.value += x / 2
  rotX.value -= y / 2
})

マウス操作によって、オブジェクトが回転するようになります。
そしてmouseupunlockがあるので、ドラッグが終了したらポインターロックが解放されるようになっています。

<div cube @mousedown.capture="lock" @mouseup="unlock">

一見ではポインターロックが何をしているかわからないかもしれませんが、下記の役割を果たしています。

  1. ポインターロックが始まると、カーソルが消え、(Chromeでは)Escで抜けられる旨のトーストが出る
  2. どれだけポインターロック後に動かしたとしても、カーソルの位置がドラッグ終了後に変わっていない
  3. ブラウザの領域を抜け出すような大きな移動量でも、ドラッグ操作をし続ける(useMouseの移動量も、通常はブラウザの領域外に出ると取得が出来ないが、大きくブラウザの領域外に出る動きをしても取得することが出来る)

これは前述のMDNの説明のこのあたりになります。

マウスイベントのターゲットをひとつの要素にロックでき、マウスが一方向へどれだけ移動できるかの制限を除去でき、視野からカーソルを取り除くことができます。

ポインターロックがなければ、ポインターがブラウザーまたはスクリーンの端に達したときに回転や操作が止まります。ゲームのプレイヤーはマウスカーソルがゲームのプレイ領域から外れて、別のアプリケーションを意図せずクリックしてゲームからマウスのフォーカスが外れてしまうことを心配せずに、熱中してボタンをクリックしたり、マウスカーソルをあちこちに動かしたりすることができるようになります。

特にブラウザの大きさに縛られたくないゲームなどで活躍しそうです。
ここまでがわかったところで、Pointer Lock APIの使い方とusePointerLockの実装を軽く見てみましょう。

Pointer Lock APIの使い方とusePointerLockの実装

Pointer Lock APIには下記の特徴があります。

  • ポインターロックは永続的です。明示的に API が呼び出されるかユーザーが特定の解放ジェスチャを行うまで、マウスを解放しません。
  • ポインターロックはブラウザーまたはスクリーンの境界に制限されません。
  • ポインターロックはマウスボタンの状態に関係なく、イベントが発生し続けます。
  • ポインターロックはカーソルを隠します。

つまり明示的に呼び出しと終了を行います。

素のPointer Lock APIでは、requestPointerLock()で呼び出し、exitPointerLock()で解除を行います。

usePointerLockのlockはこのような実装になっています。

  async function lock(
    e: MaybeElementRef<MaybeHTMLElement> | Event,
    // options?: PointerLockOptions,
  ) {
    if (!isSupported.value)
      throw new Error('Pointer Lock API is not supported by your browser.')

    triggerElement.value = e instanceof Event ? <HTMLElement>e.currentTarget : null
    targetElement = e instanceof Event ? unrefElement(target) ?? triggerElement.value : unrefElement(e)
    if (!targetElement)
      throw new Error('Target element undefined.')
    targetElement.requestPointerLock()

    return await until(element).toBe(targetElement)
  }

targetElement(usePointerLock引数のtargetもしくはe.currentTarget)に対してrequestPointerLock()を行います。
そのあと、await until(element).toBe(targetElement)をしていますが、untilは同じくVueUseの関数で、下記のpointerlockchangeイベントのリスナーでelementpointerLockElementに更新されるのを待っています。
ポインターロックはDocumentを拡張しており、document.pointerLockElementで現在ロックしている要素へのアクセスを可能にします。

useEventListener(document, 'pointerlockchange', () => {
  const currentElement = document!.pointerLockElement ?? element.value
  if (targetElement && currentElement === targetElement) {
    element.value = document!.pointerLockElement as MaybeHTMLElement
    if (!element.value)
      targetElement = triggerElement.value = null
  }
})

pointerlockchangeイベントはポインターロックの状態が変化したときに発動し、付加的なデータは含まれません。
Pointer Lock APIにはほかにpointerlockerrorイベントがあり、そちらをリスンするコードもusePointerLockに含まれ、エラーをスローする役割になっています。

useEventListener(document, 'pointerlockerror', () => {
  const currentElement = document!.pointerLockElement ?? element.value
  if (targetElement && currentElement === targetElement) {
    const action = document!.pointerLockElement ? 'release' : 'acquire'
    throw new Error(`Failed to ${action} pointer lock.`)
  }
})

unlockはシンプルに、document.existPointerLockを発火し、pointerlockchangeによってelementがnullになるのを待ってtrueを返します。

async function unlock() {
  if (!element.value)
    return false

  document!.exitPointerLock()

  await until(element).toBeNull()
  return true
}

これらのlock, unlock, element, triggerElementに加えブラウザがサポートしているかどうかのisSupportedをusePointerLockは返します。

コード全容はこちらです。

https://github.com/vueuse/vueuse/blob/fa7280728fab4705332a730be7f499006bba61d0/packages/core/usePointerLock/index.ts

おわりに

いかがでしたでしょうか。Pointer Lock APIは、ゲームなどの用途意外ではあまり使わないのかもしれませんが、知っておくといつか役に立つAPIかもしれません。

VueUseのWeb API関連の実装を見るのが趣味なので、今後もこのような記事を書くかもしれません ◟꒰ ´꒳` ꒱◞
以前書いていたuseBatteryの記事も宣伝しておきます。

https://zenn.dev/hiroko_ino/articles/battery-level-change-picture

何か不備などありましたらコメントいただけると幸いです!

Vue・Nuxt 情報が集まる広場 / Plaza for Vue・Nuxt.

Discussion