尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

纯JS实现植物大战僵尸:DOM游戏开发实战指南

纯JS实现植物大战僵尸:DOM游戏开发实战指南 简介这是一份面向前端初学者与JavaScript进阶学习者的趣味实践项目源码聚焦游戏逻辑实现与DOM交互开发帮助开发者在真实场景中掌握ES6语法、面向对象编程、事件驱动机制及基础动画控制。资源包含364个文件主体为22个核心JS脚本实现植物/僵尸类定义、碰撞检测、游戏循环等、148个PNG与135个GIF素材用于角色渲染与动作表现辅以HTML入口页、BMP/SWF资源及少量数据库文件整体压缩包仅7.8MB轻量易解压运行。已有991人学习下载适合通过可运行的完整游戏案例理解前端工程组织方式——代码模块清晰分离植物与僵尸均以Class封装配合requestAnimationFrame实现流畅动画DOM操作实时更新战况同时涵盖try...catch错误处理与定时器性能优化实践。1. 用纯 JavaScript 复刻《植物大战僵尸》不是玩具项目而是前端工程能力的试金石你点开一个“植物大战僵尸 JavaScript 版源码”的 GitHub 仓库发现它没用任何游戏引擎不依赖 WebGL 或 Canvas 2D 封装库只靠原生 DOM CSS 动画 原生 JavaScript 实现了阳光收集、植物种植、僵尸生成、碰撞判定、音效触发、关卡推进等全部核心逻辑——这不是教学 Demo而是对 DOM 性能边界、事件调度精度、状态机设计、资源加载策略的真实检验。它面向两类人想把“会写 for 循环”升级为“能控场千级元素”的中级前端以及需要快速验证交互原型、又不愿被 Unity 或 Phaser 学习曲线拖慢节奏的产品技术负责人。这类项目不追求 60fps 全屏渲染但必须在 Chrome/Firefox/Edge 最新稳定版中稳定运行 30 分钟以上不卡顿、不内存泄漏、不因 setTimeout 累积误差导致波次错乱。它解决的不是“能不能动”而是“动得准不准、撑不撑得住、改不改得快”。2. 从 DOM 结构到帧循环构建可响应、可调试的游戏主干2.1 游戏容器与层级划分为什么不用canvas而坚持 DOM主流复刻项目如pvz-js、zombie-game-js选择 DOM 而非 Canvas核心动因是调试可见性与CSS 驱动能力。DOM 元素可直接在浏览器开发者工具中 inspect、实时修改 transform、观察 computed styles、捕获 mouse/touch 事件路径。更重要的是植物生长动画、僵尸受伤抖动、阳光飘落轨迹全部由 CSS keyframes 控制避免手动计算每帧位移。典型结构如下div idgame-container div idlawn classlawn-grid/div !-- 5×9 网格每个 cell 为独立 div -- div idui-bar classui-bar/div !-- 阳光数、植物选择栏、暂停按钮 -- div idzombie-lane-0 classzombie-lane/div div idzombie-lane-1 classzombie-lane/div !-- ... lane-4 -- /div提示lawn-grid使用 CSS Grid 布局display: grid; grid-template-columns: repeat(9, 1fr); grid-template-rows: repeat(5, 1fr);每个cell设置position: relative确保植物和僵尸的绝对定位锚点统一。避免使用float或inline-block防止重排引发性能抖动。2.2 主循环requestAnimationFrame 替代 setInterval 的三重必要性所有“卡顿”问题根源常在于错误的定时器选型。setInterval在页面失焦时仍计时恢复后批量触发导致逻辑堆叠其时间精度受 JS 主线程阻塞影响无法保证 16ms 帧率。正确做法是绑定requestAnimationFrame并做 delta time 校准class GameLoop { constructor() { this.lastTime 0; this.frameCount 0; this.fpsCounter { last: 0, count: 0 }; } start() { const loop (timestamp) { const deltaTime timestamp - this.lastTime; this.lastTime timestamp; // 每 1000ms 统计一次 FPS this.fpsCounter.count; if (timestamp - this.fpsCounter.last 1000) { console.debug(FPS: ${this.fpsCounter.count}); this.fpsCounter { last: timestamp, count: 0 }; } // 核心更新传入 deltaTime 驱动物理与动画 this.update(deltaTime); this.render(); requestAnimationFrame(loop); }; requestAnimationFrame(loop); } update(deltaTime) { // 植物冷却倒计时毫秒级精度 this.plants.forEach(p p.cooldown - deltaTime); // 僵尸移动距离 速度 × deltaTime避免帧率波动导致位移跳跃 this.zombies.forEach(z z.x z.speed * (deltaTime / 16)); } }参数说明deltaTime单位为毫秒z.speed定义为“每 16ms 移动像素数”因此z.speed * (deltaTime / 16)保证无论实际帧率是 30 还是 60僵尸在 1 秒内移动总距离恒定。若直接写z.x z.speed高帧率下僵尸会“瞬移”。2.3 状态机驱动游戏阶段从“开始界面”到“通关弹窗”的可控流转游戏不是线性脚本而是多状态并发系统。常见状态包括MENU主菜单、LEVEL_STARTING关卡加载中、PLAYING正常进行、PAUSED暂停、GAME_OVER失败、LEVEL_COMPLETE通关。状态切换必须原子化且禁止隐式跳转class GameStateManager { constructor() { this.state MENU; this.handlers { MENU: this.handleMenu, PLAYING: this.handlePlaying, PAUSED: this.handlePaused, // ... }; } setState(newState) { if (this.state newState) return; // 退出当前状态清理事件监听、停止动画、释放资源 this.cleanupState(this.state); // 进入新状态初始化数据、绑定事件、启动循环 this.initState(newState); this.state newState; } handlePlaying() { // 只在此状态响应键盘种植指令 document.addEventListener(keydown, this.onPlantKeydown); // 启动僵尸波次生成器 this.waveScheduler.start(); } cleanupState(state) { switch(state) { case PLAYING: document.removeEventListener(keydown, this.onPlantKeydown); this.waveScheduler.stop(); break; case PAUSED: this.audio.pauseAll(); break; } } }注意setState必须显式调用cleanupState和initState而非仅修改this.state。否则残留的事件监听器或定时器会持续消耗 CPU导致内存泄漏。这是线上复刻项目崩溃的最常见原因。3. 植物与僵尸的实体建模用类封装行为用组合替代继承3.1 植物基类从“向日葵”到“樱桃炸弹”的共性抽象所有植物共享id、x/y坐标、health、cooldown、isAlive等属性但行为差异极大。采用组合而非继承基类定义骨架具体植物通过behavior对象注入逻辑class Plant { constructor(id, x, y, config) { this.id id; this.x x; // 网格列索引 this.y y; // 网格行索引 this.health config.health || 100; this.cooldown 0; // 毫秒0 表示可再次使用 this.isAlive true; this.behavior config.behavior; // { onTick, onCollide, onDeath } this.element this.createElement(); // 绑定 DOM 元素 } createElement() { const el document.createElement(div); el.className plant plant-${this.id}; el.style.left ${this.x * 80}px; // 假设 cell 宽 80px el.style.top ${this.y * 100}px; document.getElementById(lawn).appendChild(el); return el; } update(deltaTime) { if (!this.isAlive) return; // 冷却倒计时 if (this.cooldown 0) { this.cooldown - deltaTime; if (this.cooldown 0) this.cooldown 0; } // 执行行为逻辑如向日葵产阳光 if (this.behavior.onTick) { this.behavior.onTick(this, deltaTime); } } // 示例向日葵行为 static SUNFLOWER_BEHAVIOR { onTick: (plant, deltaTime) { if (plant.cooldown 0) { game.spawnSun(plant.x, plant.y - 1); // 在头顶生成阳光 plant.cooldown 10000; // 10秒产一次 } } }; }关键设计onTick函数接收plant实例和deltaTime使其能访问自身状态并执行副作用如 spawnSun。避免在behavior中闭包捕获外部变量防止内存泄漏。3.2 僵尸实体碰撞检测与伤害传递的轻量实现僵尸移动由 CSStransform: translateX()驱动但碰撞判定必须基于像素坐标。采用 AABB轴对齐包围盒算法每帧计算僵尸与植物矩形交集class Zombie { constructor(id, lane, config) { this.id id; this.lane lane; // 0-4 表示第几行 this.x 800; // 初始位置在屏幕外右侧 this.health config.health || 270; this.speed config.speed || 0.5; // px/ms this.isAlive true; this.element this.createElement(); } createElement() { const el document.createElement(div); el.className zombie zombie-${this.id}; el.style.left ${this.x}px; el.style.top ${this.lane * 100 20}px; // 偏移避免贴边 document.getElementById(zombie-lane-${this.lane}).appendChild(el); return el; } update(deltaTime) { if (!this.isAlive) return; this.x - this.speed * (deltaTime / 16); // 向左移动 this.element.style.transform translateX(${this.x}px); // 检测与同 lane 植物的碰撞 const plantsInLane game.plants.filter(p p.y this.lane p.isAlive); for (const plant of plantsInLane) { if (this.isCollidingWith(plant)) { plant.health - 10; if (plant.health 0) { plant.isAlive false; plant.element.remove(); } break; // 一帧只处理一个碰撞避免连锁伤害 } } } isCollidingWith(plant) { // 简化 AABB僵尸宽 60px高 90px植物宽 50px高 80px const zombieRect { left: this.x, right: this.x 60, top: this.lane * 100 20, bottom: this.lane * 100 110 }; const plantRect { left: plant.x * 80, right: plant.x * 80 50, top: plant.y * 100, bottom: plant.y * 100 80 }; return !(zombieRect.right plantRect.left || zombieRect.left plantRect.right || zombieRect.bottom plantRect.top || zombieRect.top plantRect.bottom); } }性能优化isCollidingWith不调用getBoundingClientRect()触发重排而是维护预计算的矩形坐标。plantsInLane过滤提前限定范围避免遍历全部植物。3.3 阳光系统DOM 元素池与生命周期管理阳光Sun是高频创建/销毁对象直接document.createElement会导致 GC 频繁。采用对象池模式复用 DOM 元素class SunPool { constructor() { this.pool []; this.maxSize 20; } acquire(x, y) { let sunEl; if (this.pool.length 0) { sunEl this.pool.pop(); sunEl.style.display block; sunEl.style.left ${x}px; sunEl.style.top ${y}px; sunEl.dataset.value 25; // 默认值 } else { sunEl document.createElement(div); sunEl.className sun; sunEl.innerHTML ☀; document.body.appendChild(sunEl); } return sunEl; } release(sunEl) { if (this.pool.length this.maxSize) { sunEl.style.display none; this.pool.push(sunEl); } else { sunEl.remove(); } } } // 全局实例 const sunPool new SunPool(); // 使用示例 function spawnSun(x, y) { const sunEl sunPool.acquire(x, y); // 添加飘落动画 sunEl.style.transition top 1s ease-out, opacity 1s; sunEl.style.top ${y - 100}px; sunEl.style.opacity 0; // 1秒后自动回收 setTimeout(() { sunPool.release(sunEl); }, 1000); }参数说明maxSize20是经验值覆盖单关卡最大同时存在阳光数。过小导致频繁创建/销毁过大占用内存。可通过performance.memory监控调整。4. 关卡与资源加载JSON 配置驱动按需加载音频与图片4.1 关卡配置 JSON用声明式描述代替硬编码将波次、僵尸类型、出现时间、阳光初始值等抽离为 JSON使关卡编辑无需改 JS 逻辑// level-1.json { initialSun: 50, waves: [ { time: 5000, zombies: [ {type: basic, count: 3, interval: 2000}, {type: cone, count: 1, interval: 5000} ] }, { time: 15000, zombies: [ {type: bucket, count: 2, interval: 3000} ] } ], objectives: [ {type: survive, duration: 60000}, {type: kill, target: 20} ] }加载逻辑使用fetchPromise.all确保资源就绪async function loadLevel(levelId) { try { const levelData await fetch(/levels/level-${levelId}.json).then(r r.json()); // 预加载本关卡所需图片 const imagePromises levelData.waves.flatMap(wave wave.zombies.map(z new Promise(resolve { const img new Image(); img.onload () resolve(); img.src /assets/zombies/${z.type}.png; }) ) ); await Promise.all(imagePromises); // 初始化游戏状态 game.reset(levelData); game.start(); } catch (err) { console.error(Failed to load level ${levelId}:, err); showErrorMessage(关卡加载失败请刷新重试); } }注意Promise.all会等待所有图片加载完成才 resolve避免游戏开始后出现“图片闪烁”。若某张图 404catch会捕获并降级处理如显示占位符。4.2 音频管理Web Audio API 实现低延迟播放audio标签在快速连续播放时有延迟且无法精确控制音量。改用 Web Audio APIclass AudioManager { constructor() { this.context new (window.AudioContext || window.webkitAudioContext)(); this.sounds {}; } async loadSound(name, url) { const response await fetch(url); const arrayBuffer await response.arrayBuffer(); const audioBuffer await this.context.decodeAudioData(arrayBuffer); this.sounds[name] audioBuffer; } play(name, volume 1) { if (!this.sounds[name]) return; const source this.context.createBufferSource(); const gainNode this.context.createGain(); source.buffer this.sounds[name]; source.connect(gainNode); gainNode.connect(this.context.destination); gainNode.gain.value volume; source.start(); } } // 初始化 const audio new AudioManager(); audio.loadSound(sun-collect, /sounds/sun-collect.mp3); audio.loadSound(plant-click, /sounds/plant-click.mp3); // 使用 audio.play(sun-collect, 0.7);参数说明volume0.7将音量降至 70%避免多音效叠加爆音。gainNode.gain.value可动态调节支持“静音”功能。5. 调试与性能调优定位卡顿、内存泄漏与逻辑偏差的三板斧5.1 用 Performance 面板抓取 16ms 帧率瓶颈当游戏运行卡顿时打开 Chrome DevTools →Performance→ 点击录制 10 秒 → 停止后分析查看 Main 线程火焰图寻找超过 16ms 的长任务如update函数耗时 25ms确认是否因querySelectorAll遍历过多 DOM 或未节流的resize事件。检查 Rendering若Layout或Paint时间飙升说明 CSS 触发了强制同步布局如读取offsetTop后立即写style.left。修复方式批量读取所有布局信息再批量写入。Memory 标签页录制前后堆快照对比查找重复创建未释放的Zombie实例或未清理的addEventListener。实战技巧在GameLoop.update开头插入console.time(update)结尾console.timeEnd(update)快速定位 JS 执行热点。5.2 植物冷却与僵尸波次的时间漂移校准requestAnimationFrame的timestamp是单调递增的但若update函数执行超时会导致后续帧deltaTime异常增大如 100ms进而使僵尸瞬间跨越半屏。解决方案限制单帧最大deltaTimeupdate(timestamp) { const rawDeltaTime timestamp - this.lastTime; this.lastTime timestamp; // 防止大间隔导致逻辑崩坏 const deltaTime Math.min(rawDeltaTime, 100); // 最大按 100ms 计算 // 正常更新逻辑... }为什么是 100ms人类感知卡顿的阈值约 120ms设为 100ms 留出余量。若真实间隔超过 100ms说明主线程已严重阻塞应优先排查长任务而非强行补偿。5.3 检查 DOM 元素泄漏的终端命令在控制台执行以下命令快速统计游戏区域 DOM 元素数量是否异常增长// 统计所有植物、僵尸、阳光元素 const plantCount document.querySelectorAll(.plant).length; const zombieCount document.querySelectorAll(.zombie).length; const sunCount document.querySelectorAll(.sun[style*display: block]).length; console.log(Plants: ${plantCount}, Zombies: ${zombieCount}, Suns: ${sunCount}); // 检查是否超出预期如关卡结束时植物应为 0 if (plantCount 50) console.warn(植物元素疑似未清理);配合Elements面板的Break on attribute change右键某个僵尸元素 →Break on→attribute modification当其style.transform被修改时断点可追踪到哪段代码未正确移除元素。关键指标正常运行中zombie元素数应 ≤ 当前波次最大僵尸数如 5sun元素数应 ≤ 20对象池上限。若持续增长必有removeChild缺失或element.remove()未调用。5.4 用 localStorage 持久化玩家进度的最小实现保存关卡、阳光数、已解锁植物避免刷新丢失class SaveManager { static save(key, data) { try { localStorage.setItem(pvz-${key}, JSON.stringify(data)); } catch (e) { console.warn(localStorage quota exceeded, skip save); } } static load(key, defaultValue null) { try { const data localStorage.getItem(pvz-${key}); return data ? JSON.parse(data) : defaultValue; } catch (e) { return defaultValue; } } } // 保存进度 SaveManager.save(progress, { currentLevel: 3, totalSun: 12500, unlockedPlants: [peashooter, sunflower, wallnut] }); // 加载 const progress SaveManager.load(progress, { currentLevel: 1, totalSun: 50 });注意localStorage是同步阻塞操作不要在update循环中频繁调用。仅在关卡结束、植物解锁等关键节点保存。本文还有配套的精品资源点击获取
返回列表