actions について
actionsは、@pmndrs/viverseでゲームを開発する人向けの入力レイヤー用スキルです。キーボード、マウス、タッチ、画面上の操作をゲームロジックが扱うActionに変換し、状態とイベントの区別やモードごとのライフサイクル管理を説明します。
主な機能
- 01StateActionとEventActionの使い分け
- 02キーボード、マウス、タッチのバインディング
- 03画面ジョイスティックとジャンプボタン
- 04カスタムActionと入力マッピング
- 05AbortControllerによるバインディング管理
活用シーン
- 01ゲームキャラクターのキーボード・タッチ移動設定
- 02ジャンプや発射などの単発入力をシステムへ伝達
- 03メニュー、プレイ、カットシーンごとの操作切り替え
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ drawcall-ai/ skills --skill actionsインストール後、Claude Code で/actionsを入力して読み込まれたか確認します。
詳細
@pmndrs/viverse のアクションとアクションバインディング
アクションを使うと、入力とゲームロジックを切り離せます。バインディングはハードウェアイベントをアクションに変換し、システムが各フレームで利用できるようにします。
入力 → バインディング → アクション → ゲームロジック
このスキルで扱うのは入力レイヤーのみです。キャラクターを動かす場合は、入力の意図をさらに 入力 → Acta → 物理演算 と伝えます。システムでこれらのアクションを読み取り、移動の 意図 を Acta(アニメーション)に渡すと、Acta が BvhCharacterPhysics 用の速度を生成します。物理コントローラーを生の入力から直接動かさないでください。acta、physics、camera の各スキルを参照してください。
アクションの種類
- StateAction – 永続的な状態(移動、走行)です。複数の書き込み元の値は、
.get()を介して1つの値に統合されます。 - EventAction – 1回限りのイベント(ジャンプ、発射)です。
.emit()で発行し、.subscribe()で受け取ります。
組み込みアクション
| アクション | 種類 | 目的 |
|---|---|---|
MoveForwardAction、MoveBackwardAction、MoveLeftAction、MoveRightAction | State | 移動(0~1)— 各フレームで .get() を使って読み取ります |
RunAction | State | スプリントの切り替え — 各フレームで .get() を使って読み取ります |
JumpAction | Event | ジャンプのトリガー — .subscribe() を使用します |
RotateYawAction、RotatePitchAction | Event | すぐに使える回転差分(ラジアン)— .subscribe() を使って蓄積します |
ZoomAction | Event | カメラのズーム差分 — .subscribe() を使用します |
State と Event の違い:State アクションは変更されるまで状態が保持されるため、
.get()でポーリングします。Event アクションは1回だけ発生するため、.subscribe()で受け取ります。受け取らなかった値は失われます。
システムでアクションを使う
以下の例では、セットアップ時に const abortController = new AbortController() を1つ作成し、その .signal をすべてのバインディングとサブスクリプションに渡すことを前提としています。これにより、すべてをまとめてクリーンアップできます(末尾のライフサイクルを参照)。
StateAction は各フレームで .get() を使ってポーリングします。EventAction は初期化時に一度だけサブスクライブし、更新ループ内ではサブスクライブしないでください。
class PlayerSystem extends createSystem(query) {
private isJumping = false
init() {
// ✅ Subscribe to events ONCE
JumpAction.subscribe(
() => {
this.isJumping = true
},
{ signal: abortController.signal },
)
}
update(delta: number) {
// ✅ Poll state each frame
const forward = MoveForwardAction.get()
// ❌ NEVER subscribe here — creates duplicate listeners every frame
}
}
組み込みバインディング
組み込みバインディングはすべて (domElement, abortSignal) を受け取り、中断時に自動的にクリーンアップされます。
キーボード
import { KeyboardLocomotionActionBindings } from '@pmndrs/viverse'
const keyboard = new KeyboardLocomotionActionBindings(document.body, abortController.signal)
// Optional: require pointer lock for movement
keyboard.moveForwardBinding.requiresPointerLock = true
// Optional: customize keys (defaults: WASD, Shift, Space)
keyboard.moveForwardBinding.keys = ['KeyW', 'ArrowUp']
マウス(ポインターロック)
import { PointerLockRotateZoomActionBindings } from '@pmndrs/viverse'
const mouse = new PointerLockRotateZoomActionBindings(document.body, abortController.signal)
mouse.lockOnClick = true
lockOnClick を指定すると、ターゲット上のどこをクリックしてもポインターがロックされます。そのため、プレイヤーがメニューや HUD ボタンでカーソルを使う必要がある間は、このバインディングを有効にしてはいけません。有効にするとメニューを操作できなくなります。アクティブなモードに操作を限定するを参照してください。
回転差分の受け取り
RotateYawAction と RotatePitchAction は、マウスを動かすとすぐに使える回転差分(ラジアン)を発行します。サブスクライブして蓄積します。
import { RotateYawAction, RotatePitchAction } from '@pmndrs/viverse'
import { Euler } from 'three'
let pitch = 0,
yaw = 0
RotatePitchAction.subscribe(
(delta) => {
pitch = Math.max(-Math.PI / 2, Math.min(Math.PI / 2, pitch + delta))
},
{ signal: abortController.signal },
)
RotateYawAction.subscribe(
(delta) => {
yaw += delta
},
{ signal: abortController.signal },
)
// Apply to camera: positive pitch = look up, positive yaw = look left
camera.rotation.copy(new Euler(pitch, yaw, 0, 'YXZ'))
規約:正のピッチは上方向、正のヨーは左方向に回転します。
Euler(pitch, yaw, 0, 'YXZ')の順序を使用してください。カメラのパターンについては camera スキルを参照してください。
タッチ(ポインターキャプチャ)
import { PointerCaptureRotateZoomActionBindings } from '@pmndrs/viverse'
const touch = new PointerCaptureRotateZoomActionBindings(document.body, abortController.signal)
モバイル UI
import { ScreenJoystickLocomotionActionBindings, ScreenButtonJumpActionBindings } from '@pmndrs/viverse'
const joystick = new ScreenJoystickLocomotionActionBindings(document.body, abortController.signal)
const jumpBtn = new ScreenButtonJumpActionBindings(document.body, abortController.signal)
デスクトップでは .mobile-only CSS クラスによってコントロールが自動的に非表示になります。
カスタムのキーからアクションへのマッピング
mapFrom を使うと、入力イベントを発行前に変換できます。
import { KeyboardActionBinding, RotateYawAction } from '@pmndrs/viverse'
const mapped = RotateYawAction.mapFrom((e: KeyboardEvent) => {
if (e.code === 'KeyQ') return -0.02
if (e.code === 'KeyE') return 0.02
return 0
})
const binding = new KeyboardActionBinding(mapped, document.body, abortController.signal)
binding.keys = ['KeyQ', 'KeyE']
カスタムアクションの作成
import { StateAction, EventAction } from '@pmndrs/viverse'
// StateAction(mergeFn, neutralValue)
const CrouchAction = new StateAction<boolean>((a, b) => a || b, false)
// EventAction(combineFn?, neutralValue?)
const FireAction = new EventAction<void>()
StateActionにはマージ関数と中立値を必ず指定する必要があります。指定しない場合、.get()は例外をスローします(複数の書き込み元を統合するのがマージ関数の役割です)。EventActionで結合関数が必要なのは、.get()で読み取る場合のみです。通常の.emit()/.subscribe()パターンでは、引数なしの形式で問題ありません。
StateAction への書き込み
ライターを作成し、状態が変化したときに .write() を呼び出します。複数のライターは自動的にマージされます。
const crouchWriter = CrouchAction.createWriter(abortController.signal)
document.body.addEventListener(
'keydown',
(e) => {
if (e.code === 'KeyC') crouchWriter.write(true)
},
{ signal: abortController.signal },
)
document.body.addEventListener(
'keyup',
(e) => {
if (e.code === 'KeyC') crouchWriter.write(false)
},
{ signal: abortController.signal },
)
// Read merged state
const isCrouching = CrouchAction.get()
EventAction の発行
イベントが発生したときに .emit() を呼び出します。連続ポーリング(ゲームパッドなど)の場合は、前の状態を記録して、押されたときだけ発行します。
let wasPressed = false
function pollGamepad() {
const pressed = navigator.getGamepads()[0]?.buttons[0].pressed ?? false
if (pressed && !wasPressed) FireAction.emit()
wasPressed = pressed
requestAnimationFrame(pollGamepad)
}
// Or subscribe to events
FireAction.subscribe(() => console.log('Fire!'), { signal: abortController.signal })
ライフサイクル
AbortController を使用してクリーンアップを管理します。
const abortController = new AbortController()
// Create bindings...
const keyboard = new KeyboardLocomotionActionBindings(document.body, abortController.signal)
// Clean up all bindings at once
abortController.abort()
アクティブなモードに合わせて操作を制御する
ゲームには、メニュー、プレイ中、ポーズ画面、カットシーン、ゲームオーバー画面などのモードがあり、有効な操作は現在のモードに一致している必要があります。これを確実にする最も簡潔な方法は、各モードに専用の AbortController を持たせることです(ライフサイクルを参照)。モードに入ると、前のモードのコントローラーを中止して、すべてのバインディングとサブスクリプションを一度に解除し、新しいモードに必要なバインディングを構築します。存在しない操作は誤作動しないため、フラグで無効化するより確実です。
よくある問題は、その操作を使わないモードでもゲームプレイ用の操作を有効なままにしてしまうことです。起動時に lockOnClick = true を設定した PointerLockRotateZoomActionBindings を構築し、解除しないままにすると、プレイヤーがメニューボタンをクリックした瞬間にポインターをロックしてしまい、メニューが使えなくなります。この問題の解決策は、そのボタンだけに特別な処理を加えることではありません。メニューモードではゲームプレイ用の操作を一切構築しないため、ロックバインディングはプレイ開始後にのみ存在します。同様に、プレイモードのバインディングを有効なままにすると、カットシーン中も WASD やマウスルックに反応してしまいます。カットシーンモードではプレイヤー操作を持たせないようにしてください。
1 つのモードで、有効な操作とクリック可能な DOM UI の両方が本当に必要な場合(プレイ中の HUD ボタンなど)、UI の操作がポインターをロックするリスナーに届かないようにする必要があります。document.body にロックオンクリックのリスナーを設定すると、イベントがそこまでバブリングするため、どこでクリックしても発火します。バインディングの DOM ターゲットに document.body ではなくキャンバス要素を渡せば、キャンバス外のオーバーレイ UI をクリックしてもリスナーに届きません。または、UI の pointerdown ハンドラーで stopPropagation() を呼び出してください。(3D シーン内のクリック可能な UI では、オブジェクトごとのフィルタリングが必要です。[pointer-events] スキルを参照してください。)
モードが管理するのは操作だけとは限りません。カットシーンやポーズでは通常、シミュレーションも停止します(関連システムを停止・再開してください。[ecs] スキルを参照)。また、カメラの管理者を別のものに切り替える場合もあります([camera] スキルを参照)。モードが管理するものは、切り替え時にすべてまとめて切り替えてください。バグの原因はいつも、前のモードのまま残された要素です。
制限事項 — 不足機能への対応
組み込みのバインディングは、キーボード、マウス(ポインターロック)、タッチ、画面上のモバイル操作に対応しています。ゲームパッド、キーの再割り当て、ゲーム固有のアクション(しゃがむ、身をかがめる、アビリティなど)のように、組み込みで対応していないものが必要になるのは想定内です。カスタムの StateAction/EventAction を定義し、それに mapFrom バインディングを設定するか、独自の DOM/ゲームパッドリスナーで値を書き込みます(上記を参照)。アクションレイヤーはこのように拡張できる設計なので、ゲームプレイシステム内で生の入力を読み取るのではなく、カスタムアクションを使用してください。