资讯详情

资讯详情

回合制游戏源码拆解:战斗循环与可扩展技能系统设计

简介这份资源面向Java初学者与游戏开发入门者提供一套回合制游戏核心逻辑的源码参考帮助理解轮流行动机制下游戏流程与角色行为的组织方式。包内共0个文件压缩包约1KB因上游未提供文件类型明细具体构成暂无法确认从描述看应包含游戏管理器、士兵与Boss等关键类的实现代码。资源围绕回合制规则展开管理器负责初始化状态、调度当前行动者并驱动回合推进士兵类承载生命值、攻击力等属性与攻击、防御、移动等动作Boss类则在其基础上扩展更高难度与特殊技能并可能结合工厂、策略、状态等设计模式与封装、继承、多态等面向对象原则。目前已有1015人学习适合作为课程设计或小型项目的起步参考读者可借此梳理回合制游戏的类职责划分与调度思路并对照自身代码查漏补缺。1. 回合制游戏源码拆解从战斗循环到可扩展技能系统很多开发者第一次拿到回合制游戏的源码时会下意识觉得“回合制嘛逻辑简单不就是你打一下我打一下”。真把工程跑起来、翻完战斗模块才会发现回合制恰恰是最考验状态管理和数据驱动设计的品类——出手顺序、buff 叠加、技能结算、AI 决策任何一处状态没管好都会变成后期改不动的玄学 bug。这份回合制游戏源码就是围绕这套战斗循环搭起来的完整工程包含角色属性、技能配置、回合调度、战斗 AI 和存档几个核心部分适合想学游戏逻辑架构、或者想拿一套能跑的战斗框架做二次开发的开发者。下面按“能干什么 → 怎么跑 → 怎么改”的顺序拆开讲。2. 工程结构与技术选型一套能跑起来的战斗框架长什么样2.1 目录结构逐个说清拿到源码先别急着点运行把目录扫一遍心里对“数据在哪、逻辑在哪、配置在哪”有个数后面改起来才不迷路。这类回合制工程常见的目录划分是这样的turn-based-game/ ├── src/ │ ├── core/ # 战斗核心回合调度、状态机 │ │ ├── BattleManager.js │ │ ├── TurnScheduler.js │ │ └── StateMachine.js │ ├── entities/ # 角色、敌人、召唤物等实体 │ │ ├── Character.js │ │ └── Enemy.js │ ├── skills/ # 技能基类与具体技能实现 │ │ ├── SkillBase.js │ │ └── skills/ │ ├── ai/ # 敌方决策逻辑 │ │ └── BattleAI.js │ ├── data/ # 配置数据角色、技能、关卡 │ │ ├── characters.json │ │ ├── skills.json │ │ └── stages.json │ └── ui/ # 战斗界面渲染与输入 │ └── BattleView.js ├── assets/ # 图片、音效等静态资源 ├── tests/ # 战斗逻辑单元测试 └── package.jsoncore是整套逻辑的心脏entities描述“谁在打”skills描述“怎么打”data把数值和逻辑解耦ai决定“敌人怎么打”。这个分层不是摆设——它直接决定了你后面加一个新技能时是改一处配置就行还是要动三四个文件。我一般拿到新工程会先看data目录因为配置结构往往能反推出整个战斗系统的设计意图。2.2 技术选型与运行环境这类工程通常用 JavaScript/TypeScript 写逻辑层配合 Canvas 或 DOM 做战斗表现好处是逻辑和渲染能分开测试战斗核心不依赖具体渲染引擎。运行环境上Node.js 负责跑逻辑和测试浏览器负责跑完整交互。常见做法是# 安装依赖 npm install # 启动开发服务器浏览器打开战斗界面 npm run dev # 只跑战斗逻辑的单元测试不启动界面 npm testnpm run dev一般会起一个本地服务默认端口在package.json的scripts里能看到常见是 3000 或 5173。如果启动后页面白屏先看控制台报错八成是资源路径或某个 JSON 配置字段缺失——这类工程对配置完整性很敏感少一个字段就可能在初始化角色时抛异常。提示第一次跑建议先执行npm test让战斗逻辑的测试用例先过一遍。逻辑测试通过说明核心链路是通的再去调界面会省很多事。2.3 战斗循环的整体架构回合制的核心是一个“调度器 状态机”的组合。调度器决定这一回合谁出手状态机管理当前处于哪个阶段选目标、结算伤害、判定死亡、切换回合。把这两个东西分开是为了让“出手顺序规则”和“单次行动流程”互不干扰——前者可能因为速度属性、buff、装备而变后者相对稳定。// core/BattleManager.js class BattleManager { constructor(units, scheduler) { this.units units; // 参战单位列表 this.scheduler scheduler; // 回合调度器 this.turnCount 0; // 当前回合数 this.state IDLE; // 战斗状态机当前状态 } // 战斗主循环每次推进一个单位的行动 step() { if (this.isBattleOver()) { this.state FINISHED; return this.getResult(); } const actor this.scheduler.next(this.units); // 取出下一个行动者 this.state ACTING; const action actor.decideAction(this.units); // 决策普攻/技能/防御 this.resolveAction(actor, action); // 结算行动结果 this.turnCount; this.state IDLE; return null; } // 结算一次行动伤害、buff、死亡判定都在这里 resolveAction(actor, action) { const targets action.selectTargets(this.units); targets.forEach(t action.apply(actor, t)); this.units this.units.filter(u u.isAlive()); // 清理死亡单位 } }这段是整个工程的骨架。step()每次只推进一个单位的行动而不是一次跑完整个回合这样界面层可以在每次step()后刷新画面也方便做“行动动画播放中”的中间态。scheduler.next()是出手顺序的唯一入口想改速度机制、加“再动”效果都从这里下手。resolveAction里先选目标再结算最后统一清理死亡单位——注意清理放在结算之后否则一个 AOE 技能打到一半目标被移除会引发数组越界。参数上units是单位数组每个单位需要有isAlive()、decideAction()这些约定方法scheduler需要实现next(units)返回下一个行动者。这两个约定就是这套框架的扩展点后面加新单位类型、新调度规则都围绕它们做。3. 核心模块拆解回合调度、技能结算与战斗 AI3.1 回合调度器速度、再动与出手顺序出手顺序是回合制最容易出问题的地方。最朴素的实现是“按速度排序每回合从头遍历”但一旦加入“再动”“减速”“插队”这些机制简单排序就不够用了。这套工程用的是“行动值累积”思路每个单位有一个行动值速度越快累积越快谁先攒满谁先动。// core/TurnScheduler.js class TurnScheduler { constructor() { this.actionGauge new Map(); // 单位 - 当前行动值 } // 返回下一个行动者并扣除其行动值 next(units) { // 先给所有存活单位累积行动值 units.forEach(u { if (!u.isAlive()) return; const cur this.actionGauge.get(u) || 0; this.actionGauge.set(u, cur u.speed); }); // 找出行动值最高且已满的单位 let actor null; let maxGauge -Infinity; units.forEach(u { if (!u.isAlive()) return; const g this.actionGauge.get(u) || 0; if (g 100 g maxGauge) { // 100 为行动阈值 maxGauge g; actor u; } }); if (actor) { // 扣除阈值保留溢出部分实现“速度越快动得越多” this.actionGauge.set(actor, this.actionGauge.get(actor) - 100); } return actor; } }关键在100这个行动阈值和“保留溢出”的处理。速度 120 的单位每轮攒 120扣掉 100 还剩 20下一轮就比速度 100 的单位更早攒满自然形成“高速多动”。想加“再动”效果直接给对应单位的行动值加 100 即可想加“减速”在累积阶段乘一个系数。这套机制比单纯排序灵活得多也是很多成熟回合制游戏的通用做法。注意actionGauge用 Map 存单位对象作为 key。如果单位在战斗中被重新创建比如复活Map 里的旧引用会变成内存泄漏常见做法是在单位死亡时同步清理对应条目。3.2 技能系统数据驱动的技能配置与结算技能如果每个都写一个类几十个技能下来维护成本会爆炸。这套工程走的是“数据驱动 少量行为类型”的路子技能的效果类型伤害、治疗、加 buff在代码里定义好具体数值和组合放在skills.json里配置。{ skill_fireball: { name: 火球术, type: damage, target: single_enemy, power: 1.8, element: fire, cost: 20, effects: [ { kind: damage, scale: magic }, { kind: buff, buffId: burn, chance: 0.3, duration: 2 } ] } }对应的结算逻辑// skills/SkillBase.js class SkillBase { constructor(config) { this.config config; // 来自 skills.json 的配置 } apply(actor, target) { this.config.effects.forEach(effect { switch (effect.kind) { case damage: // scale 决定用攻击还是法强计算 const base actor[effect.scale] * this.config.power; const dmg Math.max(1, base - target.defense); target.hp - dmg; break; case buff: // 概率触发命中才挂 buff if (Math.random() effect.chance) { target.addBuff(effect.buffId, effect.duration); } break; case heal: target.hp Math.min(target.maxHp, target.hp actor.magic * this.config.power); break; } }); } }effects数组是这套设计的核心一个技能可以同时包含伤害、buff、治疗多种效果按顺序结算。scale字段决定伤害吃哪个属性power是倍率chance控制概率触发。想加新技能绝大多数情况只需要往skills.json里加一条配置不用碰代码。只有当出现全新的效果类型比如“吸血”“反伤”时才需要在switch里加一个case。参数说明power是倍率不是固定值1.8 表示 180% 属性duration单位是回合数chance取值 0 到 1。这几个字段的命名和取值范围建议在团队里统一否则配置多了以后很容易出现“这个技能 power 写的是百分比还是倍数”的扯皮。3.3 战斗 AI敌方决策的优先级与评分敌方 AI 不需要多聪明但一定要“可预测、可调试”。这套工程用的是“候选行动评分”方案AI 枚举当前能做的所有行动给每个行动打分选最高分的执行。// ai/BattleAI.js class BattleAI { decideAction(self, units) { const enemies units.filter(u u.isAlive() u.faction ! self.faction); const allies units.filter(u u.isAlive() u.faction self.faction); const candidates []; // 候选一对每个敌人使用每个可用技能 self.skills.forEach(skill { if (self.mp skill.config.cost) return; // 蓝不够跳过 enemies.forEach(target { candidates.push({ action: skill, target, score: this.scoreSkill(skill, self, target, enemies) }); }); }); // 候选二治疗血量最低的队友 const hurtAlly allies.sort((a, b) a.hp / a.maxHp - b.hp / b.maxHp)[0]; if (hurtAlly hurtAlly.hp / hurtAlly.maxHp 0.5) { candidates.push({ action: self.healSkill, target: hurtAlly, score: 80 // 治疗优先级给高 }); } // 选最高分同分随机避免行为呆板 candidates.sort((a, b) b.score - a.score); return candidates[0]; } scoreSkill(skill, self, target, enemies) { let score skill.config.power * 10; // 能一击击杀大幅加分 if (target.hp self.attack * skill.config.power) score 100; // 优先打血量低的 score (1 - target.hp / target.maxHp) * 20; return score; } }评分函数是 AI 的“性格”所在。想让敌人更激进就提高击杀加分想让敌人更保守就提高治疗优先级。所有决策都走candidates数组调试时把数组打印出来一眼就能看出 AI 为什么选了某个行动——这比一堆嵌套if-else好排查太多。3.4 存档与战斗状态序列化回合制游戏经常需要“中途存档、下次继续”这就要求战斗状态能完整序列化。核心是把BattleManager里所有可变状态单位血量、buff、行动值、回合数导出成 JSON读档时再还原。// 存档只存可变状态不存方法 function saveBattle(battle) { return JSON.stringify({ turnCount: battle.turnCount, units: battle.units.map(u ({ id: u.id, hp: u.hp, mp: u.mp, buffs: u.buffs, gauge: battle.scheduler.actionGauge.get(u) || 0 })) }); } // 读档按 id 从配置重建单位再覆盖可变状态 function loadBattle(data, unitFactory) { const parsed JSON.parse(data); const units parsed.units.map(s { const u unitFactory.create(s.id); // 从配置重建 u.hp s.hp; u.mp s.mp; u.buffs s.buffs; return u; }); // ...重建 BattleManager 并恢复 actionGauge }这里有个血泪经验存档只存“可变状态”单位的静态属性攻击、防御、技能列表一律从配置重建。如果把整个单位对象序列化进去一旦配置更新旧存档就会带着过时的属性出现“读档后角色数值不对”的诡异问题。4. 避坑与常见问题跑不起来、打不动、数值崩了怎么办4.1 启动后白屏或报错找不到模块现象npm run dev后浏览器白屏控制台报Cannot find module或某个 JSON 解析失败。原因通常是依赖没装全或者data目录下的配置文件有语法错误多一个逗号、少一个引号。解决先npm install重装依赖再用node -e require(./src/data/skills.json)单独验证每个 JSON 文件能否解析。配置文件是这套工程的命门改完一定要单独校验。4.2 战斗卡死某个单位一直不出手现象战斗进行到一半停住界面不再推进。原因多半是调度器next()返回了null——所有单位行动值都没到 100或者行动值最高的单位已经死亡但没被清理。解决在next()里加兜底如果没有任何单位达到阈值就强制给全场累积一次行动值同时确认死亡单位在resolveAction后确实被移除了。4.3 技能伤害为负数或异常高现象打出来的伤害是负的或者一下秒杀全场。原因通常是power配置写错把 1.8 写成 180或者scale字段指向了不存在的属性导致actor[effect.scale]是undefined参与运算后变成NaN。解决在SkillBase.apply里对base做一次Number.isFinite校验配置加载时也加一层字段白名单校验把错误挡在启动阶段。4.4 buff 叠加后数值失控现象同一个 buff 反复叠加角色属性暴涨或暴跌。原因是addBuff没有做去重和层数上限。解决给 buff 加maxStack字段叠加时判断是否已达上限同类 buff 要么刷新持续时间要么叠层规则要统一不能两种混着来。4.5 读档后战斗行为异常现象存档再读回来AI 行为变了或者 buff 不生效。原因是存档时漏存了某些状态比如行动值、buff 剩余回合或者读档时单位重建顺序和原来不一致导致调度器里的引用对不上。解决存档字段和单位可变属性一一对应读档后跑一遍npm test里的战斗回归用例确认行为一致。5. 二次开发加新技能、新角色与自定义战斗规则5.1 加一个新技能的最短路径大部分情况下加技能就是改配置。比如加一个“冰霜新星”对全体敌人造成伤害并减速{ skill_frost_nova: { name: 冰霜新星, type: damage, target: all_enemy, power: 1.2, element: ice, cost: 35, effects: [ { kind: damage, scale: magic }, { kind: debuff, debuffId: slow, chance: 0.8, duration: 2 } ] } }如果debuff这个kind已经实现过那到这里就结束了重启即可生效。如果没实现就在SkillBase.apply的switch里加一个case debuff逻辑和buff对称只是作用方向相反。这就是数据驱动的好处新内容以配置为主代码改动被压缩到最小。5.2 加新角色与平衡性验证新角色同样是配置驱动在characters.json里加一条指定初始属性、技能列表、AI 类型即可。但加完角色一定要做平衡性验证我的习惯是写一个批量模拟脚本让新角色和现有角色各打 100 场看胜率分布// tests/balance.test.js function simulate(a, b, rounds 100) { let winA 0; for (let i 0; i rounds; i) { const battle createBattle([a, b]); while (!battle.isBattleOver()) battle.step(); if (battle.getResult().winner a.faction) winA; } return winA / rounds; // 返回 A 的胜率 }胜率明显偏离 50% 的角色要么数值有问题要么技能机制过强。这个脚本跑起来很快比手动打几十场靠谱得多。我一般会把胜率控制在 45% 到 55% 之间超出范围就回去调power或cost。5.3 改战斗规则从调度器下手想改核心规则比如把“行动值累积”换成“固定速度排序”只需要替换TurnScheduler保持next(units)接口不变即可。这就是前面强调接口约定的价值——调度规则可以整体替换BattleManager一行都不用动。同理想加“连携技”“合击”可以在resolveAction前后插入钩子而不是把逻辑塞进step()里。5.4 一个我踩过的坑刚接触这套工程时我图省事直接在BattleManager.step()里写了个while循环想一次把整场战斗跑完再渲染。结果界面直接卡死因为战斗里有个 buff 的触发概率依赖Math.random()循环里没有让出主线程浏览器以为页面挂了。从那以后我每次改战斗循环都强制走一遍“单步推进 界面刷新”的流程绝不在主循环里塞长任务。希望这套拆解能帮你少走点弯路把这份源码真正跑起来、改起来。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →