关于 actions
actions 是面向使用 @pmndrs/viverse 构建游戏的开发者的输入层技能,将键盘、鼠标、触控和屏幕控件事件映射为游戏逻辑可读取的 Action。它区分持续状态与一次性事件,并支持自定义 Action 和按模式管理生命周期。
主要功能
- 01区分 StateAction 与 EventAction
- 02提供键盘、鼠标和触控绑定
- 03提供屏幕摇杆与跳跃按钮绑定
- 04支持自定义 Action 和输入映射
- 05通过 AbortController 管理绑定生命周期
适用场景
- 01为游戏角色配置键盘或触控移动
- 02将跳跃、开火等一次性输入交给系统
- 03按菜单、游戏或过场模式启停控制
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ drawcall-ai/ skills --skill actions安装后在 Claude Code 中输入/actions确认已加载。
详细介绍
使用 @pmndrs/viverse 的 Action 和 Action Binding
Action 将输入与游戏逻辑解耦。Binding 会将硬件事件转换为系统每帧使用的 Action。
输入 → Binding → Action → 游戏逻辑
本技能仅涵盖输入层。对于移动角色,意图会继续传递:输入 → Acta → 物理——系统读取这些 Action,并将移动意图交给 Acta(动画),由它生成 BvhCharacterPhysics 所需的速度。不要直接根据原始输入驱动物理控制器。请参阅 acta、physics 和 camera 技能。
Action 类型
- StateAction – 持久状态(移动、奔跑)。多个写入者通过
.get()合并为一个值。 - EventAction – 一次性事件(跳跃、开火)。通过
.emit()发出,并通过.subscribe()消费。
内置 Action
| Action | 类型 | 用途 |
|---|---|---|
MoveForwardAction, MoveBackwardAction, MoveLeftAction, MoveRightAction | State | 移动(0–1)——每帧使用 .get() 读取 |
RunAction | State | 冲刺切换——每帧使用 .get() 读取 |
JumpAction | Event | 跳跃触发器——使用 .subscribe() |
RotateYawAction, RotatePitchAction | Event | 可直接使用的旋转增量(弧度)——使用 .subscribe() 并累加 |
ZoomAction | Event | 摄像机缩放增量——使用 .subscribe() |
State 与 Event 的区别:State Action 会持续有效,直到发生变化——通过
.get()轮询。Event Action 只触发一次——使用.subscribe()捕获,否则值会丢失。
在系统中使用 Action
以下示例假定在设置阶段创建一个 const abortController = new AbortController();其 .signal 会传递给每个 Binding 和订阅,以便统一清理(请参阅末尾的生命周期)。
每帧使用 .get() 轮询 StateAction。在初始化时订阅一次 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
}
}
内置 Binding
所有内置 Binding 都接受 (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 按钮时,不得启用此 Binding,否则菜单将无法使用。请参阅将控制范围限定为当前模式。
使用旋转增量
鼠标移动时,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'))
约定:正值 pitch 表示向上旋转,正值 yaw 表示向左旋转。使用
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 类自动隐藏这些控件。
自定义按键到 Action 的映射
使用 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']
创建自定义 Action
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(参见生命周期):进入一种模式时,中止上一种模式的控制器,一次性拆除其所有绑定和订阅,并创建新模式所需的绑定。不存在的控件就不会误触发,这比用标志禁用控件更有保障。
反复出现的问题是:在不使用游戏操作控件的模式中,仍让这些控件保持启用状态。在启动时创建一个 PointerLockRotateZoomActionBindings,将 lockOnClick = true,却从未将其拆除,这样玩家一点击菜单按钮就会锁定指针,导致菜单无法使用。解决办法不是为这个按钮添加特殊处理,而是让菜单模式完全不创建游戏操作控件,因此锁定绑定只会在游戏开始后才存在。同样,如果游戏模式的绑定仍处于启用状态,过场动画也会继续响应 WASD 和鼠标视角操作;过场动画模式不应拥有玩家控件。
当某种模式确实需要同时启用游戏控件和可点击的 DOM UI(例如游戏进行中的 HUD 按钮)时,UI 交互不得传递到锁定指针的监听器。document.body 上的点击时锁定监听器会响应任意位置的点击,因为事件会冒泡到该监听器。将 canvas 元素而非 document.body 作为绑定的 DOM 目标,这样点击画布外的叠加 UI 就不会触发该监听器;或者在 UI 的 pointerdown 处理程序中调用 stopPropagation()。(对于 3D 场景内的可点击 UI,请按对象进行过滤——参见 pointer-events 技能。)
模式通常不只负责控件:过场动画或暂停通常还会冻结模拟(停止并恢复相关系统——参见 ecs 技能),也可能将相机交给其他所有者(参见 camera 技能)。切换模式时,应一并切换该模式负责的所有内容——遗留在上一种模式中的问题,总是某项功能仍处于启用状态所致。
局限性——填补空缺
内置绑定涵盖键盘、鼠标(指针锁定)、触控和屏幕移动端控件。如果需要的功能不在其中——例如游戏手柄、重映射按键或游戏专属操作(蹲伏、侧倾、技能)——这是预期情况:定义自定义 StateAction/EventAction,并使用 mapFrom 绑定或自行编写 DOM/游戏手柄监听器来写入其中(如上所示)。操作层就是为了以这种方式扩展而设计的,因此应使用自定义操作,而不是在游戏系统中读取原始输入。