斗框架:TurnManager與ActionQueue調(diào)度解析)
簡介CocoKnightManager 是一套基于 Cocos Creator 引擎的開源回合制游戲系統(tǒng)設(shè)計工程面向希望掌握回合制玩法架構(gòu)的初中級游戲開發(fā)者與獨立團隊。項目以模塊化為核心將回合管理器、行動規(guī)則引擎與角色狀態(tài)機拆分為獨立組件并通過 JSON 等數(shù)據(jù)配置驅(qū)動屬性、效果與事件便于靈活組合與二次擴展。壓縮包共 22 個文件約 216KB包含 7 個 json 配置、7 個 meta 資源描述、3 張 png 貼圖、2 個 ts 腳本及 fire、md 等工程與說明文件完整呈現(xiàn)了場景、腳本與配置的組織方式。目前已有 836 人學習下載。讀者可從中獲取回合流程控制、行動規(guī)則抽象與狀態(tài)切換的參考實現(xiàn)理解數(shù)據(jù)驅(qū)動設(shè)計如何降低內(nèi)容調(diào)整成本并借助開源代碼快速搭建自己的回合制原型節(jié)省底層系統(tǒng)搭建時間。1. 回合制戰(zhàn)斗的骨架CocoKnightManager 到底管了什么很多回合制項目死在“戰(zhàn)斗邏輯和界面糊在一起”上按鈕點一下傷害計算、動畫播放、狀態(tài)刷新全塞在同一個回調(diào)里改一個技能要翻五個文件。CocoKnightManager 這個開源工程就是沖著這個痛點來的它把回合制游戲里最容易失控的那部分——回合調(diào)度、行動隊列、技能結(jié)算、狀態(tài)機切換——單獨抽成一套可復用的系統(tǒng)層Cocos Creator 只負責表現(xiàn)。換句話說它管的不是“一個騎士長什么樣”而是“誰先動、動了之后世界變成什么樣”。適合兩類人手里有 Cocos Creator 基礎(chǔ)、想做一個能擴展的回合制 Demo 的開發(fā)者以及已經(jīng)寫過一版戰(zhàn)斗邏輯、被狀態(tài)同步和技能疊加折磨過、想看看別人怎么分層的人。系統(tǒng)開源代碼結(jié)構(gòu)比功能數(shù)量更值得看。2. 工程結(jié)構(gòu)與技術(shù)選型為什么把戰(zhàn)斗邏輯從節(jié)點樹里拽出來2.1 目錄分層表現(xiàn)層、系統(tǒng)層、數(shù)據(jù)層三刀切拿到工程先別急著點運行把目錄掃一遍能省掉后面一半的迷茫。這個項目的目錄不是按“場景/預制體/腳本”這種引擎默認習慣堆的而是按職責切assets/ scripts/ core/ # 回合調(diào)度器、行動隊列、事件總線 TurnManager.ts ActionQueue.ts EventBus.ts battle/ # 戰(zhàn)斗規(guī)則傷害公式、技能結(jié)算、buff 疊加 BattleContext.ts SkillResolver.ts BuffSystem.ts units/ # 單位數(shù)據(jù)與運行時狀態(tài) UnitModel.ts UnitState.ts ui/ # 純表現(xiàn)血條、行動條、飄字 BattleHUD.ts TurnIndicator.ts prefabs/ units/ # 單位預制體只掛表現(xiàn)腳本 data/ skills.json # 技能配置邏輯層讀取 units.json # 單位基礎(chǔ)屬性core不 import 任何cc的 UI 組件battle只依賴core和unitsui單向依賴battle暴露的事件。這條依賴方向是整個工程能二次開發(fā)的前提——你想換一套美術(shù)表現(xiàn)只動ui和prefabs規(guī)則層一行不用碰。2.2 技術(shù)棧與版本假設(shè)TypeScript 事件驅(qū)動工程用 TypeScript 寫Cocos Creator 3.x 的組件體系。為什么強調(diào) TS回合制的狀態(tài)字段多當前 HP、行動值、buff 剩余回合、技能冷卻用any滿天飛的 JS 寫兩周后自己都不記得state.t是回合數(shù)還是目標。項目里UnitState是個明確的接口// units/UnitState.ts export interface UnitState { id: string; // 單位唯一標識戰(zhàn)斗內(nèi)不變 hp: number; // 當前生命 maxHp: number; actionValue: number; // 行動值越小越先動 buffs: BuffInstance[]; // 當前掛載的 buff 列表 cooldowns: Recordstring, number; // 技能 id - 剩余冷卻回合 }字段含義直接決定調(diào)度邏輯actionValue用“越小越先動”而不是“速度越大越先動”是為了讓插入行動比如某個技能讓目標立刻再動一次變成簡單的數(shù)值減法不用重排整個隊列。這是回合制里很常見的一個設(shè)計取舍新手容易一開始用速度排序后面加“推條/拉條”技能時就得推倒重來。2.3 事件總線UI 不輪詢邏輯不碰節(jié)點EventBus是個極簡的發(fā)布訂閱沒有引入第三方庫。核心就三個方法on、off、emit。戰(zhàn)斗層算完一次傷害emit(unit:damaged, payload)UI 層監(jiān)聽后播飄字、刷血條。好處是邏輯層完全不知道血條節(jié)點叫什么名字測試時甚至可以不起場景直接跑純邏輯。// core/EventBus.ts type Handler (payload?: any) void; export class EventBus { private static handlers: Mapstring, Handler[] new Map(); static on(event: string, fn: Handler) { const list this.handlers.get(event) ?? []; list.push(fn); this.handlers.set(event, list); } static off(event: string, fn: Handler) { const list this.handlers.get(event); if (!list) return; this.handlers.set(event, list.filter(h h ! fn)); // 防止重復注冊導致回調(diào)疊加 } static emit(event: string, payload?: any) { this.handlers.get(event)?.forEach(fn fn(payload)); } }參數(shù)說明event用模塊:動作的命名約定unit:damaged、turn:start、skill:cast避免不同模塊撞名payload不約束類型是刻意的簡化但實際項目里建議按事件名建一個 payload 類型映射表否則半年后沒人記得turn:start帶的是回合數(shù)還是單位 id。這里就是典型的“先跑通再收緊”的寫法。3. 回合調(diào)度核心TurnManager 與 ActionQueue 怎么配合3.1 行動隊列用最小堆還是排序數(shù)組回合制的“誰先動”本質(zhì)是個優(yōu)先隊列問題。項目里ActionQueue用的是排序數(shù)組而不是堆原因很實際一場戰(zhàn)斗通常 612 個單位每次取最小值時sort一下的開銷可以忽略而數(shù)組在調(diào)試時能直接console.log看到完整順序堆看不到。這是典型的“規(guī)模決定數(shù)據(jù)結(jié)構(gòu)”的取舍別一上來就上堆。// core/ActionQueue.ts import { UnitState } from ../units/UnitState; export class ActionQueue { private units: UnitState[] []; push(unit: UnitState) { this.units.push(unit); } // 取出行動值最小的單位行動值相同則按 id 穩(wěn)定排序 popNext(): UnitState | undefined { this.units.sort((a, b) a.actionValue ! b.actionValue ? a.actionValue - b.actionValue : a.id.localeCompare(b.id) ); return this.units.shift(); } // 行動結(jié)束后把單位放回隊列并推進其行動值 requeue(unit: UnitState, baseActionValue: number) { unit.actionValue baseActionValue; // 累加而非重置保證速度差異體現(xiàn)在長期頻率上 this.units.push(unit); } peekAll(): UnitState[] { return [...this.units].sort((a, b) a.actionValue - b.actionValue); } }關(guān)鍵在requeue里的而不是。如果每次行動后把行動值重置成固定值那速度屬性就只影響第一次出手后面大家輪流坐莊回合制的節(jié)奏感就沒了。累加的做法讓“速度 120 的單位每 100 行動值動一次、速度 80 的每 150 動一次”這種頻率差異自然浮現(xiàn)。peekAll是給 UI 畫行動條用的返回副本防止外部改到內(nèi)部數(shù)組。3.2 TurnManager一個回合的完整生命周期TurnManager是調(diào)度的大腦它不關(guān)心傷害怎么算只負責“輪到誰、什么時候開始、什么時候結(jié)束”。一個回合的鏈路是turn:start→ 取隊首單位 → 檢查能否行動眩暈等控制→ 交給外部決策玩家輸入或 AI→ 執(zhí)行行動 →turn:end→ 單位回隊 → 下一回合。// core/TurnManager.ts import { ActionQueue } from ./ActionQueue; import { EventBus } from ./EventBus; import { UnitState } from ../units/UnitState; export class TurnManager { private queue new ActionQueue(); private round 0; private current: UnitState | null null; init(units: UnitState[]) { units.forEach(u this.queue.push(u)); } startNextTurn(): UnitState | null { const next this.queue.popNext(); if (!next) return null; this.current next; this.round; EventBus.emit(turn:start, { unit: next, round: this.round }); // 控制類狀態(tài)在這里攔截而不是散落在各技能里 if (this.isStunned(next)) { EventBus.emit(turn:skipped, { unit: next }); this.endTurn(); return this.startNextTurn(); // 遞歸推進到下一個可行動單位 } return next; } endTurn() { if (!this.current) return; EventBus.emit(turn:end, { unit: this.current }); this.queue.requeue(this.current, this.current.baseActionValue ?? 100); this.current null; } private isStunned(u: UnitState): boolean { return u.buffs.some(b b.type stun b.remaining 0); } }這里有兩個值得抄的點。第一控制狀態(tài)眩暈、冰凍統(tǒng)一在startNextTurn里攔截而不是讓每個技能自己判斷“目標能不能動”否則新增一種控制就要改 N 個技能。第二被跳過回合的單位仍然走endTurn回隊保證行動值正常推進不會因為被控就永久掉隊。遞歸調(diào)用startNextTurn在極端情況下全隊被控會棧溢出實際項目里建議改成while循環(huán)這是我在類似結(jié)構(gòu)上翻過車的地方。3.3 和 UI 的對接行動條只讀 peekAllTurnIndicator這個 UI 組件每幀或每次turn:start時調(diào)queue.peekAll()拿排序后的單位列表按順序擺頭像。它不持有任何調(diào)度邏輯也不改隊列。這種“UI 只讀、邏輯只寫”的邊界是后面加“預覽未來三回合行動順序”功能時不用動核心代碼的原因。如果你接手后發(fā)現(xiàn)行動條和實際出手順序?qū)Σ簧习顺墒?UI 自己維護了一份順序而不是讀peekAll。4. 技能結(jié)算與 Buff 系統(tǒng)傷害公式和狀態(tài)疊加怎么不打架4.1 SkillResolver把“技能”拆成配置加解析器技能不寫成一個個類而是skills.json里的數(shù)據(jù) 一個統(tǒng)一的SkillResolver解析。這樣加新技能是改配置不是加文件。配置長這樣{ id: slash, name: 橫斬, targetType: enemy_single, effects: [ { type: damage, power: 1.2, scaling: atk }, { type: buff, buffId: bleed, chance: 0.3, duration: 2 } ], cooldown: 2 }effects是數(shù)組意味著一個技能可以同時造成傷害和掛 buff順序執(zhí)行。chance讓概率類效果不用寫進代碼。解析器按type分發(fā)// battle/SkillResolver.ts import { BattleContext } from ./BattleContext; import { BuffSystem } from ./BuffSystem; export class SkillResolver { static resolve(ctx: BattleContext, casterId: string, targetId: string, skill: any) { const caster ctx.getUnit(casterId); const target ctx.getUnit(targetId); if (!caster || !target) return; for (const effect of skill.effects) { if (effect.chance ! undefined Math.random() effect.chance) continue; switch (effect.type) { case damage: { const base caster.atk * effect.power; // 攻擊力 × 倍率 const reduced base * (1 - target.def / (target.def 100)); // 防御減傷100 是調(diào)參常數(shù) const final Math.max(1, Math.round(reduced)); // 保底 1 點防止高防無敵 target.hp - final; ctx.emit(unit:damaged, { unitId: targetId, amount: final, source: casterId }); break; } case buff: BuffSystem.apply(target, effect.buffId, effect.duration ?? 1); break; } } } }防御減傷用def / (def 100)而不是直接減法是為了避免“防御堆到超過攻擊力就完全免傷”的數(shù)值崩壞這是回合制里最常見的公式選擇。Math.max(1, ...)的保底也是血淚經(jīng)驗沒有它高防單位會讓戰(zhàn)斗變成無限平局。常數(shù) 100 是調(diào)參入口想讓你游戲前期節(jié)奏快就調(diào)小后期數(shù)值膨脹就調(diào)大。4.2 BuffSystem疊加規(guī)則必須顯式定義Buff 最容易出 bug 的地方不是“加沒加上”而是“同一個 buff 加兩次會怎樣”。項目里BuffInstance帶remaining和stacks疊加策略在BuffSystem.apply里按 buff 類型查表// battle/BuffSystem.ts import { UnitState } from ../units/UnitState; interface BuffDef { id: string; type: stun | bleed | atk_up; stackMode: refresh | stack | ignore; // 疊加策略 maxStacks?: number; } export class BuffSystem { static apply(unit: UnitState, buffId: string, duration: number) { const def BuffSystem.getDef(buffId); const existing unit.buffs.find(b b.id buffId); if (!existing) { unit.buffs.push({ id: buffId, type: def.type, remaining: duration, stacks: 1 }); return; } switch (def.stackMode) { case refresh: // 只刷新持續(xù)時間不疊層 existing.remaining Math.max(existing.remaining, duration); break; case stack: // 疊層受上限約束 existing.stacks Math.min((existing.stacks ?? 1) 1, def.maxStacks ?? 99); existing.remaining Math.max(existing.remaining, duration); break; case ignore: // 已存在則不再施加 break; } } // 每回合結(jié)束時統(tǒng)一遞減避免各技能自己減導致漏減或雙減 static tickAll(unit: UnitState) { unit.buffs.forEach(b b.remaining--); unit.buffs unit.buffs.filter(b b.remaining 0); } private static getDef(id: string): BuffDef { // 實際項目從 buffs.json 讀這里給默認值示意 return { id, type: bleed, stackMode: refresh }; } }tickAll統(tǒng)一在turn:end時對全場單位調(diào)用一次而不是讓每個 buff 自己記時。這樣“持續(xù) 2 回合”的語義是確定的施加當回合不算之后兩個回合結(jié)束各減一次。很多項目 buff 時長錯亂就是因為施加時機和遞減時機分散在不同地方。4.3 BattleContext一次戰(zhàn)斗的共享狀態(tài)容器BattleContext持有所有單位、當前回合數(shù)、隨機數(shù)種子如果要回放戰(zhàn)斗并提供getUnit、emit等便捷方法。它不寫業(yè)務(wù)邏輯只做狀態(tài)聚合。這樣SkillResolver和BuffSystem都是無狀態(tài)的靜態(tài)方法測試時構(gòu)造一個BattleContext塞幾個UnitState就能跑不用起 Cocos 場景。這是整個工程最值得學的一點把“能純邏輯測試”當成架構(gòu)目標而不是事后補測試。5. 避坑與排查跑起來之后最容易卡住的幾個點5.1 現(xiàn)象行動條順序和實際出手對不上原因通常是 UI 自己緩存了一份順序或者peekAll返回的是內(nèi)部數(shù)組引用被外部改了。解決確認peekAll返回副本工程里用了[...this.units]UI 每次turn:start重新拉取不要onLoad時拉一次就存著。另外檢查requeue的有沒有被誤改成。5.2 現(xiàn)象某個單位被眩暈后永久不再行動原因startNextTurn里被跳過的單位沒有走endTurn沒回隊列。解決跳過分支里必須先endTurn再遞歸/循環(huán)取下一個。工程里遞歸寫法在“連續(xù)多個單位被控”時還可能棧溢出建議改成while (true)循環(huán)取到可行動單位再break。5.3 現(xiàn)象buff 持續(xù)時間比配置多一回合或少一回合原因施加 buff 的時機和tickAll的時機在同一回合內(nèi)順序不定。解決約定tickAll只在turn:end調(diào)用一次且施加 buff 的技能結(jié)算發(fā)生在turn:start之后、turn:end之前。如果技能是在turn:end里觸發(fā)的比如持續(xù)傷害要明確它是否參與當回合遞減工程里默認不參與。5.4 現(xiàn)象傷害飄字重復播放或播放兩次原因EventBus.on在組件onEnable里注冊但onDisable沒off場景切換后舊回調(diào)還在。解決所有on必須配對off或者用onEnable/onDisable成對管理。EventBus是靜態(tài)的生命周期比節(jié)點長這是靜態(tài)事件總線最常見的坑。5.5 現(xiàn)象技能配置改了但游戲里沒生效原因skills.json被 Cocos 當資源加載后緩存或者路徑寫錯讀到了舊文件。解決確認 JSON 放在resources下用resources.load讀改完清一次構(gòu)建緩存開發(fā)期可以在BattleContext初始化時打印讀到的技能數(shù)量數(shù)量不對就是沒讀到。6. 二次開發(fā)換一套戰(zhàn)斗規(guī)則要動哪幾層想把這個工程改成自己的游戲最省力的路徑是從數(shù)據(jù)層往上改而不是從 UI 往下改。第一步改units.json和skills.json把職業(yè)、技能、數(shù)值換成你的設(shè)定這一步不動代碼就能看到完全不同的戰(zhàn)斗。第二步如果要做“多段攻擊”或“追擊”在SkillResolver的effects里加一個type: multi_hit分支按hits字段循環(huán)調(diào)用傷害計算注意每段都要單獨emit飄字事件否則 UI 只顯示一次。第三步如果要做“行動條推拉”在ActionQueue加modifyActionValue(unitId, delta)直接改actionValue后重新sort因為隊列規(guī)模小不用維護堆的索引。驗證改造是否正確的辦法很土但有效寫一個純邏輯的測試腳本不啟動 Cocos直接new TurnManager()、塞三個UnitState、循環(huán)調(diào)startNextTurn和endTurn二十次打印每次出手的單位 id 和行動值。如果順序和頻率符合預期再回引擎里接 UI。這個習慣讓我在改數(shù)值和調(diào)度規(guī)則時少走了很多“改完跑場景看半天”的彎路。改造目標要動的文件是否碰 UI換數(shù)值/技能data/*.json否加新效果類型SkillResolver.ts否改出手頻率規(guī)則ActionQueue.ts否加新控制狀態(tài)TurnManager.ts BuffSystem.ts否換表現(xiàn)/動畫ui/ prefabs/是最后說個我自己的教訓早期我總想先把架構(gòu)設(shè)計得很完整再寫結(jié)果TurnManager里塞了太多“以后可能用到”的鉤子反而看不清主鏈路。這個工程的好處是每個類都只干一件事你接手時先跑通默認戰(zhàn)斗再按上表逐層替換比一上來就重構(gòu)整個調(diào)度器穩(wěn)得多。從那以后我每次改回合制邏輯都強制先跑一遍純邏輯腳本再進引擎希望幫到你。本文還有配套的精品資源點擊獲取