Web 端 PvP 实时对战从零实现:匹配、同步、伤害全链路拆解
两个浏览器标签页如何实现实时对战?从匹配大厅到血量同步,全链路技术实现。
前言
多人实时对战是游戏开发中最具挑战性的环节之一。本文完整拆解我在 "黑客帝国 VR 系统" 中实现 PvP 对战的全过程------包含 匹配配对 → Socket.IO 房间 → 位置同步 → 伤害计算 → HUD 实时更新 五个环节。
每个环节都有可运行的代码和踩过的坑。
一、整体架构
scss
┌─ 浏览器A ─┐ ┌─ 浏览器B ─┐
│ MatchLobby │ │ MatchLobby │
│ ↓ 准备 │ │ ↓ 准备 │
└────┬───────┘ └────┬───────┘
│ match-ready │ match-ready
└──────┬──────────────┘
↓
┌─────────────┐
│ Socket.IO │ matchQueue (模块级共享)
│ Server │ checkMatchStart()
└──────┬──────┘
↓ match-start (双方)
┌──────┴──────┐
↓ ↓
┌─ PvPArena ─┐ ┌─ PvPArena ─┐
│ 蓝色(自己) │ │ 蓝色(自己) │
│ 橙色(对手) │ │ 橙色(对手) │
│ player-move │ │ player-move │
└──────┬──────┘ └──────┬──────┘
│ player-hit │
└──────┬──────────┘
↓
player-damaged
(双方实时扣血)
二、匹配大厅:两个标签页如何找到彼此?
2.1 前端 MatchLobby 组件
tsx
export const MatchLobby: React.FC<Props> = ({ onStart, onBack }) => {
const socket = io('http://localhost:4000')
useEffect(() => {
socket.emit('match-join', { name: 'Player_' + randomId() })
socket.on('match-players', (list) => setPlayers(list))
socket.on('match-start', (opponent) => {
onStart(socket, opponent) // 传递 socket 给 PvP 场景
})
}, [])
// ...
}
关键设计 :匹配成功后,把同一个 socket 实例传给 PvPArena,避免重复连接。
2.2 服务端匹配队列
typescript
// ❌ 错误写法:定义在 io.on('connection') 内部
io.on('connection', (socket) => {
const matchQueue = new Map() // 每个连接独立!互相看不见
})
// ✅ 正确写法:定义在模块级,所有连接共享
const matchQueue: Map<string, MatchEntry> = new Map()
io.on('connection', (socket) => {
socket.on('match-join', (data) => {
matchQueue.set(socket.id, { ... })
broadcastMatchPlayers()
})
socket.on('match-ready', (ready) => {
// ...更新状态
checkMatchStart() // 检查是否2人都准备
})
})
坑1 :matchQueue 在连接回调内部定义时,每个玩家有独立队列,永远无法配对。必须提到模块级。
2.3 配对逻辑
typescript
const checkMatchStart = () => {
const readyPlayers = [...matchQueue.values()].filter(e => e.ready)
if (readyPlayers.length >= 2) {
const [p1, p2] = readyPlayers.slice(0, 2)
matchQueue.delete(p1.id)
matchQueue.delete(p2.id)
p1.socket.emit('match-start', { id: p2.id, name: p2.name })
p2.socket.emit('match-start', { id: p1.id, name: p1.name })
}
}
三、Socket.IO 房间:位置如何广播?
匹配成功后,双方进入 PvP 场景。位置同步是关键。
3.1 客户端:发出位置
tsx
// 动画循环中每帧执行
if (socketRef.current?.connected) {
socketRef.current.emit('player-move', {
position: { x: ppos.x, y: ppos.y, z: ppos.z },
rotation: { x: 0, y: 0 },
isMoving: keys.has('w') || keys.has('a') || keys.has('s') || keys.has('d')
})
}
3.2 服务端:房间内广播
typescript
socket.on('player-move', (data) => {
// PvP 玩家检查(必须在普通房间检查之前!)
const pvpPlayer = pvpManager.getPlayer(socket.id)
if (pvpPlayer) {
socket.to(pvpPlayer.roomId).emit('player-move', {
id: socket.id,
position: data.position,
...
})
}
// 普通房间玩家...
})
坑2 :服务端原有的 if (!player) return 在 PvP 广播代码前面 。PvP 玩家注册在 PvPManager 而非 players Map,被直接拦截了。必须把 PvP 广播移到 return 之前。
3.3 客户端:接收对手位置
tsx
socket.on('player-move', (data: any) => {
if (data.id === playerIdRef.current) return // 忽略自己
let rm = remotePlayers.current.get(data.id)
if (!rm) {
// 首次发现对手,创建橙色人形
const m = new HumanoidModel(0xff8800)
m.mesh.position.set(5, 0, 5) // 初始偏移避免重叠
scene.add(m.mesh)
rm = { mesh: m.mesh, id: data.id }
remotePlayers.current.set(data.id, rm)
// 注册到战斗系统(否则射线检测不到!)
combatRef.current?.addAgent(data.id, {
id: data.id, mesh: m.mesh, position: m.mesh.position, health: 100
})
}
rm.mesh.position.set(data.position.x, data.position.y, data.position.z)
combatRef.current?.updateAgentPosition(data.id, rm.mesh.position)
})
坑3 :对手的 mesh 添加到场景后,必须在 CombatSystem 中注册,否则 raycaster.intersectObjects() 不会命中。
四、伤害同步:射击→扣血→HUD 全链路
4.1 客户端射击
tsx
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return
if ((e.target as HTMLElement).tagName !== 'CANVAS') return // 忽略 UI 点击
const result = combatRef.current.fire()
if (result?.hit) {
const targetId = (result.target as any)?.id
if (targetId) {
socketRef.current.emit('player-hit', {
targetId, damage: 10, weapon: 'pistol'
})
}
}
}
坑4 :window.addEventListener('mousedown', ...) 会在点击导航按钮时也触发射击。必须检查 event.target.tagName === 'CANVAS'。
4.2 服务端处理伤害
typescript
socket.on('player-hit', (data) => {
const target = pvpManager.getPlayer(data.targetId)
if (!target) return
target.hp = Math.max(0, target.hp - data.damage)
// 广播扣血给房间所有人
io.to(target.roomId).emit('player-damaged', {
targetId: data.targetId,
attackerId: socket.id,
attackerName: attackerName,
damage: data.damage,
targetHp: target.hp,
weapon: data.weapon
})
})
4.3 客户端接收伤害 + HUD 更新
tsx
// ❌ 错误:用 useState,动画循环读到闭包旧值
const [myHP, setMyHP] = useState(100)
// ✅ 正确:useRef + useState 双写
const myHPRef = useRef(100)
const [myHP, setMyHP] = useState(100)
socket.on('player-damaged', (d) => {
if (d.targetId === playerIdRef.current) {
myHPRef.current = d.targetHp // ref 立即更新
setMyHP(d.targetHp) // state 触发 React 重渲染
}
})
// 动画循环中读 ref(永远是最新值)
scorePanel.innerHTML = `❤️ ${myHPRef.current} | 💀 ${killsRef.current}`
坑5 :动画循环 loop() 在 useEffect([], []) 中只创建一次。其闭包中的 myHP 永远是初始值 100。必须用 useRef 作为实时数据通道。
五、战斗系统:射线检测的注意事项
typescript
fire(): HitResult | null {
// 必须设置 camera,否则射线遇到 Sprite 会崩溃
this.raycaster.camera = this.camera
this.raycaster.set(origin, direction)
// 遍历所有注册的代理
const meshList = []
this.agents.forEach((agent) => {
if (agent.mesh) meshList.push(agent.mesh)
})
const intersects = this.raycaster.intersectObjects(meshList, true)
// ...
}
Two critical points:
raycaster.cameramust be set, or sprites crash JS- Opponent meshes must be registered via
addAgent(), or raycaster won't detect them
六、完整 PvP 流程总结
sql
步骤1: 匹配
标签A 点"匹配对战" → match-join → matchQueue.add(A)
标签B 点"匹配对战" → match-join → matchQueue.add(B)
双方点"准备" → match-ready → checkMatchStart()
→ 2人都准备 → match-start → 跳转 PvPArena
步骤2: 初始化
PvPArena 收到 matchSocket → pvp-join → 加入房间 "pvp-arena"
→ pvp-init → 获取 playerId
→ player-move 每帧发出位置
步骤3: 对战
射击 → CombatSystem.fire() → 命中 → player-hit
→ 服务端扣血 → player-damaged 广播
→ myHPRef.current 更新 → HUD 实时刷新
总结
PvP 实时对战的实现要点:
- 共享队列必须模块级,不能放在连接回调内部
- PvP 广播要绕开 普通玩家的
if (!player) return - 对手 mesh 要注册到 CombatSystem 才能被射线检测
- HUD 用 ref 读实时值,state 给 React 渲染用
- 射击事件过滤
tagName === 'CANVAS',避免误触 UI
完整源码见 VR-System v3.5。
📸 截图清单:
- 匹配大厅 --- 两个玩家列表中,一个已准备 ✅

- PvP 竞技场 --- 蓝色自己 + 橙色对手 + AI机器人 + HUD血量

- 控制台日志 --- 训练场
标签:Socket.IO PvP 实时对战 WebGL 游戏开发 Three.js




