下载 App

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

修改于04/2366 浏览开发交流
做完多人同屏和聊天之后,下一步自然就是组队。这篇帖子记录了我在《妖灵志》中实现多人组队系统的完整过程——同屏邀请组队、好友邀请组队、世界聊天频道邀请组队,以及组队后的战斗同步。
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 制造组队系统实现
03:06
为了不被斩杀,我只能做到这些了截图
为了不被斩杀,我只能做到这些了
之前一直申请不到计划3,之前说太花哨,后面说体量完成度不足,在我一番小作文下,编辑终于给了我解答,什么原生质感,玩家开局体验啥的。 本来计划2也够用,准备以后再优化了,结果计划2赠送的积分要砍半了,学生党实在消费不起,于是只能再冲一波计划3了 从周六中午到现在一天半时间,优化了一遍角色卡,战斗卡,还有抽卡界面,优化了新手教程,该说不说确实比之前要舒服些。 前半部分为新版,后半部分为旧版。 明天再次
45 赞
9 回复
【新游上线】末日100天截图
【新游上线】末日100天
大家好,我们是《末日100天》的开发者Paul~ 这是一款围绕「生存-经营-轮回」展开的废土策略小游戏:白天分配 3 点行动力经营据点,夜里迎接十天一循环的尸潮,每张地图第 100 天还有一场首领战等着你。五张地图、五大区域机制、可继承的轮回遗产,以及钻石商城里的抽奖机——我们希望每次重开都有一点点新意。 以下是游戏简单介绍: 1.游戏背景 废土历元年,未知病毒席卷全球,城市化为死地。幸存者们退守
9 赞
6 回复
别再烧积分了!从每次 2000 到每次 50 的血泪经验截图
别再烧积分了!从每次 2000 到每次 50 的血泪经验
适用场景:所有在用 TapTap Code 开发游戏的小白 难度:★☆☆☆☆ 背景最近群里总有人"炫耀"一次操作花了几千积分,说实话这不是值得高兴的事——这是在告诉所有人你的开发方式有根本性问题。我也走过这条弯路,所以来说说怎么改。 积分消耗的本质是什么积分 = AI 处理的 token 数量 = 你发给 AI 的内容 + AI 回给你的内容所以烧积分的本质只有两种: 你让 AI 做了大量无效/重
精华
68 赞
21 回复
如何提升你游戏中的UI美术水平截图
如何提升你游戏中的UI美术水平
一、请为你的游戏挑选一款合适的字体 很多开发者没有注意到其实UI界面中最重要的元素是字体,一款合适的字体能给整个游戏的界面感官带来蜕变。 请看案例 那么如何挑选适合自己的字体呢? 我总结出了一些小经验可供参考: 黑体:通用 宋体:古风、武侠、修仙 楷体:古风、武侠、修仙 圆体:卡通、动漫、Q版 科技:科幻、未来、电子 顺手分享一个免费可商用字体网站: 当然 如果你不知道自己的游戏适合什么风格
精华
124 赞
44 回复
《灵植仙途》开发心得—代码小白,用TapTap,7天做出游戏雏形截图
《灵植仙途》开发心得—代码小白,用TapTap,7天做出游戏雏形
大家好,我是《灵植仙途》的制作者,首先感谢官方大大给的测试资格。我是一个彻彻底底完完全全的代码小白。变量、函数、循环这些概念我有所耳闻,但真要我从零敲出一行Lua代码,十有八九会缺逗号少括号。 那这款有70种灵植、52位NPC、63件法器、5种结局的修仙种田游戏是怎么做出来的? 答案是:我用详细的设计文档,驱动TapTap制造的AI帮我写完了全部代码。 7天时间,204次构建,约24400行
11 赞
3 回复
开发心得-界面布局心得截图
开发心得-界面布局心得
大家好,我正在用 TapTap 制造制作一款2D 治愈林间徒步旅行游戏《徒步荒野》。 我分享一下我的开发心得,希望能有用,下面是一些界面布局的心得。 第一步:定版式、操作 先确定游戏是横版还是竖版、需要哪些操作按钮,敲定整体玩法思路。 我的玩法灵感参考《边境之旅》,主打散步看风景、沿途触发随机事件。 原版是 3D,考虑到自己是新手,选择2D 横版街机式来做,制作更简单、好上手。 第二步:敲定全局美
6 赞
3 回复
2D 等轴测视角:完整理论截图
2D 等轴测视角:完整理论
2D 等轴测视角:完整理论 一、视角的本质 什么是等轴测 人站在高处斜着往下看地面,地面上的方格子不再是正方形,而变成了菱形。这就是等轴测的视觉本质。 俯视(正上方看下去): 等轴测(斜上方看下去): ┌──┬──┬──┐ ◇ ├──┼──┼──┤ ◇ ◇ ├──┼──┼──┤ ◇ ◇ ◇ └──┴──┴──┘ ◇ ◇ ◇ 正方形网格经过旋转 + 压缩,变成了菱形网格。 为什么是 2:1
6 赞
创作资源申请《今天也元气满满》截图
创作资源申请《今天也元气满满》
游戏名称:今天也元气满满 开发工具:TapTap制造 类型:文字 / 克苏鲁 × 职场讽刺 当前版本:v1.2(v1.3 开发中,核心系统已实装) 周一 闹钟响了。理智-10。 到公司。打卡。茶水间海报写着「态度决定一切」。理智-1。 领导说"开个短会"。理智-5。 ——如果上班是一款克苏鲁游戏,大概就是这种体验。 我们把它做出来了。 这游戏玩的是什么? 你是一家制药公司的HR。公司里大部分人的性
9 赞
1 回复
游戏自荐《少女终焉契约》截图
游戏自荐《少女终焉契约》
之前玩放置类型的rpg游戏的时候我就在想,现在游戏的操作有的太过于繁琐了,有没有一款休闲一点的,想上线就上线,离线的时候角色能自动行动的游戏呢?感谢TapTap制造,我按照我的想法开发出了现在这款游戏。 一、故事背景和UI 游戏的故事背景是传统的异世界穿越故事,玩家被女神召唤到异世界,随后组建工会,招募美少女,打怪升级探索世界。所以我选择了更加二次元萌系画风的UI风格,主页为各个主要的功能,点开来
8 赞
3 回复
塔拉拉20万分经验分享截图
塔拉拉20万分经验分享
前言 不是教程也不是官方,只是分享一下自己开发过程中遇到的问题和经验。 自上个月拿到资格后,便废寝忘食开始了塔拉拉的无代码做游戏探索,一开始想做一个简单的游戏先试试效果,然后就开始了一个本来是小游戏却消耗近20w分的项目,先说说这其中遇到的问题和积分消耗大户。 一、开始开发 本地新建文件夹,写好你这个游戏的策划案,主文件介绍核心玩法(重中之重)、美术风格,然后分文件介绍游戏中的各种功能/系统,做好
76 赞
19 回复