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