☰
纯前端回合制游戏源码解析:从战斗逻辑到二次开发
2026/10/15 16:14:41 网站建设 项目流程

简介:这是一份面向Java学习者的回合制游戏核心源码包,包含Manager、Soldier、Boss等关键类文件,可帮助理解回合制战斗流程、角色属性与技能设计,以及如何用面向对象思想搭建可扩展的游戏逻辑框架。资源压缩后仅1KB,文件数较少,以.java源码为主,便于初学者快速通读和复用核心设计思路。目前已有1013人学习浏览。通过学习这些类之间的调度关系,读者可以掌握状态管理、行为封装与回合轮转等基础写法,并可进一步结合工厂模式、策略模式等设计模式进行扩展,适合作为课程设计、毕业设计或小型独立开发的入门参考。

1. 回合制游戏源码拿到手:先别急着双击 index.html

这个回合制游戏项目是一份不依赖后端、不依赖打包工具的纯前端源码,用原生 HTML + CSS + JavaScript 实现了一套完整的战斗系统:角色属性、技能释放、敌方 AI、战斗日志、存档读档全都有,浏览器打开就能跑。

如果你是刚接触游戏逻辑的新手,这套源码比在游戏引擎里搭组件直观得多,战斗流程几乎全是平铺的函数调用;如果你是有经验的开发者,想快速搭一个战斗原型,或者给数值策划做一套验证工具,这里面战斗状态机的可读性也够用,改起来不费劲。

我拿到手的第一反应也是双击 index.html,结果在某个浏览器版本上直接白屏——不是代码坏了,是 ES 模块的跨域限制。这一下就把「怎么跑、怎么改」的细节全逼出来了。下文从项目结构讲起,拆到战斗核心代码,最后给出一套二次开发的完整套路。

2. 项目结构与技术选型:五个文件各管一条线

解压源码包之后先别急着看代码,先看目录。这个项目的组织方式很朴素:没有 build 目录、没有 node_modules、没有框架脚手架,就是一个静态站点该有的样子。按职责而不是按页面来拆文件,是小项目里最不容易写乱的一种组织方式。

2.1 源码目录拆开看:每个文件对应一类改动

turn-based-game/ ├── index.html # 入口页面:战斗界面的 DOM 骨架 ├── css/ │ └── style.css # 布局、血条、按钮、日志区的样式 ├── js/ │ ├── data.js # 角色 / 技能 / 敌人的静态配置 │ ├── battle.js # 战斗核心:回合调度与伤害结算 │ ├── ai.js # 敌方决策逻辑 │ ├── ui.js # DOM 渲染与事件绑定 │ └── save.js # localStorage 存档与读档 └── assets/ └── sound/ # 音效占位目录,源码包里是空壳

这套划分的逻辑很清楚:data.js 是纯数据,battle.js 是纯规则,ai.js 是敌人的「脑回路」,ui.js 是唯一碰 DOM 的地方,save.js 只管持久化。改数值去 data.js,改战斗规则去 battle.js,改界面去 ui.js,互不越界。源码工具包里这种按职责分文件的做法,比按「页面」分文件更抗改动,因为你调一个伤害公式的时候,不需要在十个文件里翻哪里写了计算逻辑。

index.html 本身也很薄,主要工作是搭骨架和引用模块脚本。技能按钮统一带>// js/data.js // 角色与技能配置:所有可调数值都集中在这里,战斗逻辑里不写死任何数字 export const CHARACTERS = { warrior: { name: '战士亚瑟', maxHp: 180, atk: 22, def: 14, speed: 10, maxMp: 40, skills: ['heavy_slash', 'holy_shield'] }, mage: { name: '法师梅林', maxHp: 110, atk: 30, def: 6, speed: 14, maxMp: 80, skills: ['fireball', 'ice_armor'] } }; export const SKILLS = { heavy_slash: { name: '重斩', type: 'damage', multiplier: 1.5, mpCost: 8, description: '造成 150% 攻击力的物理伤害' }, holy_shield: { name: '圣盾', type: 'buff', defenseUp: 10, duration: 3, mpCost: 15, description: '3 回合内防御提升 10 点' } }; export const ENEMIES = [ { name: '哥布林', maxHp: 60, atk: 12, def: 4, speed: 8, skills: ['scratch'] }, { name: '石像守卫', maxHp: 150, atk: 16, def: 18, speed: 4, skills: ['smash'] } ];

