Web 端 PvP 实时对战从零实现:匹配、同步、伤害全链路拆解

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人都准备
  })
})

坑1matchQueue 在连接回调内部定义时,每个玩家有独立队列,永远无法配对。必须提到模块级。

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'
      })
    }
  }
}

坑4window.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:

  1. raycaster.camera must be set, or sprites crash JS
  2. 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 实时对战的实现要点:

  1. 共享队列必须模块级,不能放在连接回调内部
  2. PvP 广播要绕开 普通玩家的 if (!player) return
  3. 对手 mesh 要注册到 CombatSystem 才能被射线检测
  4. HUD 用 ref 读实时值,state 给 React 渲染用
  5. 射击事件过滤 tagName === 'CANVAS',避免误触 UI

完整源码见 VR-System v3.5


📸 截图清单

  1. 匹配大厅 --- 两个玩家列表中,一个已准备 ✅
  1. PvP 竞技场 --- 蓝色自己 + 橙色对手 + AI机器人 + HUD血量
  1. 控制台日志 --- 训练场

标签:Socket.IO PvP 实时对战 WebGL 游戏开发 Three.js

相关推荐
Sunia1 小时前
《Agentx专栏》06-记忆系统:用Redis+Milvus给AI配上短期+长期双层记忆
java·架构
AI科技星1 小时前
依托Gε₀ = e²/(4παmₚ²)核心方程:全新公式推导+原创理论提炼+全维度精算验证
人工智能·线性代数·架构·概率论·学习方法
用户938515635072 小时前
前端必会:从 Fetch 到 DeepSeek,一篇搞懂 HTTP 请求的方方面面
javascript·架构
小谢小哥2 小时前
68-持续集成详解
java·后端·架构
A-刘晨阳2 小时前
数据库挂了服务就瘫?我用PostgreSQL主从流复制搭了高可用架构,cpolar打通远程访问
数据库·postgresql·架构
candyTong2 小时前
为什么 Agent Skill 不是通过向量 RAG 召回的?
架构
踩着两条虫2 小时前
开源 AI 低代码平台 VTJ.PRO 双版本齐发:核心引擎 v0.17.1 与在线平台 v2.4.1 正式上线,强化团队协作与 AI 资产管理
前端·人工智能·低代码·架构·开源
Cosolar2 小时前
RAGFlow 从入门到精通:完整学习教程
人工智能·面试·架构
heimeiyingwang2 小时前
【架构实战】API版本管理:让接口平滑演进
架构