☰
从零复刻《超级动物自走棋》游戏(7):优化大改战斗画面
2026/9/29 10:48:11 网站建设 项目流程

系列说明前六篇讲的是选型、数据、重复实现、测试、平衡和玩家反馈 —— 全是里子。 这一篇讲面子:前端改版。

起因很简单:游戏功能已经能玩了,但战斗画面不好看。 双方挤在左边两块,右边一大片空白,没有血条、没有伤害数字, 打起来就像两张静态表格在互相刷新数字。

这篇写的是把战斗画面照官方重做一遍的完整过程: 怎么定风格(做了三版让人投票)、配色怎么从深色科技换成暖色桌游、 布局怎么照官方改成「对手在上 / 我方在下 / 中间交战区」、 以及打击感那点细节 —— 血条、飘字、前冲、受击、阵亡。

也照例翻车:改布局的时候我自己改出一个「对手永远看不见」的 bug。

📚 本系列
第 1 篇 · 技术选型与引擎架构第 2 篇 · 我被一份“参考数据”坑了整整一轮
第 3 篇 · 同一个功能,我写了三遍第 4 篇 · 没报错 ≠ 通过了
第 5 篇 · 把“感觉太强”变成数据第 6 篇 · 根据真实玩家反馈进行优化!

🎮 边玩边读

👉 Super Auto Pets · 单机版

纯网页版,点开就能玩——不用下载、不用注册、不用登录。


一、改版前的战斗画面长什么样

先把"当时"贴出来,不然说"不好看"没有说服力。

改版前的经典模式战斗画面。双方各自一块、都挤在左侧,右边一大片空白;没有血条,也没有伤害数字

改版前的 8 人混战。血量是纯色长条,宠物卡是深色玻璃质感

问题可以拆成三条,它们要分开修:

问题属于哪一层
深色冷调,和"动物卡牌"这个题材气质不搭配色
双方挤左边、右边空着,看不出"谁在打谁"布局
掉血只是数字变了,没有任何反馈打击感

如果混在一起改,最后只会得到一句"好像变好看了一点"——说不清改了什么,也说不清下次该怎么改。

所以我把它们拆成三步:先定风格,再改布局,最后加打击感。


二、第一步:做三版让人投票,而不是自己拍板

配色这件事最怕"我觉得好看"。

我很清楚自己在这个维度上没有判断力 —— 我能看出"丑",但看不出"哪个更好看"。 与其自己纠结三轮,不如把选项做出来,让玩的人选。

所以我把同一套战斗布局,用三种配色和质感各渲染了一遍:

三种风格对比(A 深色科技的精致版 / B 暖色桌游 / C 明亮清新)。三版用的是同一套元素:圆形头像底盘、血条、伤害飘字、阵亡变灰划线、攻击前冲 —— 差别只在配色和质感

这个做法有个关键前提:三版必须是同一套骨架,只换皮。

否则投票就失去意义了 —— 如果 A 版有血条、B 版没有,那选出来的到底是"配色"还是"功能"? (顺带一提,图片底部那行小字"以后换成真图只要替换里面的 emoji"就是本文第八节要讲的东西,当时已经埋进去了。)

结果选了B · 暖色桌游—— 理由是它最接近官方那套「木质桌面 + 实物卡牌」的味道。


三、配色:从深色科技到暖色桌游

定完风格就是换色。听起来是体力活,但这里有个真的会踩的坑。

新配色是这样两套东西拼起来的:

