《妖灵志》组队系统与战斗同步设计实录:从需求到落地的完整方案

修改于04/2363 浏览开发心得
做完多人同屏和聊天之后,下一步自然就是组队。这篇帖子记录了我在《妖灵志》中实现多人组队系统的完整过程——同屏邀请组队、好友邀请组队、世界聊天频道邀请组队,以及组队后的战斗同步。
horizontal linehorizontal line

先说背景

《妖灵志》是一款回合制 MMO 手游,之前已经实现了多人同屏同步、世界聊天、好友系统、私聊功能(详见上一篇帖子)。这一次要做的是在这些基础上,把组队和组队战斗做出来。
组队功能要解决的核心问题:
1. 怎么邀请? — 三种入口(同屏点击、好友列表、世界聊天),统一走一套事件
2. 队伍状态怎么同步? — 队长、队员、进出队、队伍信息,所有人实时一致
3. 组队战斗怎么同步? — 回合制战斗中多个玩家各控制自己的角色,行动指令汇总后统一结算
horizontal linehorizontal line

一、事件定义:先把协议定好

做聊天和好友系统时学到的经验——事件先行,逻辑后写。先在 SaveEvents.lua 里把组队相关的网络事件全部定义好:
组队事件(11个)
PARTYINVITESEND / RECV — 发送/接收组队邀请
PARTYACCEPTSEND / RECV — 接受邀请通知
PARTYDECLINESEND / RECV — 拒绝邀请通知
PARTYLEAVE — 离开队伍
PARTYKICK — 队长踢人
PARTYSYNC — 队伍状态全量同步
PARTYDISBAND — 队伍解散
PARTY_CHAT — 队伍频道聊天
战斗事件(4个)
BATTLESTARTSYNC — 战斗开始,同步初始状态
BATTLEACTIONSEND — 客户端提交本回合行动指令
BATTLETURNRESULT — 服务端广播回合结算结果
BATTLEENDSYNC — 战斗结束
一共 15 个事件,覆盖了组队的完整生命周期:邀请 → 接受/拒绝 → 队伍管理 → 战斗同步 → 战斗结束。客户端和服务端共享同一份事件名定义,改名不怕漏。
horizontal linehorizontal line

二、三种组队入口,一套邀请逻辑

组队的入口有三个地方,但最终都走同一个 PARTYINVITESEND 事件,服务端不需要关心邀请从哪来的。

入口 1:同屏点击邀请

在探索界面点击其他玩家 → 弹出交互面板 → 点击"邀请组队"。
之前 PlayerInteract.lua 里这个按钮只弹了个 Toast(占位实现)。改造后加上了真正的网络逻辑:
lua
elseif btnId == "invite_team" then
    if not IsNetworkMode_() then
        setToastMsg_({ text = "单机模式无法组队" })
        return true
    end
    -- 检查:是否队长?队伍是否已满?
    local myParty = PartySystem_.GetMyParty()
    if myParty and myParty.leaderId ~= myUserId_ then
        setToastMsg_({ text = "只有队长可以邀请" }); return true
    end
    if myParty and #myParty.members >= MAX_PARTY_SIZE then
        setToastMsg_({ text = "队伍已满" }); return true
    end
    -- 发送邀请
    local vm = VariantMap()
    vm["targetName"] = Variant(info.charName)
    vm["targetUID"]  = Variant(info.uid)
    network:GetServerConnection():SendRemoteEvent(EVENTS.PARTY_INVITE_SEND, true, vm)

入口 2:好友列表邀请

在好友面板里,每个在线好友旁边加一个"邀请组队"按钮,点击后同样发送 PARTY_INVITE_SEND。优势是不需要同屏——只要对方在线,不管在哪张地图都能邀请。

入口 3:世界聊天频道招募

