从零拆解网页版植物大战僵尸:HTML+JavaScript塔防游戏开发实战
发布时间:2026/9/26 10:06:38来源:尧图网络
1. 从零拆解一个网页版植物大战僵尸整体设计思路1.1 为什么选 HTML JavaScript 这套组合做植物大战僵尸这种塔防游戏选技术栈其实就两条路要么用 Unity、Godot 这类游戏引擎要么用原生 Web 技术手搓。我一开始也纠结过后来想明白一件事——这个项目的核心诉求是“打开浏览器就能玩不用装任何东西”那 HTML CSS JavaScript 就是最直接的答案。原生 Web 技术做游戏有几个实打实的好处。第一是零依赖分发一个.html文件双击就能跑发给朋友测试连服务器都不用搭。第二是调试成本极低浏览器 F12 打开就能看 DOM 结构、打断点、改参数比引擎里翻日志快得多。第三是渲染方式灵活植物大战僵尸这种 2D 网格布局的游戏用 DOM 元素或者 Canvas 都能实现不像 3D 游戏那样被绑死在 WebGL 上。具体到渲染方案我最终选了DOM CSS 定位而不是纯 Canvas。原因很实际这个游戏的元素都是规整的格子植物、僵尸、子弹本质上都是“在某个格子里显示一张图”用绝对定位的div配合background-image就能搞定而且每个元素都是真实 DOM 节点点击事件、层级控制、动画过渡都能直接用 CSS 写省掉一大堆手写的碰撞检测和重绘逻辑。Canvas 更适合粒子效果多、元素数量上千的场景植物大战僵尸一屏也就几十个对象DOM 完全扛得住。提示如果你的目标是做成手机端也能流畅玩的版本元素数量再翻几倍那时候再考虑迁移到 Canvas 或 WebGL前期用 DOM 快速验证玩法是最划算的。1.2 游戏核心循环与模块划分任何游戏拆到最底层都是一个循环读取输入 → 更新状态 → 渲染画面。植物大战僵尸也不例外只不过它的“输入”是鼠标点击种植物“状态”是阳光数量、植物血量、僵尸位置“渲染”是把这些数据画到屏幕上。我把整个项目拆成了六个模块每个模块职责单一方便单独调试网格系统负责把屏幕划分成 5 行 9 列的战斗区域提供“像素坐标 ↔ 格子坐标”的双向转换。这是整个游戏的地基所有对象定位都依赖它。资源管理统一管理植物、僵尸、子弹的图片素材和基础属性血量、攻击力、冷却时间。用配置对象集中存放改数值不用翻代码。阳光系统定时掉落阳光、点击收集、数量增减。这是游戏的资源命脉节奏全靠它控制。植物系统种植逻辑、冷却判断、攻击行为、被啃食后的血量结算。僵尸系统生成波次、移动、攻击植物、死亡判定。主循环用requestAnimationFrame驱动每帧更新所有对象状态并检查胜负条件。这样拆的好处是我想调僵尸速度就只动僵尸模块想改阳光掉落频率就只动阳光模块互不干扰。很多新手一上来把所有逻辑塞进一个setInterval里改一处崩三处这是要极力避免的。1.3 坐标系设计像素与格子的换算关系这是最容易被忽略但最关键的细节。游戏里所有对象的位置我统一用格子坐标存储渲染时才换算成像素。为什么因为僵尸移动、子弹飞行如果直接用像素会出现“僵尸走到第 3.7 格”这种尴尬情况判断“僵尸是否碰到植物”就得做浮点数比较容易出 bug。我的做法是格子宽 80px、高 100px战斗区域左上角偏移(offsetX, offsetY)。那么格子(row, col)的中心像素坐标就是centerX offsetX col * 80 40 centerY offsetY row * 100 50反过来鼠标点击的像素坐标(px, py)换算成格子col Math.floor((px - offsetX) / 80) row Math.floor((py - offsetY) / 100)这套换算我封装成了gridToPixel()和pixelToGrid()两个函数全项目只在这里做坐标转换其他地方一律用格子坐标。实测下来这样写碰撞检测简单到极致——僵尸和植物在同一行、且僵尸的col小于等于植物的col加一个阈值就算碰到了。2. 核心细节解析与实操要点2.1 网格系统的搭建与坐标换算网格系统说白了就是一张背景图加一套换算规则。背景我用 CSS 的background-image铺一张草坪图然后用repeating-linear-gradient叠一层半透明的格子线这样不用额外切图就能看到格子边界调试的时候特别方便。#battlefield { position: relative; width: 720px; /* 9 列 × 80px */ height: 500px; /* 5 行 × 100px */ background: url(lawn.png) repeat; background-size: 80px 100px; }每个格子我不单独创建 DOM 节点而是用一个透明的“点击层”覆盖整个战场监听一次点击事件通过pixelToGrid()算出点的是哪个格子。这样做的好处是 DOM 节点数量少性能好坏处是没法给单个格子加 hover 效果。如果你想要“鼠标悬停高亮格子”的体验那就得老老实实创建 45 个格子 div用事件委托处理点击。两种方案我都试过前者性能好后者交互细腻看你的取舍。注意格子坐标一定要做边界检查。玩家可能点到战场外面row或col会算出负数或超出范围这时候必须直接 return否则后面数组越界会报错。2.2 植物对象的属性设计与冷却机制每种植物的属性我用一个配置对象描述核心字段包括cost阳光消耗、cooldown冷却毫秒数、hp血量、attack攻击力、range攻击范围用格子数表示、produce是否产阳光。以向日葵和豌豆射手为例const PLANT_CONFIG { sunflower: { cost: 50, cooldown: 7500, hp: 300, attack: 0, produce: 25000 }, peashooter:{ cost: 100, cooldown: 7500, hp: 300, attack: 20, range: 9 }, wallnut: { cost: 50, cooldown: 30000,hp: 4000,attack: 0, range: 0 } };冷却机制是新手最容易写错的地方。我见过有人用setTimeout来重置冷却结果玩家疯狂点击时创建了几十个定时器内存直接爆掉。正确做法是记录上次种植的时间戳每次点击时用Date.now() - lastPlantTime和cooldown比较function canPlant(type) { const cfg PLANT_CONFIG[type]; if (sun cfg.cost) return false; if (Date.now() - (lastPlantTime[type] || 0) cfg.cooldown) return false; return true; }冷却的视觉反馈我用 CSS 的conic-gradient画一个扇形遮罩随时间从满圆缩到零比单纯变灰直观得多。这个技巧在很多塔防游戏里都能复用。2.3 僵尸移动与攻击的帧同步处理僵尸移动的核心是“每帧走多少像素”。这里有个坑如果你直接写zombie.x - 1那在不同刷新率的显示器上速度会不一样144Hz 的屏幕上僵尸跑得比 60Hz 快一倍多。解决办法是基于时间差计算位移let lastTime 0; function gameLoop(now) { const dt now - lastTime; lastTime now; zombies.forEach(z { z.x - z.speed * dt / 1000; // speed 单位像素/秒 }); requestAnimationFrame(gameLoop); }这样无论屏幕刷新率多少僵尸每秒移动的像素数都是恒定的。speed我设成 20 像素/秒也就是 4 秒走一格节奏和原版比较接近。僵尸攻击植物的判定我用的是“同行 横向距离小于阈值”。具体来说僵尸的col减去植物的col小于 0.3 格时僵尸停止移动开始按固定间隔啃食每次扣植物attack点血。这里要注意僵尸啃食时不能继续移动否则会“穿模”走到植物后面去。我一开始就踩过这个坑僵尸一边啃一边往前挪最后跑到植物右边去了画面非常诡异。2.4 阳光掉落与收集的交互细节阳光系统有两个来源天上定时掉落以及向日葵产出。天上掉落的阳光我用一个独立的定时器每 8 到 12 秒随机生成一个从屏幕顶部飘落到随机位置。飘落动画用 CSS 的transition配合transform: translateY()实现比 JS 逐帧改位置省事。点击收集的逻辑很简单给阳光元素绑click事件点击后sun 25并移除元素。但有个体验细节阳光飘落过程中如果玩家没点落地后要停留几秒再消失不能立刻没了否则玩家手速慢一点就亏了。我设的是落地后停留 5 秒最后 1 秒开始闪烁提示即将消失。提示阳光的z-index一定要设得比植物和僵尸高否则会被挡住点不到。这个 bug 我调了半小时才发现血的教训。3. 实操过程与核心环节实现3.1 从空白 HTML 到可运行战场的完整搭建第一步搭 HTML 骨架。整个页面就三个主要区域顶部状态栏显示阳光数、波次、中间战场、底部植物选择栏。!DOCTYPE html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1 title植物大战僵尸 - 网页版/title link relstylesheet hrefstyle.css /head body div idtopbar span idsun-count阳光: 50/span span idwave-info第 1 波/span /div div idbattlefield/div div idplant-bar/div script srcgame.js/script /body /html第二步写 CSS 把战场和植物栏摆好。战场固定 720×500植物栏用 flex 横向排列每个植物卡片显示图片和阳光消耗。第三步写 JS 初始化。核心是三个数组plants、zombies、bullets分别存所有活动对象。每帧遍历这三个数组更新状态然后同步到 DOM。这里有个性能优化点不要每帧都重建 DOM而是对象创建时生成 DOM 节点之后只改style.left和style.top。我一开始图省事每帧innerHTML重绘结果僵尸一多就卡成幻灯片改成复用节点后丝滑多了。3.2 植物种植的完整交互链路种植的交互链路是这样的玩家点击植物卡片 → 卡片高亮进入“待种植”状态 → 鼠标移到战场上格子高亮预览 → 点击格子检查阳光和冷却 → 通过则创建植物对象和 DOM 节点扣除阳光记录冷却时间 → 退出待种植状态。let selectedPlant null; plantBar.addEventListener(click, e { const card e.target.closest(.plant-card); if (!card) return; const type card.dataset.type; if (!canPlant(type)) return; selectedPlant type; highlightCards(type); }); battlefield.addEventListener(click, e { if (!selectedPlant) return; const rect battlefield.getBoundingClientRect(); const { row, col } pixelToGrid(e.clientX - rect.left, e.clientY - rect.top); if (row 0 || row 4 || col 0 || col 8) return; if (isOccupied(row, col)) return; plantAt(selectedPlant, row, col); selectedPlant null; });isOccupied()检查该格子是否已有植物避免重复种植。这个检查必须做否则两个植物叠在一起僵尸啃哪个都乱套。3.3 僵尸波次生成与难度曲线设计僵尸生成我用“波次”来管理。每一波定义僵尸数量、类型和生成间隔。第一波 3 只普通僵尸间隔 3 秒第二波 5 只间隔 2.5 秒之后每波递增到第 5 波开始混入路障僵尸血量翻倍。const WAVES [ { count: 3, interval: 3000, types: [normal] }, { count: 5, interval: 2500, types: [normal] }, { count: 7, interval: 2000, types: [normal, cone] }, { count: 10, interval: 1800, types: [normal, cone] }, { count: 15, interval: 1500, types: [normal, cone, bucket] } ];难度曲线的设计原则是“让玩家刚好喘不过气”。太简单没挑战太难直接劝退。我的经验是第一波给玩家足够时间种 2 到 3 个向日葵攒阳光第二波开始有压力第三波必须已经布好防线。如果实测发现某波太难就调大interval或者减少count多试几次就能找到舒服的节奏。僵尸从屏幕右侧外生成随机选一行然后向左移动。生成位置我设在col 9.5也就是战场右边界外半格这样僵尸是“走进来”的视觉上更自然。3.4 子弹发射与命中判定的实现豌豆射手的攻击逻辑是每 1.5 秒检查自己所在行有没有僵尸有的话发射一颗子弹。子弹从植物位置生成以 300 像素/秒向右飞行碰到僵尸就扣血并消失。function updateBullets(dt) { bullets bullets.filter(b { b.x b.speed * dt / 1000; const hit zombies.find(z z.row b.row Math.abs(z.x - b.x) 30 ); if (hit) { hit.hp - b.attack; if (hit.hp 0) removeZombie(hit); return false; // 子弹消失 } if (b.x 720) return false; // 飞出屏幕 return true; }); }命中判定我用的是“同行 横向距离小于 30 像素”简单粗暴但够用。更精确的做法是矩形碰撞检测但对这个游戏来说没必要30 像素的容差已经足够准确玩家肉眼看不出问题。注意子弹和僵尸的数组遍历顺序有讲究。如果先更新僵尸位置再检测子弹可能出现子弹“穿过”僵尸的情况。我的做法是先更新子弹位置并检测命中再更新僵尸位置这样能保证判定准确。4. 常见问题与排查技巧实录4.1 游戏卡顿与内存泄漏的排查游戏跑几分钟后越来越卡这是最常见的性能问题。我用 Chrome 的 Performance 面板录了一段发现两个元凶一是僵尸死亡后 DOM 节点没移除二是子弹数组只增不减。排查方法很简单在控制台定时打印plants.length、zombies.length、bullets.length如果数字只涨不跌那就是没清理。解决就是在对象死亡时同时做两件事——从数组里filter掉以及调用element.remove()移除 DOM 节点。function removeZombie(z) { z.el.remove(); zombies zombies.filter(item item ! z); }还有一个隐蔽的坑requestAnimationFrame如果忘记在页面隐藏时暂停切到后台标签页后游戏还在跑回来时僵尸已经走到家门口了。解决办法是监听visibilitychange事件页面隐藏时暂停循环。4.2 点击事件失效与层级冲突“点了植物卡片没反应”或者“点了格子没种上”这类问题八成是事件层级冲突。常见原因有三个一是阳光元素的z-index太高盖住了植物卡片二是战场上的点击层被其他元素遮挡三是pointer-events: none没设对。我的排查套路是打开 F12用元素选择器点一下没反应的位置看选中的是哪个元素。如果选中的不是你期望的节点那就是层级问题。解决方法是给装饰性元素比如格子线、背景加pointer-events: none让点击穿透到真正的交互层。4.3 僵尸穿模与判定异常的修复僵尸穿模有两种表现一是僵尸走到植物右边去了二是僵尸和植物重叠但不啃食。前者是因为移动和攻击的判定顺序错了僵尸在啃食状态下还在执行移动逻辑。修复方法是给僵尸加一个state字段walking状态下才移动eating状态下只扣血。后者通常是判定阈值设得太小。我一开始设的是Math.abs(z.x - p.x) 5结果僵尸走到植物跟前了还不啃因为像素差刚好是 6。后来改成 30问题解决。这个阈值要根据僵尸和植物的图片宽度来调一般取两者宽度之和的一半比较合适。4.4 常见问题速查表问题现象可能原因排查方法解决方案游戏越来越卡DOM 节点未清理控制台打印数组长度死亡时 remove 节点并 filter 数组点击无反应元素层级遮挡F12 选中元素查看装饰元素加 pointer-events: none僵尸穿模移动与攻击状态冲突观察僵尸 state 字段加状态机eating 时不移动子弹穿过僵尸更新顺序错误检查循环内更新顺序先更新子弹判定再更新僵尸不同屏幕速度不一未基于时间差计算对比不同刷新率设备用 dt 计算位移阳光点不到z-index 太低检查阳光元素层级提高阳光 z-index冷却不重置定时器方案错误检查是否用 setTimeout改用时间戳比较4.5 几个让我少走弯路的实操心得第一个心得先做能跑的最小版本再加功能。我一开始想一步到位把植物、僵尸、阳光、波次全写完再测试结果一运行满屏报错根本不知道从哪查起。后来改成先只做“点击种一个植物”跑通了再加僵尸再加子弹每步都能验证效率反而高得多。第二个心得数值全部抽到配置对象里。僵尸速度、植物血量、阳光掉落间隔这些数值我改了不下二十遍。如果散落在代码各处改一次要翻半天。集中到CONFIG对象后调平衡就是改几个数字的事。第三个心得善用浏览器的断点调试。僵尸不啃植物的时候我在updateZombies里打了个断点一看state字段一直是walking瞬间定位到状态切换的条件写错了。比console.log到处打日志高效太多。第四个心得图片素材用雪碧图或者 base64 内联。我一开始用一堆独立的 png 文件加载时闪一下白屏。后来把常用素材转成 base64 直接写进 CSS一个 HTML 文件自包含发给别人直接能玩体验好很多。5. 素材准备与资源管理5.1 图片素材的获取与处理植物大战僵尸的素材网上能找到不少但质量参差不齐。我的建议是优先找透明背景的 PNG尺寸统一处理成格子大小的整数倍。植物统一 80×100僵尸统一 80×120比格子高一点视觉上更立体子弹 20×20。素材处理我用的是在线的图片编辑工具批量裁剪、去背景、压缩。压缩这一步别省原图动辄几百 KB十几张图加起来好几 MB加载慢得让人想关页面。压到每张 20KB 以内整体控制在 500KB 左右加载就很快了。如果找不到合适的素材也可以自己用简单的几何图形拼。我见过有人用纯 CSS 画植物和僵尸虽然简陋但别有一番风味而且零素材依赖一个 HTML 文件搞定。5.2 用配置对象统一管理游戏数值所有游戏数值我放在一个CONFIG对象里分门别类const CONFIG { grid: { rows: 5, cols: 9, cellW: 80, cellH: 100 }, sun: { start: 50, dropInterval: [8000, 12000], value: 25 }, zombie: { baseSpeed: 20, attackInterval: 1000 }, bullet: { speed: 300, size: 20 } };这样调平衡的时候我只需要改这个对象不用碰任何逻辑代码。而且这个对象可以很方便地导出成 JSON将来想做关卡编辑器直接读 JSON 就能生成不同难度。提示数值配置最好加注释说明单位和含义比如baseSpeed: 20后面注明“像素/秒”。过一个月再回来看代码没有注释你根本想不起来这个 20 是什么单位。5.3 音效与背景音乐的轻量化方案音效不是必须的但加上之后游戏体验提升明显。我用的是 Web Audio API 直接合成简单音效比如种植的“噗”声、僵尸被击中的“啪”声用振荡器几行代码就能生成不用加载任何音频文件。function playSound(freq, duration) { const ctx new AudioContext(); const osc ctx.createOscillator(); const gain ctx.createGain(); osc.frequency.value freq; osc.connect(gain); gain.connect(ctx.destination); osc.start(); gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime duration); osc.stop(ctx.currentTime duration); }种植时调用playSound(440, 0.1)僵尸死亡调用playSound(220, 0.2)。这样零音频文件整个游戏还是一个 HTML 文件非常干净。如果你想要更丰富的音效再考虑引入外部音频文件但要注意版权问题用自己录的或者明确可商用的素材。6. 从能玩到好玩体验优化与扩展方向6.1 让操作更跟手的几个细节游戏能跑起来只是第一步玩起来爽不爽全在细节。我做了几个优化效果立竿见影。第一是种植预览。鼠标移到格子上时显示一个半透明的植物影子让玩家知道种下去长什么样、占多大地方。实现就是在mousemove时更新一个预览 div 的位置和背景图。第二是阳光自动收集。原版是要手动点的但网页版玩家可能懒得点。我加了个开关开启后阳光落地 1 秒自动收集适合休闲玩家。这个功能用setTimeout在阳光落地时触发就行。第三是僵尸血条。僵尸被攻击后头顶显示一个小血条让玩家知道还差几发子弹能打死。血条用 CSS 的width百分比控制更新时只改宽度性能开销极小。6.2 难度平衡的调整经验难度平衡是个体力活没有捷径就是反复试玩。我的经验是先定目标时长再倒推数值。我希望一局游戏 5 到 8 分钟那么按每波僵尸 30 到 60 秒算大概 8 到 10 波比较合适。阳光经济也要算账。一个向日葵 50 阳光每 25 秒产 25 阳光100 秒回本。如果玩家第一波前种 3 个向日葵大概 2 分钟后阳光就充裕了。如果发现玩家总是阳光不够就把向日葵产阳光的间隔调短或者初始阳光给多一点。僵尸强度我用“总血量”来衡量。第一波总血量 3003 只普通僵尸各 100第二波 500第三波 800这样递增比较平滑。如果某波玩家反馈太难就把总血量降 20% 再试。6.3 后续可以扩展的玩法方向这个项目做完基础版后可扩展的方向很多。我列几个我觉得有意思的更多植物类型樱桃炸弹范围伤害、寒冰射手减速、土豆地雷延时爆炸。每加一种植物就是加一个配置对象和一段特殊逻辑。更多僵尸类型撑杆僵尸跳过第一个植物、铁桶僵尸超高血量、舞王僵尸召唤小弟。僵尸的特殊行为用状态机实现。关卡系统把波次配置存成 JSON不同关卡读不同配置加个关卡选择界面。存档功能用localStorage存最高波次记录玩家下次打开能看到自己的最好成绩。移动端适配把战场缩放适配手机屏幕触摸事件替代鼠标事件。这个工作量不小但做完之后受众会大很多。我个人最推荐先做“更多植物类型”因为这是玩家感知最强的扩展而且实现成本相对低。每加一种植物游戏的可玩性就上一个台阶。6.4 代码组织与后续维护建议最后说说代码组织。我建议把代码拆成多个文件config.js放配置grid.js放网格系统plants.js、zombies.js、bullets.js各管各的main.js做主循环和初始化。用 ES6 的import/export组织浏览器原生支持不用打包工具。如果嫌多文件麻烦至少也要用注释把不同模块分隔清楚比如// 僵尸系统 。我见过有人一个文件两千行没有任何分隔改个僵尸速度要翻三分钟太痛苦了。版本管理用 Git每完成一个功能就提交一次写清楚提交信息。这样改崩了随时能回滚比手动备份game_v1.js、game_v2.js靠谱得多。我在实际做这个项目的过程中最大的体会是别追求一次写完美先让它跑起来再让它好玩最后让它好看。很多人卡在第一步想先把架构设计得天衣无缝再动手结果迟迟出不了成果。实际上游戏开发就是不断试错和调整的过程先有个能玩的版本你才知道哪里需要改。
网站建设高端定制企业官网