这套数据模型的核心思想是「配置与逻辑分离」:角色有多少血、技能造成多少倍伤害、敌人防御力多高,全部是数据;battle.js 只负责读这些数据并执行规则。multiplier是百分比倍率,mpCost是技能消耗的蓝量,speed决定出手顺序,defenseUp和duration是 buff 专用字段,其他技能类型用不到它们,读取时做个空值判断即可。

skills数组里存的是技能 ID 字符串,通过 ID 去 SKILLS 里查详情。这种用 ID 关联而不是直接嵌套对象的方式,是为了避免同一个技能在多个角色身上被复制成多份,改一处就能全局生效。你调数值时只需要动这个文件,战斗逻辑一行都不碰。

3.2 回合调度:状态机怎么决定谁先出手

// js/battle.js // 战斗状态机:每个回合按速度排序依次出手,直到一方倒下 import { SKILLS } from './data.js'; export class Battle { constructor(player, enemy) { this.player = player; this.enemy = enemy; this.round = 1; this.isOver = false; this.log = []; this.turnIndex = 0; } // 按速度从高到低生成出手顺序,只统计还活着的单位 getTurnOrder() { return [this.player, this.enemy] .filter(unit => unit.hp > 0) .sort((a, b) => b.speed - a.speed); } // 玩家选择技能后的入口:先校验蓝量,再结算 playerUseSkill(skillId) { const skill = SKILLS[skillId]; if (this.player.mp < skill.mpCost) { return { ok: false, reason: 'mp_not_enough' }; } this.player.mp -= skill.mpCost; this.applySkill(this.player, this.enemy, skill); this.log.push(`${this.player.name} 使用了 ${skill.name}`); this.advanceTurn(); return { ok: true }; } // 敌方 AI 出手,决策结果由 ai.js 返回 enemyTurn() { const action = decideEnemyAction(this.enemy, this.player); this.applySkill(this.enemy, this.player, action.skill); this.log.push(`${this.enemy.name} 使用了 ${action.skill.name}`); this.advanceTurn(); } // 每行动一次推进一个单位;所有人都出手过才进入下一回合 advanceTurn() { const alive = [this.player, this.enemy].filter(unit => unit.hp > 0); if (alive.length < 2) { this.isOver = true; return; } this.turnIndex++; if (this.turnIndex >= this.getTurnOrder().length) { this.turnIndex = 0; this.round++; } } applySkill(caster, target, skill) { if (skill.type === 'damage') { const dmg = calcDamage(caster, target, skill.multiplier); target.hp = Math.max(0, target.hp - dmg); this.log.push(`${target.name} 受到 ${dmg} 点伤害`); } if (skill.type === 'buff') { target.buff = { defUp: skill.defenseUp, remain: skill.duration }; } } }

战斗流程的关键在advanceTurn:每次行动后推进turnIndex,当所有人都出过手,也就是turnIndex大于等于存活单位数时,归零并让round加一。这样设计的好处是回合的概念被显式管理,后续要加「每回合开始时恢复少量蓝量」之类的规则,直接在advanceTurn里打个点就行。isOver标志位在每次推进后检查存活数量,少于两个就判定战斗结束。

playerUseSkill的返回值设计成对象而不是布尔值,是为了让 ui.js 能区分「蓝量不够」和其他失败原因,按钮可以做不同的提示反馈。mpCost的校验放在结算之前,顺序错了就会出现「蓝不够还能放出技能」的玄学问题,这种前置校验建议保留。buff 目前简化为直接覆盖字段,没有做剩余回合递减,真正的完整项目里这里应该有个tickBuff逻辑,属于可以继续扩展的点。

3.3 伤害公式与 AI:数值浮动和敌人脑回路

// 伤害公式:攻击力 × 倍率 - 防御减伤,最终结果浮动 ±10%,下限为 1 export function calcDamage(attacker, defender, multiplier) { const extraDef = defender.buff ? defender.buff.defUp : 0; const reduction = (defender.def + extraDef) * 0.6; const variance = 0.9 + Math.random() * 0.2; return Math.max(1, Math.floor(attacker.atk * multiplier - reduction) * variance); }
// js/ai.js // 敌人决策:血量危险时优先回血,血量健康时随机出技能或普攻 export function decideEnemyAction(enemy, player) { if (enemy.hp < enemy.maxHp * 0.3 && enemy.mp >= 10) { return { type: 'skill', skill: SKILLS['enemy_heal'] }; } const damageSkills = enemy.skills .map(id => SKILLS[id]) .filter(s => s.type === 'damage'); if (damageSkills.length > 0 && Math.random() > 0.5) { return { type: 'skill', skill: damageSkills[0] }; } return { type: 'skill', skill: SKILLS['basic_attack'] }; }

