首页 / 资讯中心 / 文章详情

原生JS游戏开发:状态管理统一动画、卡槽、冷却与铲子交互

原生JS游戏开发:状态管理统一动画、卡槽、冷却与铲子交互 ★ FEATURED ARTICLE
浮了大半年的 V1 版本终于跑起来了但说实话那个版本充其量算个静态演示植物摆上去就站桩僵尸爬起来就一路走到底子弹打上去只是变个透明没有任何反馈感。这次 V2 我给自己定的目标是真正把游戏感做出来——植物会晃动、僵尸有走路姿态、卡片能选、种完要等冷却、种错了能铲掉重来。动画、卡槽、冷却、铲子这四个需求听着是四个独立模块但真动工之后才发现它们拧在一起其实是一件事你到底打算怎么管理游戏里所有会变化的元素。这篇文章适合两类人看一类是刚用原生 HTML/CSS/JavaScript 写过一点小游戏、想再往上走一个台阶的开发者另一类是已经在 V1 基础上加了零零碎碎的功能、但总觉得代码越写越乱、加一个功能蹦出三个 Bug 的人。我会把 V2 里四个系统的设计思路、核心代码、还有我踩过的坑完整摊开来讲不整虚的。1. 先拆解需求V2 的四件事本质上是在解决同一个问题1.1 V1 留下了什么V2 要补什么V1 的时候我们已经搭好了最基础的两样东西一块 5 行 9 列的网格战场以及一条僵尸从右侧生成、向左移动、碰到植物就啃的核心逻辑。这些是骨架但玩家玩起来没有任何回合互动感——不能选种什么、种下去就没法反悔、植物和僵尸都像纸片。V2 要补的四个系统其实是游戏交互层的完整闭环动画系统负责看起来像游戏把静态贴纸变成有生命的东西。卡槽系统负责让玩家做选择把玩家和游戏内容连接起来。冷却系统负责控制游戏节奏避免玩家无脑种满全屏。铲子系统负责让选择可反悔给玩家容错空间。单看任何一个都不复杂但它们要协同工作就会出现一个核心矛盾这些系统全都要修改同一个游戏世界的状态。卡槽要读阳光数量冷却要记录种植时间铲子要操作网格数据动画要感知植物被铲除后停止播放——如果各写各的代码很快就会变成一团乱麻。1.2 用状态机统一四个系统我的解决办法是给整个游戏定义一份全局状态所有系统都围绕这份状态来运作。const gameState { sun: 200, // 当前阳光数量 selectedCard: null, // 当前选中的卡片类型null 表示未选中 tool: none, // 当前工具模式none | plant | shovel grid: [], // 二维数组存储每个格子的植物对象 timestamp: 0 // 当前帧时间戳由游戏循环写入 };这份状态是唯一的真相来源。卡槽点击后修改selectedCard铲子点击后修改tool种植成功后写入grid阳光扣除后修改sun。动画、样式、冷却遮罩都只是这份状态的投影。这套思路看着像废话但真的动起手来你会发现所有交互 Bug 几乎都是因为绕过了这份状态、直接去改 DOM 造成的。后面每一章我都在反复和这个原则较劲。1.3 植物配置表用数据驱动而不是硬编码V1 时代我写过不少if (type sunflower) {...} else if (type peashooter) {...}的垃圾代码V2 我先把所有植物统一成一张配置表const PLANTS { sunflower: { name: 向日葵, cost: 50, cooldown: 5000, // 冷却时间单位毫秒 hp: 100, icon: }, peashooter: { name: 豌豆射手, cost: 100, cooldown: 7000, hp: 150, icon: }, wallnut: { name: 坚果墙, cost: 50, cooldown: 10000, hp: 400, icon: } };这样做的好处是后面加新植物、调平衡、改费用全部只改这一张表卡槽渲染、冷却计算、种植校验可以全部自动适配。凡是能用数据描述的规则就不要用代码去表达这是 V2 让我最受益的一条经验。2. 动画系统把静态贴纸变成有生命的东西2.1 主循环requestAnimationFrame 是整个游戏的心跳动画的根基是主循环。V1 时我偷懒用多个setInterval分别驱动僵尸移动、子弹飞行和阳光增长结果三个定时器各跑各的节奏稍微一乱就出现僵尸瞬移子弹穿模。V2 我把所有更新逻辑统一放进一个requestAnimationFrame循环里let lastTime 0; function gameLoop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.1); lastTime timestamp; update(dt); // 更新所有逻辑僵尸、子弹、冷却、阳光 render(); // 把所有逻辑状态同步到 DOM requestAnimationFrame(gameLoop); } requestAnimationFrame(gameLoop);这里有个细节可能有人会忽略dt是距上一帧的时间差单位是秒。所有涉及速度的逻辑都乘以dt才能保证不同刷新率的屏幕上表现一致。至于那个Math.min(..., 0.1)是用来防止用户把页面切到后台一段时间再切回来时dt突然变成几秒钟导致僵尸直接瞬移过半个地图——这是我实际踩过的坑后面第 6 章会展开讲。2.2 植物动画能用 CSS 解决的就别用 JS植物的活着主要是循环姿态动画比如向日葵花头摆动、豌豆射手轻微呼吸。这种纯循环、不参与任何逻辑判断、也不和其他元素互动的动画用 CSSanimation是最省力的。.sunflower-body { transform-origin: 50% 90%; /* 旋转基准点放到底部 */ animation: sway 1.6s ease-in-out infinite; } keyframes sway { 0%, 100% { transform: rotate(-6deg); } 50% { transform: rotate(6deg); } }注意transform-origin: 50% 90%这一行——默认的旋转中心是元素几何中心直接旋转会变成原地转圈看起来像向日葵在做托马斯回旋把旋转基准点放到接近底部的位置才有根茎固定、花盘随风摆动的观感。这个属性是植物摆动动画的灵魂。为什么这种动画不用 JS 每帧控制因为它不产生任何需要同步的数据。游戏逻辑根本不在乎向日葵此刻是向左歪还是向右歪纯粹是视觉表现。用 CSS 让浏览器在合成器层面处理不占用主线程性能更好代码也更干净。2.3 僵尸动画外层位移与内层姿态分离僵尸和植物最大的不同是它的位置是游戏逻辑的一部分因为碰撞检测、啃食判定都依赖它的实时坐标。如果我用 CSS 动画去做僵尸的移动那逻辑层就永远不知道僵尸走到哪了——这游戏就没办法玩了。我的做法是两层分离外层负责位移内层负责走路姿态。div classzombie idzombie-1 div classzombie-body‍♂️/div /div.zombie { position: absolute; left: 0; top: 0; will-change: transform; } .zombie-body { animation: lurch 0.6s ease-in-out infinite; } keyframes lurch { 0%, 100% { transform: translateY(0) rotate(-2deg); } 50% { transform: translateY(-3px) rotate(2deg); } }外层.zombie的transform: translateX(...)由 JS 每帧更新内层.zombie-body的左右摇晃交给 CSS 自己循环。这样逻辑层只要关心外层的x坐标视觉层也不用被 JS 的逐帧操作折腾。2.4 子弹与受击反馈事件驱动的动画切换循环动画靠 CSS非循环动画就得靠 JS 切换状态了。比如豌豆射手发射的子弹飞行速度要参与碰撞检测目标位置也随时可能变化虽然 V2 的僵尸走直线相对简单但后续加路障僵尸、跳跳僵尸时速度会变所以子弹必须用 JS 推进class Bullet { constructor(x, y) { this.x x; this.y y; this.el document.createElement(div); this.el.className bullet; this.el.textContent ; this.el.style.left x px; this.el.style.top y px; gameFieldEl.appendChild(this.el); } update(dt) { this.x 200 * dt; // 每秒 200 像素 this.el.style.transform translateX(${this.x}px); // 碰撞检测、超出屏幕销毁逻辑省略 } }受击反馈也是典型的事件驱动动画僵尸被豌豆命中时短暂变白、向后顿一下再恢复正常。我通过给僵尸元素加一个临时类来实现bullet.hit(zombieEl) { zombieEl.classList.add(hit-flash); setTimeout(() zombieEl.classList.remove(hit-flash), 200); }对配合短促的音效打击感就出来了。这里有个经验受击反馈别做太长200 毫秒以内最舒服超过 300 毫秒玩家就会觉得僵尸在卡死而不是被打中。3. 卡槽系统一次完整交互的状态流转3.1 卡槽的 DOM 结构与视觉状态卡槽是玩家和游戏交互的第一道入口它的 UI 设计要直观地告诉玩家三件事有什么植物可以用、需要多少阳光、现在还差多长时间能用。我在游戏顶部放了一排卡片每张卡片的 DOM 结构大致长这样div classcard>.card { position: relative; width: 68px; height: 86px; background: #a6d96a; border: 2px solid #4e7a2a; border-radius: 6px; cursor: pointer; overflow: hidden; user-select: none; } .card.is-disabled { filter: grayscale(0.8); opacity: 0.75; cursor: not-allowed; } .cooldown-mask { position: absolute; left: 0; top: 0; width: 100%; height: 0; background: rgba(0, 0, 0, 0.55); pointer-events: none; }每张卡片上的>cardEl.addEventListener(click, () { const type cardEl.dataset.type; const plant PLANTS[type]; // 如果当前正在铲除模式先退出铲除模式 if (gameState.tool shovel) { exitShovelMode(); } // 校验阳光不足 or 冷却中直接忽略 if (gameState.sun plant.cost) return; if (getRemainCooldown(type) 0) return; // 如果已经是种植模式且点的是同一张卡片取消选中 if (gameState.tool plant gameState.selectedCard type) { exitPlantMode(); return; } // 否则进入种植模式 gameState.tool plant; gameState.selectedCard type; cardEl.classList.add(is-selected); });这里有个很容易忽略的逻辑再点一次同一张卡片应该取消种植模式。很多初版实现没有这一步导致玩家选完卡后想反悔只能随便找个格子种下去体验很糟。进入种植模式后我在网格上挂了一个mousemove监听让植物影子跟随鼠标并自动吸附到最近的网格交叉点gridEl.addEventListener(mousemove, (e) { if (gameState.tool ! plant) return; const rect gridEl.getBoundingClientRect(); const col Math.floor((e.clientX - rect.left) / CELL_WIDTH); const row Math.floor((e.clientY - rect.top) / CELL_HEIGHT); ghostEl.style.left col * CELL_WIDTH px; ghostEl.style.top row * CELL_HEIGHT px; });3.4 边界处理取消种植、右键和误触边界处理是交互系统最容易翻车的地方我把能想到的情况全列出来点击网格外区域退出种植模式不产生任何种植。点击卡槽区域如果当前是种植模式先退出再处理卡槽点击。右键点击取消种植模式相当于反悔。种植成功退出种植模式并清除选中态。实现方式是把退出种植抽成独立函数在任何需要的地方调用function exitPlantMode() { gameState.tool none; gameState.selectedCard null; document.querySelectorAll(.card).forEach(c c.classList.remove(is-selected)); ghostEl.style.display none; }提示右键取消需要监听contextmenu事件并调用preventDefault()否则浏览器会弹出默认菜单很破坏体验。4. 冷却系统用时间戳替代定时器的原因与实践4.1 setTimeout 方案为什么必踩坑V2 最开始我对冷却的处理简单粗暴种植成功后setTimeout(() 恢复卡片, 5000)。表面看没问题但很快我就撞上了三个坑多个卡片各自计时状态分裂。如果玩家种完向日葵又在第 2 秒种了豌豆射手两张卡片的恢复由两个独立的setTimeout管理代码里到处都是回调后期想暂停游戏或重新开始时定时器根本清不干净。页面切后台定时器被浏览器降频甚至挂起。切回来时setTimeout的倒计时不准了卡片会提前亮或者迟迟不亮。无法随时得到当前还剩多少冷却。因为剩余时间只存在于定时器的闭包深处外部代码拿不到想画冷却遮罩就非常费劲。4.2 基于时间戳的冷却计算正确思路是不主动计时只记录事件发生的时间点然后在渲染循环里用当前时间减去记录时间算出剩余量。冷却也是一样只要存一棵植物的lastPlantedAt剩下的全是数学。我直接在gameState上维护一个冷却记录对象const cooldowns { sunflower: { lastPlantedAt: 0 }, peashooter: { lastPlantedAt: 0 }, wallnut: { lastPlantedAt: 0 } }; function getRemainCooldown(type) { const plant PLANTS[type]; const elapsed gameState.timestamp - cooldowns[type].lastPlantedAt; return Math.max(0, plant.cooldown - elapsed); }这样设计的关键是gameState.timestamp——它由主循环里requestAnimationFrame回调的timestamp参数统一写入是全局唯一的当前时间基准。千万不要一个地方用performance.now()、一个地方用Date.now()、还有一个地方用setTimeout的时间基准不统一计算出来的冷却时间就会莫名其妙地偏大或偏小。4.3 冷却遮罩的视觉实现细节有了剩余冷却时间画遮罩就是纯展示逻辑。我每帧更新每张卡片的遮罩高度function updateCooldownMasks() { for (const type of Object.keys(PLANTS)) { const remain getRemainCooldown(type); const ratio remain / PLANTS[type].cooldown; // 0 ~ 1 const cardEl document.querySelector(.card[data-type${type}]); const maskEl cardEl.querySelector(.cooldown-mask); maskEl.style.height (ratio * 100) %; cardEl.classList.toggle(is-cooldown, remain 0); } }这里有一个我从看起来正常到真的正常的细节冷却遮罩的 CSS 千万不要加transition。我一开始给.cooldown-mask顺手写了transition: height 0.3s结果遮罩高度不是精准贴合剩余时间而是每 0.3 秒追一次视觉上永远慢半拍靠近结束那几秒特别明显。冷却遮罩不需要丝滑过渡它需要的是每帧精确反映当前剩余占比。4.4 冷却与阳光消耗的先后顺序说到种植的校验顺序很多实现是先判断冷却再判断阳光或者干脆只判断阳光。实际体验下来正确的顺序是阳光不足的优先拦截冷却中的卡片直接变灰。因为在玩家心智里卡片置灰本身就传达了现在不能用的信号没必要再弹提示。而一旦玩家点击一张阳光不足的卡片却没有任何反应会怀疑自己是不是没点到——所以我在阳光不足的卡片上加了轻微的抖动反馈。种植成功后的完整事务应该是function placePlant(type, row, col) { const plant PLANTS[type]; gameState.sun - plant.cost; // 1. 扣阳光 cooldowns[type].lastPlantedAt gameState.timestamp; // 2. 进入冷却 gameState.grid[row][col] createPlant(type, row, col); // 3. 写入网格 exitPlantMode(); // 4. 退出种植模式 updateSunDisplay(); // 5. 刷新阳光 UI }先扣阳光再进冷却顺序不能反。如果先进冷却再扣阳光极端情况下玩家阳光不够会掉进冷却了但种不起的尴尬状态。5. 铲子系统工具模式切换与目标判定的坑5.1 铲子是一种模式不是普通按钮如果把铲子做成一个点一下立即铲除某个植物的按钮那玩家根本来不及选择目标体验会非常差。原版《植物大战僵尸》的铲子实际上是一种工具模式切换点铲子进入准备铲除状态这时候鼠标移到植物上会有高亮提示再点击植物才真正铲除。我在代码里同样用gameState.tool来标识模式shovelEl.addEventListener(click, () { if (gameState.tool shovel) { exitShovelMode(); // 再次点击铲子退出铲除模式 } else { if (gameState.tool plant) exitPlantMode(); // 与种植互斥 gameState.tool shovel; shovelEl.classList.add(is-active); } });铲子模式和种植模式必须互斥不能一边举着铲子一边选卡片否则玩家在网格上点击时没人知道该种植物还是该铲植物。这又是一个状态机统一管理的受益点。5.2 悬停高亮与铲除判定铲除模式下的目标判定我建议使用事件委托在网格容器上统一监听gridEl.addEventListener(mouseover, (e) { if (gameState.tool ! shovel) return; const cell e.target.closest(.cell); if (!cell) return; const { row, col } cell.dataset; if (gameState.grid[row][col]) { cell.classList.add(shovel-target); } });铲除时同样只读取网格数据gridEl.addEventListener(click, (e) { if (gameState.tool ! shovel) return; const cell e.target.closest(.cell); if (!cell) return; const { row, col } cell.dataset; const plant gameState.grid[row][col]; if (!plant) return; removePlant(row, col); refundSun(plant.cost / 2); // 返还一半阳光给玩家容错空间 });5.3 坐标计算和网格释放的细节网格释放是个大坑。我最初实现铲除时只把植物 DOM 从页面上移除了但忘记把gameState.grid[row][col]置回null。结果就是铲完之后那个格子看着空了却怎么也种不上新植物。排查了半小时才在 console 里发现grid数组对应位置还留着旧对象的引用。所以在removePlant里我强制规定销毁 DOM 和释放网格数据必须在同一个函数里完成。function removePlant(row, col) { const plant gameState.grid[row][col]; if (!plant) return; plant.el.remove(); // 移除 DOM gameState.grid[row][col] null; // 释放网格 // 如果后续植物有动画句柄、事件监听也要在这里统一清理 }坐标计算的另一处坑在事件坐标换算从e.clientX到格子的col必须减去网格容器的边界rect.left而且网格容器不能有padding或border偏移否则点击位置会整体偏移一格左右。稳妥做法是在网格容器上用box-sizing: border-box并显式把padding设为 0。5.4 铲子与卡槽、冷却之间的互斥关系铲子不触发冷却、不重置卡槽状态——这是原版就有的规则V2 里我也保留。这意味着铲掉一棵向日葵后你可以立刻再种一棵不需要等原来的冷却。但如果卡槽本身已经处于种植模式那铲子点击必须优先把种植模式清掉否则会出现卡在种植模式里点不动铲子的假死状态。我最后把整个交互模式切换收敛成一张表写进注释里当前模式点击卡槽点击铲子点击网格无进入种植进入铲除无操作种植切换卡片或取消退出种植、进入铲除种植物铲除退出铲除、进入种植退出铲除铲除目标这张表几乎就是交互层的全部逻辑照着它写不会遗漏分支。6. 整合调试几个让我折腾到半夜的 Bug 复盘6.1 后台切回导致僵尸瞬移现象把页面切到其他标签页刷十分钟视频切回来发现所有僵尸已经走到屏幕左边直接吃掉了房子。根因requestAnimationFrame在后台会暂停切回来时第一帧的timestamp和上一帧差了几十万毫秒dt直接飙到几百秒僵尸按dt推进位移自然瞬移穿场。修复const dt Math.min((timestamp - lastTime) / 1000, 0.1);把单帧时间差封顶到 100 毫秒切回来最多只走一小步不会瞬间结束游戏。顺便说一句dt封顶还会避免切回来瞬间碰撞检测全部乱掉的连锁问题——逻辑层永远不应该处理几十秒的时间跳跃。6.2 冷却遮罩的 transition 陷阱现象冷却遮罩高度更新有肉眼可见的延迟明明冷却已经结束了卡片却还灰着约 0.3 秒。根因给.cooldown-mask写了transition: height 0.3s导致每帧设置的高度都被延迟执行遮罩永远在追赶真实值。修复删掉 transition让高度每帧直接跳变。实测下来跳变反而让冷却到点就能点的反馈更干脆。6.3 动态 DOM 的内存泄露隐患现象铲掉几十棵植物后页面明显变卡帧率掉到 20 左右。根因removePlant里只删了 DOM没清理子弹数组中已经失效的引用。更隐蔽的是我在创建植物元素时绑了匿名函数事件监听移除元素后监听器仍然被网格容器引用着。修复思路事件监听统一走事件委托不在具体植物元素上绑定管理子弹、僵尸的数组在移除元素时同步filter掉对应对象。动态元素的清理一定要和创建代码放在同一个模块里管理否则迟早漏掉。6.4 后续扩展的方向V2 这一版做完我给自己留了一张还能加什么的清单阳光生产逻辑可以挂到主循环里用elapsedTime控制向日葵每隔固定秒数吐一个阳光配合下落动画。波次系统可以抽象成每隔 N 秒生成一批僵尸同样用全局时间戳驱动不引入新定时器。植物升级、特效子弹、粒子系统都可以在现有PLANTS配置表里加字段动画系统加状态不会推翻架构。我个人做这个 V2 最大的体会是四个系统看着各管一摊但只要把全局状态 数据驱动 单一时间基准这三条立住代码根本乱不到哪里去。反而是一开始贪图方便用了一堆setTimeout和分散的状态才让自己半夜十二点还在 console 里翻undefined。最后再分享一个调试小技巧开发时在控制台定期打印gameState和cooldowns能看到所有系统状态的全貌。我大概有一半的交互 Bug都是靠console.table(gameState.grid)盯出来格子数据没释放、才定位到的。V2 跑通之后我对游戏开发里 90% 的复杂度都在状态管理这句话算是有切身体会了。
阅读完成 · 觉得有帮助?
咨询建站