分三步:
1. 队长发布招募:在聊天框点"发布招募",发送一条带 recruit 标记的消息到世界频道
2. 其他玩家看到:聊天列表中金色高亮显示招募信息,带"点击加入"按钮
3. 点击加入:向队长发送 PARTY_INVITE_SEND
lua
-- 发布招募
local msg = string.format("[组队招募] %s的队伍 (%d/%d) 招募队友,点击加入!",
    myName, currentCount, MAX_PARTY_SIZE)
local vm = VariantMap()
vm["sender"]   = Variant(myName)
vm["content"]  = Variant(msg)
vm["msgType"]  = Variant("recruit")  -- 标记为招募消息
vm["leaderId"] = Variant(myUserId_)
network:GetServerConnection():SendRemoteEvent(EVENTS.CHAT_SEND, true, vm)

三种入口对比

| 入口 | 需要同屏? | 需要好友? | 适用场景 |
|------|-----------|-----------|---------|
| 同屏点击 | 是 | 否 | 遇到陌生人想一起打怪 |
| 好友列表 | 否 | 是 | 约好友组队 |
| 世界聊天 | 否 | 否 | 广撒网找人 |
horizontal linehorizontal line

三、服务端队伍管理

数据结构

服务端用两个表维护队伍状态:
local parties_ = {}     -- partyId → { partyId, leaderId, members, maxSize, createdAt }
local playerParty_ = {} -- userId → partyId(快速查找玩家所在队伍)
每个 member 包含 userId、name、level、charImgIdx 等基本信息。

邀请处理流程

服务端收到 PARTY_INVITE_SEND 后依次检查:
1. 邀请者是否已有队伍且不是队长?→ 拒绝
2. 队伍是否已满?→ 拒绝
3. 目标是否已在其他队伍?→ 通知邀请者
4. 目标是否在线?→ 不在线则通知
全部通过后,转发 PARTYINVITERECV 给目标玩家。

接受邀请 → 创建/加入队伍

```lua
function HandlePartyAcceptSend(eventType, eventData)
    local accepter = playerStates_[connKey]
    local inviterUID = eventData["inviterUID"]:GetString()
    local inviterConn, inviterState = FindPlayerByUID(inviterUID)
    if not inviterConn then return end
    local partyId = playerParty_[inviterUID]
    if partyId then
        -- 加入现有队伍
        local party = parties_[partyId]
        if #party.members >= party.maxSize then return end
        table.insert(party.members, MakePartyMember(accepter))
    else
        -- 创建新队伍(邀请者自动成为队长)
        partyId = "party_" .. tostring(os.time()) .. "_" .. inviterUID
        parties_[partyId] = {
            partyId = partyId, leaderId = inviterUID,
            members = { MakePartyMember(inviterState) },
            maxSize = 3, createdAt = os.time(),
        }
        playerParty_[inviterUID] = partyId
        table.insert(parties_[partyId].members, MakePartyMember(accepter))
    end
    playerParty_[accepter.userId] = partyId
    -- 全量同步给所有队员
    SyncPartyToAllMembers(partyId)
end
```

为什么用全量同步?

每次队伍变化(加入、离开、踢出、队长转移)都做全量同步。因为队伍最多 3 人,数据量很小,全量简单不容易出错。增量同步要处理断线重连、消息乱序等边界情况,不值得。
horizontal linehorizontal line

四、客户端队伍管理

客户端 PartySystem 模块核心很简单:
收到邀请 → 弹出确认弹窗,加入待处理队列(避免多个邀请弹窗堆叠)
收到 PARTY_SYNC → 更新本地队伍数据,刷新 HUD
收到 PARTY_DISBAND → 清空本地队伍数据
队伍 HUD 在探索界面左上角显示队伍成员列表,包含头像、名字、血量条、队长标记。
horizontal linehorizontal line

五、组队战斗同步——回合制的天然优势

这是整个系统最核心的部分。回合制战斗做多人同步有一个巨大优势:不需要帧同步
实时动作游戏需要每帧同步位置、状态,延迟稍高就会穿模、打空。但回合制是"提交指令 → 等所有人提交 → 统一结算 → 播放动画",本质上是异步的,天然适合网络同步。

战斗流程

