系列说明前六篇讲的是选型、数据、重复实现、测试、平衡和玩家反馈 —— 全是里子。 这一篇讲面子:前端改版。
起因很简单:游戏功能已经能玩了,但战斗画面不好看。 双方挤在左边两块,右边一大片空白,没有血条、没有伤害数字, 打起来就像两张静态表格在互相刷新数字。
这篇写的是把战斗画面照官方重做一遍的完整过程: 怎么定风格(做了三版让人投票)、配色怎么从深色科技换成暖色桌游、 布局怎么照官方改成「对手在上 / 我方在下 / 中间交战区」、 以及打击感那点细节 —— 血条、飘字、前冲、受击、阵亡。
也照例翻车:改布局的时候我自己改出一个「对手永远看不见」的 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往上升 | 知道这一下掉了多少 |
| 受击抖动 | 挨打的那张卡抖一下 | 知道是谁在挨打 |
| 攻击前冲 | 出手的宠物往前冲(我方往上、对手往下) | 知道是谁在打 |
阵亡还做了变灰 + 划线,和"只是血条空了"区分开。
这里面只有一条有真正的技术含量:血条要拿"开战时生命"当满值。
如果拿"当前最大生命"当分母,会出现两种错误:
战斗中召唤出来的宠物没有"开战时生命"这个概念,分母算不出来
血量上限会因为各种技能变化,血条会忽长忽短
所以这个值必须在开战那一刻记下来:
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.js | 11 条(血条宽度、飘字/前冲/受击类、对手行渲染、经典模式的尸体停留) |
⚠️ 补一句:"全绿"不等于"对"。第七节那个"对手永远看不见"的 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 篇 · 根据真实玩家反馈进行优化!
(在线版还在持续迭代中——你点进去玩到的,可能比我写这篇时又新了一些。如果发现哪里和文章对不上,多半是我后来改了。)