伤害公式里的0.6是防御减伤系数,意思是每 10 点防御减 6 点伤害。这个系数不同项目差很多,有的用百分比减伤,有的用除法公式,这里选线性减伤是为了让数值策划好口算。variance是随机浮动区间,0.9 到 1.1 之间,让每次伤害不完全一样,否则打起来像机器人在互砍。Math.max(1, ...)保证最低伤害是 1,防止高防敌人完全免伤导致战斗打不死。

AI 的决策其实就一条规则:血量低于 30% 且蓝量够就回血,否则一半概率出技能、一半概率普攻。enemy.mp >= 10这个硬性条件很关键,如果漏掉它,敌人会在空蓝时反复尝试治疗,最后陷入死循环。想调 AI 难度,改0.3这个阈值就行:调高到 0.5,敌人会更惜命,打起来明显变难缠;调低到 0.15,敌人就是个莽夫。

3.4 UI 绑定:按钮点击到界面刷新的闭环

// js/ui.js // 绑定技能按钮,点击后按「玩家行动 → 敌人行动 → 渲染」的顺序同步跑完 import { Battle } from './battle.js'; import { CHARACTERS, ENEMIES } from './data.js'; export function initBattleUI() { // 这里用展开运算符拷贝配置,避免战斗中直接修改 data.js 里的静态对象 const player = { ...CHARACTERS.warrior, buff: null }; const enemy = { ...ENEMIES[0], buff: null }; const battle = new Battle(player, enemy); document.querySelectorAll('.skill-btn').forEach(btn => { btn.addEventListener('click', () => { const result = battle.playerUseSkill(btn.dataset.skill); if (!result.ok) return; if (!battle.isOver) battle.enemyTurn(); render(battle); }); }); } function render(battle) { const hpFill = document.getElementById('player-hp'); hpFill.style.width = (battle.player.hp / battle.player.maxHp * 100) + '%'; document.getElementById('player-hp-text').textContent = `${battle.player.hp} / ${battle.player.maxHp}`; // 敌方血条、日志区、按钮亮灭状态同理更新 }

initBattleUI里最容易被忽略的是{ ...CHARACTERS.warrior, buff: null }这行。如果不做浅拷贝,战斗里扣血会直接改掉 data.js 里的静态配置,打一局之后角色血上限就永久变低了,重开页面才恢复。这是新手改源码时最容易踩的坑之一,后面避坑章节还会提到。

整个链路是严格同步的:点击按钮 →playerUseSkill扣蓝、算伤、扣血 →enemyTurn让 AI 还手 →render集中刷新所有面板。因为每一步都是同步函数,中间任何一环算出NaN或报错,控制台会直接定位到对应行,不需要在异步回调里猜状态。render只做一件事:把 battle 里的数据同步到 DOM,所有界面状态都从 battle 对象来,没有第二个数据源头,界面就不会出现「血量显示和实际值对不上」的翻车现场。

4. 运行与避坑排查:起服务、刷数值、看控制台的完整清单

4.1 三种启动方式对比

启动方式操作适合场景注意点
直接双击 index.html文件管理器打开没引入 ES Modules 的旧版源码本项目这样跑会白屏
Python 静态服务在项目根目录执行python3 -m http.server 8000最省事,推荐浏览器访问http://localhost:8000
编辑器静态服务器插件装任意支持静态托管的插件,右键 serve前端开发者习惯热更新对纯静态项目没太大意义

直接用 Python 起服务是最稳的做法。在项目根目录打开终端,执行python3 -m http.server 8000,然后浏览器访问http://localhost:8000就能看到战斗界面。Windows 环境下如果python3命令不存在,试py -m http.server 8000,这俩的差异是 Python 安装器是否勾选了加入 PATH。

为什么不能直接双击?因为这个项目用了import/export语法。浏览器出于安全策略,file://协议下跨文件加载模块会被当成跨域请求拦截,控制台会报类似Cross origin requests are only supported for protocol schemes的错误。这不是代码坏了,而是协议限制,换成本地服务立刻就好。判断一个源码包能不能直接双击,就看它的 js 文件里有没有import语句,有就必须起服务。

4.2 高频踩坑记录:现象、原因、对策

避坑部分都是我在这个项目上真实翻车过的点,全部按「现象 → 原因 → 解决」三件套写。

第一坑:打开就白屏,控制台报跨域错误