1. 队长遇敌触发战斗
2. 服务端创建战斗实例 → BATTLE_START_SYNC → 所有队员进入战斗界面
3. 回合循环
   - 服务端广播"请提交行动"
   - 每个玩家选择自己角色的行动指令 → BATTLE_ACTION_SEND → 服务端
   - 服务端等待所有人提交(超时 30 秒自动普攻)
   - 按速度排序,依次结算
   - BATTLE_TURN_RESULT → 所有队员播放动画
   - 检查胜负,下一回合或结束
4. BATTLE_END_SYNC → 分配奖励

服务端战斗实例

```lua
function CreateBattleInstance(partyId, enemyGroup)
    local party = parties_[partyId]
    local battleId = "battle_" .. tostring(os.time()) .. "_" .. partyId
    -- 收集所有队员的角色数据
    local allPlayerChars = {}
    local expectedCount = 0
    for _, member in ipairs(party.members) do
        local playerData = LoadPlayerBattleData(member.userId)
        table.insert(allPlayerChars, {
            userId = member.userId,
            chars  = playerData.characters,
            pets   = playerData.pets,
        })
        for _, char in ipairs(playerData.characters) do
            if char.alive then expectedCount = expectedCount + 1 end
        end
    end
    battleInstances_[battleId] = {
        battleId = battleId, partyId = partyId,
        players = allPlayerChars, enemies = enemyGroup,
        currentTurn = 1, actions = {},
        expectedCount = expectedCount,
        turnTimer = 0, state = "waiting",
    }
    BroadcastBattleStart(battleInstances_[battleId])
end
```

行动指令收集与超时

```lua
function HandleBattleActionSend(eventType, eventData)
    local battle = battleInstances_[eventData["battleId"]:GetString()]
    if not battle or battle.state ~= "waiting" then return end
    -- 解析行动:{ charIndex=1, skillId="flame_slash", targetIndex=2 }
    local playerActions = cjson.decode(eventData["actions"]:GetString())
    for _, action in ipairs(playerActions) do
        action.userId = player.userId
        table.insert(battle.actions, action)
    end
    -- 所有人都提交了 → 立即结算
    if #battle.actions >= battle.expectedCount then
        ResolveTurn(battle)
    end
end
-- 每帧检查超时(30秒)
function UpdateBattles(dt)
    for _, battle in pairs(battleInstances_) do
        if battle.state == "waiting" then
            battle.turnTimer = battle.turnTimer + dt
            if battle.turnTimer >= 30 then
                FillDefaultActions(battle) -- 未提交的自动普攻
                ResolveTurn(battle)
            end
        end
    end
end
```
超时自动普攻逻辑:找出哪些角色还没提交行动,给他们填充普攻指令,目标选第一个存活的敌人。不能让一个玩家挂机卡住所有人。

回合结算

```lua
function ResolveTurn(battle)
    battle.state = "resolving"
    -- 1. 合并玩家行动和敌人 AI 行动
    local allActions = ConcatTables(battle.actions, GenerateEnemyAI(battle))
    -- 2. 按速度排序
    table.sort(allActions, function(a, b) return GetCharSpeed(battle, a) > GetCharSpeed(battle, b) end)
    -- 3. 依次执行,记录结果
    local results = {}
    for _, action in ipairs(allActions) do
        table.insert(results, ExecuteAction(battle, action))
    end
    -- 4. 广播结算结果给所有队员
    BroadcastToParty(battle, EVENTS.BATTLE_TURN_RESULT, { results = results })
    -- 5. 检查胜负 → 下一回合或结束
    local battleOver, winnerSide = CheckBattleEnd(battle)
    if battleOver then
        battle.state = "finished"
        HandleBattleEnd(battle, winnerSide)
    else
        battle.currentTurn = battle.currentTurn + 1
        battle.actions = {}
        battle.turnTimer = 0
        battle.state = "waiting"
        battle.expectedCount = CountAlivePlayerChars(battle)
    end
end
```

客户端动画播放

