name: vtj-input-system
description: Handles mouse, keyboard, and touch input via the IMouse and InputManager systems. Use when implementing controls, raycasting, or FPS-style PointerLock.
vite-threejs Input System
Overview
本项目使用 三层输入系统:
- IMouse:鼠标/触摸位置追踪,提供多种坐标格式
- InputManager:键盘和鼠标按钮状态,通过 mitt 发送事件
- PointerLockManager:FPS 风格鼠标锁定
核心原则:永远使用 iMouse.normalizedMouse 进行射线拾取,永远通过 mitt 事件消费输入。
When to Use
- 实现鼠标交互(点击、拖拽、悬停)
- 添加键盘控制
- 进行射线拾取(Raycasting)
- 处理相机控制输入
输入架构
Window Events (keydown/mousemove/etc)
│
▼
┌────────────────────────────────────────────────────────────┐
│ Input Layer │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ IMouse │ │ InputManager │ │ PointerLock │ │
│ │ (positions) │ │ (key states) │ │ (FPS mouse) │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼─────────────────┼─────────────────┼──────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────┐
│ mitt Event Bus │
│ input:update, input:jump, input:mouse_move │
└────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────┐
│ Consumer Layer │
│ Player, CameraRig, BlockInteraction, BlockRaycaster │
└────────────────────────────────────────────────────────────┘
IMouse:位置追踪
访问方式
// 通过 Experience 单例访问
this.iMouse = this.experience.iMouse
可用属性
| 属性 |
类型 |
说明 |
normalizedMouse |
Vector2 |
NDC 坐标 [-1, 1],用于射线拾取 |
mouse |
Vector2 |
左下角原点坐标 |
mouseDOM |
Vector2 |
DOM 坐标 (clientX, clientY) |
mouseScreen |
Vector2 |
屏幕中心相对坐标 |
mouseDOMDelta |
Vector2 |
帧间位移 |
isMouseMoving |
boolean |
鼠标是否移动中 |
射线拾取(MANDATORY PATTERN)
// ✅ ALWAYS: 使用 iMouse.normalizedMouse
const ndc = this.iMouse.normalizedMouse
this.raycaster.setFromCamera(ndc, this.camera)
const intersects = this.raycaster.intersectObjects(this.scene.children)
// ❌ NEVER: 手动计算 NDC
const x = (event.clientX / window.innerWidth) * 2 - 1 // 禁止!
const y = -(event.clientY / window.innerHeight) * 2 + 1 // 禁止!
屏幕中心射线(第一人称准星):
const CENTER_SCREEN = new THREE.Vector2(0, 0)
this.raycaster.setFromCamera(CENTER_SCREEN, this.camera)
InputManager:按键事件
事件列表
| 事件 |
触发条件 |
数据 |
input:update |
任意按键变化 |
{ forward, backward, left, right, shift, space, ... } |
input:jump |
空格键按下 |
无 |
input:punch_straight |
Z 键 |
无 |
input:punch_hook |
X 键 |
无 |
input:block |
C 键 |
{ isPressed: boolean } |
input:toggle_camera_side |
Tab 键 |
无 |
input:mouse_down |
鼠标按下 |
`{ button: 0 |
input:mouse_up |
鼠标释放 |
`{ button: 0 |
input:wheel |
滚轮 |
{ deltaY: number } |
ui:escape |
ESC 键 |
无 |
消费输入事件
import emitter from './utils/event-bus.js'
export default class Player {
constructor() {
// 保存绑定引用以便清理
this._handleInput = this._handleInput.bind(this)
this._handleJump = this._handleJump.bind(this)
emitter.on('input:update', this._handleInput)
emitter.on('input:jump', this._handleJump)
}
_handleInput(keys) {
this.inputState = keys
// keys = { forward: true, backward: false, left: false, right: true, shift: false, ... }
}
_handleJump() {
if (this.movement.isGrounded) {
this.movement.jump()
}
}
destroy() {
emitter.off('input:update', this._handleInput)
emitter.off('input:jump', this._handleJump)
}
}
PointerLockManager:FPS 鼠标
事件列表
| 事件 |
说明 |
数据 |
pointer:locked |
鼠标锁定成功 |
无 |
pointer:unlocked |
鼠标解锁 |
无 |
input:mouse_move |
相对鼠标移动 |
{ movementX, movementY } |
相机控制示例
// 在 CameraRig 中
constructor() {
this._handleMouseMove = this._handleMouseMove.bind(this)
emitter.on('input:mouse_move', this._handleMouseMove)
}
_handleMouseMove({ movementX, movementY }) {
// Y 轴:控制相机俯仰
this.mouseYVelocity += movementY * this.config.sensitivity
// X 轴:通常在 Player 中处理,控制角色朝向
}
destroy() {
emitter.off('input:mouse_move', this._handleMouseMove)
}
玩家朝向控制
// 在 Player 中
emitter.on('input:mouse_move', ({ movementX }) => {
this.targetFacingAngle -= movementX * this.config.mouseSensitivity
})
输入解析器
用于处理冲突输入(如同时按 W+S):
import { resolveDirectionInput } from './input-resolver.js'
const rawInput = { forward: true, backward: true, left: false, right: true }
const { resolvedInput, weights } = resolveDirectionInput(rawInput)
// resolvedInput = { forward: false, backward: false, left: false, right: true }
// W+S 互相抵消,只保留 D
添加新输入动作
Step 1: InputManager 中添加按键处理
// src/js/utils/input.js
updateKey(key, isDown) {
switch (key) {
case 'KeyQ':
this.keyStates.q = isDown
if (isDown) emitter.emit('input:new_action')
break
// ...
}
}
Step 2: 组件中监听事件
emitter.on('input:new_action', () => {
this.performNewAction()
})
设备检测
import { detectDeviceType } from './tools/dom.js'
const device = detectDeviceType()
if (device === 'Desktop') {
// 键鼠控制
} else {
// 触摸控制
}
Common Mistakes
❌ 手动计算 NDC 坐标
// BAD: 自己计算
onMouseMove(event) {
const x = (event.clientX / window.innerWidth) * 2 - 1
const y = -(event.clientY / window.innerHeight) * 2 + 1
this.raycaster.setFromCamera(new THREE.Vector2(x, y), this.camera)
}
// GOOD: 使用 IMouse
update() {
const ndc = this.experience.iMouse.normalizedMouse
this.raycaster.setFromCamera(ndc, this.camera)
}
❌ 直接监听 window 事件
// BAD: 绕过输入系统
window.addEventListener('keydown', (e) => {
if (e.code === 'Space') this.jump()
})
// GOOD: 使用 mitt 事件
emitter.on('input:jump', () => this.jump())
❌ 忘记清理事件监听
// BAD: 没有 destroy
constructor() {
emitter.on('input:update', this.handleInput.bind(this))
}
// GOOD: 保存引用并清理
constructor() {
this._boundHandler = this.handleInput.bind(this)
emitter.on('input:update', this._boundHandler)
}
destroy() {
emitter.off('input:update', this._boundHandler)
}
❌ 在 update() 中轮询按键
// BAD: 每帧检查按键状态
update() {
if (keyboard.isKeyDown('Space')) this.jump() // 会重复触发
}
// GOOD: 事件驱动
emitter.on('input:jump', () => this.jump()) // 只触发一次
Quick Reference
| 需求 |
使用 |
| 射线拾取坐标 |
this.iMouse.normalizedMouse |
| 按键状态 |
emitter.on('input:update', ...) |
| 单次按键动作 |
emitter.on('input:jump', ...) |
| 鼠标相对移动 |
emitter.on('input:mouse_move', ...) |
| 滚轮 |
emitter.on('input:wheel', ...) |
| 鼠标点击 |
emitter.on('input:mouse_down', ...) |
| 禁止 |
原因 |
| 手动计算 NDC |
不一致,易出错 |
| 直接监听 window 事件 |
绕过输入系统 |
| 匿名事件监听器 |
无法清理 |