mcpField
A
actions

actions

github.com/drawcall-ai开发120 次安装
代码仓库

关于 actions

actions 是面向使用 @pmndrs/viverse 构建游戏的开发者的输入层技能,将键盘、鼠标、触控和屏幕控件事件映射为游戏逻辑可读取的 Action。它区分持续状态与一次性事件,并支持自定义 Action 和按模式管理生命周期。

主要功能

  1. 01区分 StateAction 与 EventAction
  2. 02提供键盘、鼠标和触控绑定
  3. 03提供屏幕摇杆与跳跃按钮绑定
  4. 04支持自定义 Action 和输入映射
  5. 05通过 AbortController 管理绑定生命周期

适用场景

  1. 01为游戏角色配置键盘或触控移动
  2. 02将跳跃、开火等一次性输入交给系统
  3. 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, MoveRightActionState移动(0–1)——每帧使用 .get() 读取
RunActionState冲刺切换——每帧使用 .get() 读取
JumpActionEvent跳跃触发器——使用 .subscribe()
RotateYawAction, RotatePitchActionEvent可直接使用的旋转增量(弧度)——使用 .subscribe() 并累加
ZoomActionEvent摄像机缩放增量——使用 .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/游戏手柄监听器来写入其中(如上所示)。操作层就是为了以这种方式扩展而设计的,因此应使用自定义操作,而不是在游戏系统中读取原始输入。

评论

支持 Markdown