客户端收到 BATTLE_TURN_RESULT 后,把所有行动结果放进动画队列,逐条播放。每条记录包含施法者、技能、目标、伤害/治疗值、暴击标记等。动画播完后开放下一回合的输入。

为什么没有延迟感?

1. 输入阶段是异步的 — 每个玩家各自选择行动,不需要实时同步
2. 等待是游戏规则的一部分 — "等所有人选完"本身就是回合制的规则,和网络延迟无关
3. 结算是确定性的 — 所有客户端收到同样的结果,播放同样的动画,不存在状态不一致
4. 动画播放是本地的 — 不依赖网络
唯一可能感知到延迟的地方是"提交行动后等其他人",但 30 秒超时自动普攻兜底了。
horizontal linehorizontal line

六、异常处理

队长退出

队长退出时自动转移给队伍中下一个队员。如果最后一个人也走了,队伍自动解散。

战斗中掉线

掉线玩家的角色转为 AI 自动战斗,不中断其他人的战斗体验。给 60 秒重连窗口,重连后可以重新接管自己的角色。
lua
function HandlePlayerDisconnect(userId)
    local partyId = playerParty_[userId]
    if not partyId then return end
    -- 如果在战斗中,角色改为 AI 控制
    local battleId = FindBattleByParty(partyId)
    if battleId then
        MarkPlayerAsAI(battleInstances_[battleId], userId)
    end
    -- 标记离线,60 秒后未重连才正式移除
    MarkMemberOffline(partyId, userId)
    SetTimeout(60, function()
        if not IsPlayerOnline(userId) then
            HandlePartyLeave(partyId, userId)
        end
    end)
end
horizontal linehorizontal line

七、关键设计决策

| 决策点 | 选择 | 理由 |
|--------|------|------|
| 队伍最大人数 | 5人 | 回合制5人队伍才能把配合发挥最大化 |
| 同步方式 | 全量同步 | 队伍数据量小,全量简单可靠 |
| 战斗架构 | 服务端权威 | 防作弊,保证结果一致 |
| 超时处理 | 30 秒自动普攻 | 平衡等待时间和体验 |
| 掉线处理 | AI 接管 + 60 秒重连 | 不影响其他队员 |
| 邀请入口 | 三种统一事件 | 减少服务端分支 |
horizontal linehorizontal line

八、踩过的坑

1. 队伍 ID 冲突:最初用 os.time() 作为队伍 ID,同一秒内创建两支队伍就冲突了。改成 os.time() + 队长 UID 拼接。
2. 战斗中断线的时序问题:断线事件和行动提交可能同时到达。如果先处理断线把人踢了,再收到他最后的行动会报错。解决方法是断线时只标记状态,不立即清理数据,等当前回合结算完再处理。
3. 邀请弹窗堆叠:同时收到多个邀请时弹窗叠在一起。改成队列处理,一次只显示一个,处理完再弹下一个。
4. 战斗结果的确定性:客户端和服务端都有战斗计算代码,各自随机数会导致结果不一致。最终改为所有计算都在服务端做,客户端只负责播放动画和显示数字。
horizontal linehorizontal line

九、总结

| 文件 | 职责 |
|------|------|
| SaveEvents.lua | 新增 15 个组队/战斗事件 |
| server_main.lua | 队伍管理 + 战斗实例 + 指令结算 |
| game/PartySystem.lua | 客户端队伍状态 |
| ui/PartyHUD.lua | 队伍 HUD |
| ui/PlayerInteract.lua | 同屏邀请入口 |
| ui/FriendOverlay.lua | 好友邀请入口 |
| ui/ChatOverlay.lua | 聊天招募入口 |
| game/BattleManager.lua | 客户端战斗动画 |
组队系统核心在于想清楚事件流和异常处理。回合制的特性让战斗同步变得简单——不需要帧同步,不需要预测回滚,只要"收集指令 → 统一结算 → 广播结果"就行。
希望这些思路能帮到你。有问题欢迎评论区聊。
猜你想搜
taptap 制造组队系统实现
5
2