ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

从零复刻《超级动物自走棋》游戏(7):优化大改战斗画面

2026/9/29 10:48:36 拓冰建站 浏览量
从零复刻《超级动物自走棋》游戏(7):优化大改战斗画面 系列说明前六篇讲的是选型、数据、重复实现、测试、平衡和玩家反馈 —— 全是里子。 这一篇讲面子前端改版。起因很简单游戏功能已经能玩了但战斗画面不好看。 双方挤在左边两块右边一大片空白没有血条、没有伤害数字 打起来就像两张静态表格在互相刷新数字。这篇写的是把战斗画面照官方重做一遍的完整过程 怎么定风格做了三版让人投票、配色怎么从深色科技换成暖色桌游、 布局怎么照官方改成「对手在上 / 我方在下 / 中间交战区」、 以及打击感那点细节 —— 血条、飘字、前冲、受击、阵亡。也照例翻车改布局的时候我自己改出一个「对手永远看不见」的 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 classpet-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 篇 · 根据真实玩家反馈进行优化在线版还在持续迭代中——你点进去玩到的可能比我写这篇时又新了一些。如果发现哪里和文章对不上多半是我后来改了。