现象:双击 index.html 后页面空白,开发者工具里报Cross origin requests相关错误。

原因:ES Modules 在file://协议下被浏览器拦截,import语句无法加载其他 js 文件。

解决:换成本地静态服务,用上面表格里的python3 -m http.server 8000,或者任意能托管静态文件的工具都行。跑起来后如果还白屏,再看是不是路径写错,检查 index.html 里<script type="module">的src是否对应真实目录。

第二坑:打一局之后角色属性永久变了

现象:打完一场战斗,重开按钮发现角色血量上限变低了,刷新页面才恢复正常。

原因:战斗代码直接改写了 data.js 里的静态配置对象。CHARACTERS.warrior被Battle构造函数直接持有,扣血扣的是配置对象本身的hp字段。

解决:初始化战斗时做浅拷贝,{ ...CHARACTERS.warrior, buff: null },保证每次战斗都从原始配置重新读取。如果角色对象里嵌了数组或对象类型的字段,浅拷贝不够,要手写深拷贝或用structuredClone。

第三坑:敌人无限回血,战斗永远结束不了

现象:敌人血量见底后反复使用治疗技能,但治疗量大于伤害量,双方进入死循环。

原因:AI 只判断了「血量低于 30% 且蓝量够」就回血,没有判断是否已经满血,也没有限制治疗频率。

解决:在decideEnemyAction里加一个满血判断,比如enemy.hp < enemy.maxHp * 0.6才允许治疗;同时确保治疗量不超过血量上限,回血逻辑里加Math.min(heal, enemy.maxHp - enemy.hp)。

第四坑:战斗日志区不动,看不到新消息

现象:技能、伤害都有正常结算,血条也在变,但日志区一直停在最上面,新内容看不到。

原因:往日志容器appendChild之后没有把滚动条拉到底部,新日志被挤到可视区之外。

解决:每次追加日志后执行logContainer.scrollTop = logContainer.scrollHeight,或者用scrollIntoView让最新一条日志自动滚进视野。这个小问题在日志条目多了之后特别容易触发,属于界面层的经典疏漏。

第五坑:读档后角色数值对不上,buff 字段变成 null

现象:战斗中途存档,读档后发现防御 buff 消失,甚至某些数值字段变成undefined。

原因:localStorage只能存字符串,存档时把 battle 对象JSON.stringify了,buff 等嵌套对象序列化后,读档时没有对缺失字段做兜底。

解决:读档时把解析结果和默认配置做合并,Object.assign(defaultCharacter, savedData),对buff、skills这类可能缺失的字段逐个给默认值。另外建议只在战斗外存档,不要在战斗中途写档,避免把临时战斗状态也存进去。

提示:以上五个坑有一个共性——都是数据边界问题。要么是数据被意外修改、要么是数据缺失没有兜底、要么是数据加载受限。改源码时先想清楚数据的来源和去处,能避开一大半问题。

5. 二次开发思路:加技能、调数值、换皮肤的三板斧

最直接的改造是加一个新技能。在 data.js 的 SKILLS 对象里追加一条配置:

triple_strike: { name: '三连斩', type: 'damage', multiplier: 0.85, hits: 3, mpCost: 14, description: '连续攻击 3 次,每次造成 85% 攻击力伤害' }

光配数据还不够,battle.js 的applySkill目前只读multiplier,遇到hits字段会静默忽略。需要补一段循环结算的逻辑:

if (skill.hits > 1) { for (let i = 0; i < skill.hits; i++) { const dmg = calcDamage(caster, target, skill.multiplier); target.hp = Math.max(0, target.hp - dmg); } }

改完必须验证三件事:蓝量不足时按钮有没有正确拦截、连续命中时防御减伤是否逐段结算、战斗日志是否按次数逐条记录。数值平衡也一样,把 data.js 里的atk、multiplier、def当成变量调,每次只动一个,然后跑三局观察血线变化,别同时改三处,否则翻车了都不知道是哪条数值导致的。

换 UI 皮肤则更省事。style.css 里的血条颜色和按钮底色都走 CSS 变量,改--hp-color、--skill-bg这两个变量,一套新战斗界面就出来了,不需要动任何 JS。想换角色形象,把CHARACTERS里的name字段和对应插画替换即可。

我自从那次双击白屏的教训之后养成一个习惯:接手任何源码包,先强制自己走一遍「起服务 → 打一局 → 开控制台看报错 → 再动手改」,顺序一次都不能乱。先跑通再改、改一处验证一处,这个方法比什么都管用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询