:root { /* ---- 木质 UI 底 ---- */ --bg: #241a12; --panel: #3a2a1e; --line: #5c432b; --text: #f4e8d2; /* 浅色字,用在木色底上 */ --dim: #b8a184; ​ /* ---- 奶油色实物卡牌 ---- */ --card: #fdf6e6; --card-line: #8a6a45; --card-edge: #6b5133; /* 厚底边,靠投影做厚度 */ --card-text: #46321a; /* 深色字,用在奶油卡面上 */ --card-dim: #8b7355; }

坑就在这里:这套配色有【两套文字色】

  • --text/--dim是浅色,给木色面板用

  • --card-text/--card-dim是深色,给奶油色卡面用

因为页面上一半是深棕木质、一半是奶油色卡牌,这两种背景的明度差了十万八千里 —— 一套文字色不可能同时压住两边。

用错的后果是"字直接看不见":拿--text(浅米色)写在奶油卡面上, 对比度几乎为零,字就消失了。而且它不报错,只是看起来"那块区域有点空"。

代价就是:卡片内部的每条样式都得记住用哪一套。 我没有找到能自动化的办法 —— 这属于设计系统本身的复杂度,只能靠约定和注释。

改了配色之后,顺手把几处"割裂"也一起处理了:

  • 道具卡原来是深色的,和宠物卡并排放着像两个游戏的东西 → 统一成奶油卡

  • 按钮 / 空位 / 阵营徽章跟着同一套变量走,不再各处硬编码颜色

💡 这一节的教训不是"要小心",而是:换皮的时候,先把"有几套底色"数清楚。只要底色多于一种,文字色就不可能只有一套。


四、第二步:布局照官方重做

布局的问题最直观:看不出谁在打谁。

官方那套是「对手在上 / 我方在下」,中间留一条交战区。我照搬了:

改版后的战斗画面。对手在上、我方在下、中间是交战区;每张卡下面一条血条,圆形底盘 + 奶油实物卡

具体改了四件事:

改动之前之后
布局双方各一块、竖排在左侧对手在上 / 交战区 / 我方在下,横向居中
战场纯色背景木纹桌面(径向渐变 + 条纹 + 内阴影)
交战区没有中间一条分隔线,战斗中 ⚔ 会脉动
头像光秃秃一个 emoji圆形底盘(径向渐变 + 内阴影)

关于"圆形底盘",有个小理由值得说:它是一个为将来留的位置。

现在里面是 emoji,但它是固定尺寸的圆形 —— 以后换成真实图片时, 裁切、对齐、缩放都不用重做。如果直接用裸 emoji,图片进来的时候 整套尺寸都得重新调。先把容器定下来,比先把内容定下来更重要。


五、第三步:打击感 —— 五件小事

布局解决了"看不清",但战斗还是"数字在变"。

要让它像"打起来",我加了五个反馈。它们都很小,但少一个都会明显变钝:

反馈实现为什么需要
血条每张卡下面一条,以开战时生命为满值数值高低一眼可比,不用心算
危险色剩余低于 34% 时血条变红不靠读数字就知道"这只快没了"
伤害飘字红色−N往上升知道这一下掉了多少
受击抖动挨打的那张卡抖一下知道是谁在挨打
攻击前冲出手的宠物往前冲(我方往上、对手往下)知道是谁在打

阵亡还做了变灰 + 划线,和"只是血条空了"区分开。

这里面只有一条有真正的技术含量:血条要拿"开战时生命"当满值。

如果拿"当前最大生命"当分母,会出现两种错误:

  1. 战斗中召唤出来的宠物没有"开战时生命"这个概念,分母算不出来

  2. 血量上限会因为各种技能变化,血条会忽长忽短

所以这个值必须在开战那一刻记下来:

function battleCloneView(p, side) { return { uid: p.uid, defId: p.defId, def: p.def, lvl: p.lvl, atk: p.atk, hp: p.hp, maxHp: p.hp, // 开战时的生命 —— 血条以它为满值 perks: (p.perks || []).map(function (x) { return { id: x.id, uses: x.uses }; }), side: side }; }

代价:这个字段是"回放层"的职责,不是引擎的。 引擎里根本没有maxHp这东西 —— 它只管算血,不管怎么画。 所以每加一个回放层,就得记得补这个字段。


六、两种实现,一份样式

到这里出现一个岔路。

这个项目有两套战斗回放:经典模式一套、8 人混战和联机共用一套。 (为什么会有两套、以及它后来造成的麻烦,是第 3 篇的主题,这里不重复。)

那么问题来了:上面那五个反馈,要在两套里各写一遍吗?

如果各写一遍,马上就会分叉 —— 一边血条 34% 变红、另一边 30% 变红, 过两个月就没人知道哪个是对的了。这就是第 3 篇那个坑的入口。

所以我抽了两个共用函数放在render.js:

/* 战斗中的宠物卡装饰(血条 / 扣血飘字 / 前冲 / 受击) * 三种模式共用 —— playback.js 和 ui.js 都调这个,样式才不会各写一遍。 * * fx = { lunges: [uid...], hits: [uid...], floats: [{uid, n}...] } * isMine = 这一侧是不是我方(决定前冲方向:对手在上面,我方往上冲) */ function battleDecorateCard(card, pet, fx, isMine) { // 血条:以开战时的生命为满值 if (pet.maxHp > 0) { const ratio = pet.hp / pet.maxHp; const bar = el('div', 'pet-hpbar' + (ratio <= 0.34 ? ' low' : '')); const fill = el('i'); fill.style.width = Math.max(0, Math.min(100, ratio * 100)) + '%'; bar.appendChild(fill); card.appendChild(bar); } if (!fx) return; if (fx.lunges && fx.lunges.indexOf(pet.uid) >= 0) { card.classList.add(isMine ? 'lunge-up' : 'lunge-down'); } if (fx.hits && fx.hits.indexOf(pet.uid) >= 0) card.classList.add('hit'); for (const f of (fx.floats || [])) { if (f.uid !== pet.uid) continue; card.appendChild(el('span', 'dmg-float' + (f.n <= 0 ? ' zero' : ''), f.n <= 0 ? '0' : '−' + f.n)); } }

另一个函数负责把引擎事件翻译成"这一帧要播什么":

/* 从引擎事件里抽出「这一帧要播的特效」 */ function battleFxOfEvent(ev) { if (!ev) return null; if (ev.e === 'attack') { const floats = []; if (ev.dmgA > 0) floats.push({ uid: ev.a, n: ev.dmgA }); if (ev.dmgB > 0) floats.push({ uid: ev.b, n: ev.dmgB }); return { lunges: [ev.a, ev.b], hits: floats.map(function (f) { return f.uid; }), floats: floats }; } if (ev.e === 'dmg') { return { hits: [ev.t], floats: [{ uid: ev.t, n: ev.n }] }; } return null; }

注意这个分工:装饰归装饰,翻译归翻译。

battleDecorateCard完全不知道"攻击事件长什么样",battleFxOfEvent完全不碰 DOM。这样换视觉只改前者、引擎事件变了只改后者。

为什么能这么切?因为这个引擎的输出是有顺序的事件日志, 不是"最终状态"。回放层拿到的是attack/dmg/faint这样一条条的动作, 所以"这一帧发生了什么"是能从事件本身推出来的 —— 不需要去 diff 两次状态。

这是第 1 篇里那个架构决定在这里第二次收到回报。

代价:fx必须由回放层主动传进来。 也就是说两个回放器都得改(playback.js和ui.js), 而且每加一种新特效,两边都要接一次。


七、翻车:我自己改出了一个「对手永远看不见」

换布局的时候,我把经典模式战斗区的那段 HTML 结构重写了。

重写完测试全绿,我自己点开玩了一局 ——对手那一行是空的。

第一反应是"对手没生成"。但紧接着就不对了:如果对手没生成, 战斗根本打不起来,而战斗是正常打完的。

打开调试工具看 DOM:对手的三张卡都在,只是父元素被设成了display: none。

根因很朴素:

  • 商店阶段要把对手行藏起来(那时候不该显示上一场的对手),代码里有display = none

  • 而开始战斗时的那句display = ''跟着旧结构一起被我删掉了

于是对手卡被正常渲染进一个隐藏容器里 ——DOM 是对的,只是看不见。

排查过程里最有用的不是截图,而是一个笨办法:把computedStyle打出来。截图只能告诉我"这里是空的",computedStyle才能告诉我"它不是没渲染,是被隐藏了"。

⚠️ 这个 bug 的完整诊断在第 3 篇(它是"两份实现"的直接后果)—— 同一件事在两套回放里各写一遍,改了一套、忘了另一套。 这里只记一句:改布局的时候,删 HTML 容易,删干净配套的样式操作很难。

同一个提交里还顺手修掉了另一个 bug:经典模式的"尸体停留"又出现了。

原因是上一轮只修了 8 人/联机那套回放,经典模式是ui.js里的另一套,同样漏了。 这次的处理方式不是"再补一遍",而是把这个函数提到render.js,三种模式共用:

/* 血量归零就立刻标记阵亡。 * ⚠️ 引擎的 attack 事件【自带伤害】,所以回放里血量在这一步就掉到 0 了, * 但 _dead 要等好几条事件之后的 faint 才标记 —— 中间那些 ability 步骤里, * 这具 0 血的"尸体"还留在场上,看起来就是「死了还在打」。 * 实测最长停留 30 步(约 12 秒)。 * 这里只是【提前打标记】,真正移出数组仍在下一步开头做。 */ function markDeadIfZero(pet) { if (pet && pet.hp <= 0) pet._dead = true; }

同一个 bug 修第二次,就不该再修第三次 —— 该做的是把两份实现合成一份。


八、图片接口:现在还是 emoji,但换真图只改一个地方

改版之后最容易收到的反馈是"为什么宠物还都是 emoji"。

这是有意的。真图涉及:找素材、授权、裁切、压缩、加载失败兜底…… 在"先把界面结构定下来"这件事上,emoji 的信息量其实够用(每种动物一个)。

但我不想将来换图的时候要去动几十处PET_EMOJI[p.defId]取值 —— 那又会变成"同一个功能很多份实现"。

所以整套渲染都走同一个出口:

/* 宠物图片接口(预留) * 现在用 emoji 顶着,但整套渲染都走 petArtHtml(),所以以后要换成真图 * 只需要在这里加一行、把图片丢进 assets/pets/ 就行 —— 三种模式、 * 商店、队伍、战斗、图鉴会一起生效,不用改任何别的地方。 * 没在这里登记的宠物会自动退回 emoji,所以可以一张一张慢慢加。 */ const PET_ART = { // Ant: 'assets/pets/ant.png', }; ​ function petArtHtml(defId) { const src = PET_ART[defId]; if (src) { return '<img class="pet-img" src="' + esc(src) + '" alt="' + esc((PETS[defId] && PETS[defId].cn) || defId) + '">'; } return PET_EMOJI[defId] || '🐾'; }

关键设计是"没登记的自动退回 emoji":

这意味着换图可以一张一张来—— 今天加猫、明天加狗,中间任何时刻游戏都是可玩的。 不需要"一次性换完",也不需要维护一个"哪些换了哪些没换"的开关。

代价:现在它只是一个接口,没有真图。<img>的尺寸约束、裁切方式、加载失败的兜底样式都还没验证过 —— 真到换图那天,这些才是主要工作量,接口本身反而是最简单的一环。


九、改版后的样子

新版首页(暖色桌游风)

新版经典模式商店。宠物卡是奶油色实物卡,道具卡也跟着统一了

新版 8 人混战面板。血量条也换成了同一套视觉语言

改版后做了一轮回归:

项目结果
node 测试全绿(当时的规模写在第 4 篇里)
浏览器测试页3 个全通过
四个页面 headless 加载零 JS 错误
新增classic_battle_test.js11 条(血条宽度、飘字/前冲/受击类、对手行渲染、经典模式的尸体停留)

⚠️ 补一句:"全绿"不等于"对"。第七节那个"对手永远看不见"的 bug, 在测试全绿的情况下依然存在 —— 因为当时的测试没有断言"对手行可见"。 这是第 4 篇的主题,这里只留个提醒。


小结

这件事学到什么
三版风格让人投票自己没判断力的维度就别硬拍;但选项必须是同一套骨架,否则投出来的不是你想问的东西
两套文字色只要底色多于一种,文字色就不可能只有一套。用错了不报错,只是字消失
圆形底盘先把容器定下来,比先把内容定下来更重要—— 图片进来时不用重做尺寸
血条用"开战时生命"有些值只在某一层存在(引擎没有maxHp),跨层传递是必须付的成本
抽battleDecorateCard/battleFxOfEvent装饰和翻译分开;同一个 bug 修第二次,就该把两份实现合成一份
PET_ART接口"未登记自动退回"让迁移可以一张一张做,不用一次性换完
对手行消失DOM 对了不代表看得见。截图说"这里是空的",computedStyle才说"它被隐藏了"

还有一条是操作层面的:改布局的时候,删 HTML 容易,删干净配套的样式操作很难。这次是"商店阶段设了display:none、开战时该清掉的那行被一起删了"。 以后我改结构会顺手搜一遍和它相关的style.display。


💬 说说你的想法

改版这块我特别想知道两件事:

1. 现在这个风格你满意吗?

图 3 那三版是当时做的对比。如果你觉得 A(深色科技)或 C(明亮扁平)其实更好,或者有其他想法都可以在评论区说或者私信我。

2. 战斗画面还缺什么?

我自己能想到但还没做的有:

想法为什么没做
伤害数字换成"累计伤害"一时想不到怎么显示才不糊
攻击前冲加拖尾怕和 4× 倍速打架
宠物真图素材和授权还没着落,见第八节
战斗回放可以拖进度条回放器现在是"一条条播",加拖拽要重做状态机

如果你玩的时候觉得"这里好像差点什么",请一定说 —— 这类反馈我基本都能改,而且改完效果比我自己想的方案好。


下一篇写什么

《联机:让两个人打同一局》

这一篇讲联机,要回答的是一个有点别扭的问题:在一个没有后端的纯静态页面上,怎么做出一个联机模式?

  • 为什么最后选的是服务端权威 + 长轮询,而不是看起来更正统的 WebSocket

  • 房间怎么建、断线怎么重连、有人挂机怎么办

  • 以及「单机的 8 人混战」和「联机的 8 人混战」,哪些东西是共用的、哪些必须各写一套

还有一个绕不开的现实:联机得自己把那个小服务器跑起来(零依赖,双击就能开), 公网那份静态页只能单机玩。所以下一篇我会把仓库地址放出来,代码都在里面。

⬅️ 上一篇:第 6 篇 · 根据真实玩家反馈进行优化!


(在线版还在持续迭代中——你点进去玩到的,可能比我写这篇时又新了一些。如果发现哪里和文章对不上,多半是我后来改了。)

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

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

立即咨询