ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Canvas+JavaScript实战:球球吞噬对战游戏的坐标、碰撞与AI追击逻辑解析

Canvas+JavaScript实战:球球吞噬对战游戏的坐标、碰撞与AI追击逻辑解析 开局先聊点实际的。很多刚接触“球球对战”玩法的人第一反应是这不过是一款靠手速吃饭的休闲小游戏可真要自己动手把它做出来才发现里面既有坐标系与镜头缩放的数学问题又有碰撞检测、AI 追击策略、对象池管理这些工程化细节并不像表面上那么轻松。这套“最强球球鏖战!「Ariel篇」(8-18)”系列就是围绕一个可以独立运行的球球吞噬对战小游戏展开的实现记录核心目标不是做商业级产品而是把玩法背后的关键逻辑拆清楚、写明白让读者在浏览器里就能玩到一场有 AI 对手参与的“球球鏖战”。本文是这个系列中关于游戏主体框架与 Ariel 这个 AI 角色行为逻辑的落地篇适合有一定 JavaScript 基础、想通过小项目巩固前端逻辑能力的开发者也适合从零接触 Canvas 游戏开发的初学者按步骤照做。1. 从玩法说起球球对战游戏到底在做什么1.1 一句话定义“球球鏖战”本质上是玩家控制一个圆形小球在一个二维世界地图里移动吃掉地图上随机生成的小食物来增大自身体积同时还要躲避或攻击其他被 AI 控制的球。游戏的大致规则是当两个球发生接触时体积更大的球可以吞噬体积更小的球并继承对方的体积而玩家需要在地图边界和 AI 追击之间找到平衡尽可能活到最后成为那个“最强球球”。如果从开发者角度来定义这款游戏就是一个“基于 Canvas 的实时渲染应用 简单状态同步系统”。这里不需要联网所以所有 AI 球都运行在本地循环里。所谓“鏖战”的紧张感来自 AI 对玩家行为的实时反馈。比如当玩家体积足够大时AI 会主动绕开当玩家体积较小时AI 中的追击型角色又会快速靠近试图吃掉玩家。正因为有这种反馈机制游戏才会呈现出一种“被针对”的压迫感。1.2 它解决了什么问题很多游戏教程里的示例都是静态页面或者一次性交互读者写完以后并不知道游戏循环、帧刷新、碰撞检测这些东西在真实项目中是怎么配合的。而球球对战玩法麻雀虽小五脏俱全它有持续渲染、实时输入、AI 决策、体积变化、镜头跟随、碰撞结算几乎覆盖了游戏开发入门需要面对的所有核心问题。从这个角度来说做这个项目不是为了复刻哪一款商业游戏而是借助“Ariel篇”这个小切口把游戏主循环从零到一实现出来。读者学完以后可以继续扩展多人联机、道具系统、排行系统等更复杂功能也可以把其中 AI 行为控制、镜头缩放等逻辑迁移到其他 2D 游戏项目中。1.3 常见应用场景与扩展方向这类玩法本身在实时对战游戏里很常见比如大球吃小球类玩法、IO 类游戏、以及一些派对竞技小游戏。抛开商业产品不说单从学习角度它的应用场景也很清晰学习中台项目用一个小型可玩 Demo 来展示 Canvas、面向对象设计、游戏循环等知识点。面试作品补充把 AI 追击、碰撞优化、镜头平滑跟随这些点写清楚比堆砌多个半成品更有说服力。内部技术分享基于这套代码讲解游戏循环的 requestAnimationFrame 用法、坐标变换以及碰撞检测优化。接下来我们先把环境准备好逐步实现能运行的最小版本再重点拆解 Ariel 这个 AI 角色的行为决策。2. 环境准备与版本说明2.1 最低运行环境由于本项目的核心是 HTML5 Canvas 与原生 JavaScript因此不需要安装额外框架也不需要 Node.js 环境。只要你的电脑上有一个现代浏览器即可例如 Chrome、Edge、Firefox 或 Safari。示例代码没有使用 import、export 等模块化语法直接双击 HTML 文件也能打开运行这样能降低初学者的上手门槛。如果你希望有更好的开发体验可以考虑安装一个轻量级静态服务器。因为浏览器在file://协议下运行单个 HTML 文件通常没问题但一旦后续代码改成了 ES Module 或需要加载图片资源直接双击文件就可能遇到跨域问题。对于本文示例来说静态服务器不是必需的我这里只提供一个备选命令# 进入项目目录后用 Python 自带工具起一个简单的静态服务 python3 -m http.server 8080然后访问http://localhost:8080即可。如果你的电脑没有安装 Python也可以使用 VS Code 的 Live Server 插件或其他任意静态服务器工具。2.2 技术栈说明页面结构HTML5游戏渲染Canvas 2D API游戏逻辑原生 JavaScript样式控制CSS这里不引入 WebGL也不引入物理引擎。Canvas 2D API 对于 2D 球体渲染、圆弧绘制已经绰绰有余而且它有着很广的兼容性。所有代码都放在一个index.html文件中结构如下最强球球鏖战-Ariel篇/ ├── index.html # 页面结构与所有游戏逻辑 └── README.md # 可选项目说明考虑阅读方便我会把 HTML、CSS、JavaScript 按模块拆开讲解最后再给出一份完整可以复制的index.html。如果后续想做代码维护我建议把 JavaScript 部分拆成game.js、player.js、ai.js、food.js等独立文件但本文为了演示完整运行先以内联脚本为主。3. 核心机制拆解坐标系、游戏循环与体积变化3.1 坐标系与镜头概念游戏中所有球体都生活在一个比屏幕大得多的“世界坐标系”里。玩家看到的只是摄像机坐标系的一部分屏幕中心始终跟随玩家自己。为此我们不仅要保存每个球在世界中的坐标还要在绘制时计算它们相对屏幕的位置。简单来说屏幕坐标的计算公式是屏幕X 世界X - 摄像机X 画布宽度 / 2 屏幕Y 世界Y - 摄像机Y 画布高度 / 2这里摄像机坐标取玩家坐标即可。不过如果玩家体积增大视野要适当拉远否则屏幕会被玩家的大圆占满。于是需要引入缩放值 zoomzoom 初始缩放 / Math.max(1, 玩家半径 / 基础半径)这个公式的意图是玩家越大zoom 值越小世界在屏幕上显示得越小视野范围也就更大。绘制时所有半径都要乘以当前的 zoom才能保证视觉比例统一。3.2 游戏循环与帧率控制浏览器提供了requestAnimationFrame来驱动游戏循环它会自动配合屏幕刷新率。每次刷新时我们需要做这几件事处理玩家输入。更新所有球的位置。让 AI 根据环境做决策。检测食物碰撞和球与球之间的吞噬。更新摄像机位置。清屏并绘制所有元素。为了避免游戏速度受屏幕刷新率影响可以记录上一帧的时间间隔deltaTime然后让移动距离等于速度乘以秒数let lastTime 0; function gameLoop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.05); lastTime timestamp; update(dt); render(); requestAnimationFrame(gameLoop); }这里做了Math.min截断是为了防止浏览器切后台再切回来时出现非常大的时间差导致球瞬移穿透边界。3.3 体积增长模型球的“战斗力”直接与半径相关。食物被吃掉后球的体积会变而这里的体积应该是面积而非半径。半径增长公式可以设计成半径 基础半径 * Math.sqrt(当前体积 / 基础体积)这样每次吞掉一个食物不会让半径无限线性增加而是随着球变大半径增速逐渐放缓。游戏里可以使用“面积”作为球体存储的核心属性再根据面积计算绘制半径也可以直接使用半径作为体积单位但增长曲线要用开方控制。本文采用最简单的方案每个食物给球增加 4 个面积单位球的实际绘制半径通过面积反推。这样既不会让球的增长显得突兀又能让体积系统有一个可扩展的基础。关键代码如下后面完整实现里还会再次用到function getRadiusFromArea(area) { return Math.sqrt(area / Math.PI); }3.4 球的碰撞吞噬规则两个球是否碰撞不能简单看圆心距离是否小于两者半径之和。如果这样判断大球必须完全“碰到”小球才会吞噬游戏手感会很差。更常见的规则是当两个球的圆心距离小于大球半径减去小球半径乘以某个系数时大球直接吞掉小球。这里选择一种直观规则圆心距离小于“大球半径 - 小球半径 × 0.4”时触发吞噬。这个系数可以按手感调整数值越小吞噬判定越宽松。如果两个球半径接近比如玩家半径 80AI 半径 78那么触发吞噬的距离会比较小就会形成真正的“对峙缠斗”也就是标题里的“鏖战”。为什么要这样设计因为如果两个球大小接近任意一方都能轻松吃掉对方游戏就变成谁先碰到谁谁赢缺少策略性和紧张感。通过缩小判定距离双方需要不断拉扯、抢占位置才有那种“差一点点就成功”的刺激体验。4. AI 角色 Ariel 的行为逻辑设计4.1 AI 分层决策思路在多数 2D 对战游戏里AI 并不是每帧都做复杂思考而是根据当前状态选择一种行为模式。Ariel 的行为可以拆成三级全局状态正常游荡、追击、逃跑。决策输入自身面积、玩家面积、两者距离、地图边界。输出移动方向、移动速度。为了让 AI 看起来更真实决策频率不需要每帧都执行。可以每 0.2 秒重新评估一次当前状态期间只保持状态内的小幅方向扰动。这样 AI 不会像一个精确制导导弹一样死死锁住玩家而是会有一些“犹豫”和“试探”。4.2 Ariel 的性格配置激进追击型本项目里 Ariel 被设定成偏激进的角色。当 Ariel 的体积大于玩家体积的 1.1 倍时它会主动向玩家移动试图吞噬玩家当自己体积明显小于玩家时它会向玩家反方向逃跑。如果二者体积接近Ariel 会在一定距离外徘徊不贸然冲进危险区域。为了让这种算法更稳定我给 AI 定义了决策间隔。在未到决策点时Ariel 继续沿用上次方向同时叠加一个小的随机偏转模拟自然晃动。下面这段代码展示了决策核心function updateAI(ai, player, dt, worldWidth, worldHeight) { ai.decisionTimer - dt; if (ai.decisionTimer 0) { ai.decisionTimer 0.2; const dx player.x - ai.x; const dy player.y - ai.y; const dist Math.hypot(dx, dy); const aiRadius getRadiusFromArea(ai.area); const playerRadius getRadiusFromArea(player.area); let decision 0; // 0 游荡, 1 追击, 2 逃跑 if (aiRadius playerRadius * 1.1 dist 600) { decision 1; } else if (aiRadius playerRadius * 0.9 dist 500) { decision 2; } else { decision 0; } if (decision 1) { ai.vx (dx / dist); ai.vy (dy / dist); ai.speed 180; } else if (decision 2) { ai.vx (-dx / dist); ai.vy (-dy / dist); ai.speed 200; } else { const randomAngle Math.random() * Math.PI * 2; ai.vx Math.cos(randomAngle); ai.vy Math.sin(randomAngle); ai.speed 60; } } ai.x ai.vx * dt * ai.speed; ai.y ai.vy * dt * ai.speed; keepInBounds(ai, worldWidth, worldHeight); }4.3 为什么不把追击写成一追到底如果 Ariel 永远朝玩家当前位置直线移动游戏会非常容易预测。玩家只要垂直方向稍微移动就能绕开它同时 AI 在接近边界时还可能卡住。为了增加真实感Ariel 的移动需要分两层目标方向由状态决策给出。实际方向在目标方向上叠加面向玩家的偏置和随机扰动。我建议在 AI 位置记录一个aimAngle表示当前想要前进的方向。每次决策时用简单插值让角度平滑转向目标角。这样球看起来会“转弯”而不是瞬间掉头。配合适当的运动噪声AI 也会有类似真实玩家的微操感。Ariel 的核心难度并不来自固定数值而来自“它总是能根据玩家状态改变目标”。玩家变大它逃跑玩家变小它追击玩家和它差不多大时它会和你保持距离。这种压迫感才是鏖战的来源。5. 完整实战从零实现“最强球球鏖战”可运行版本5.1 设计参数为了控制代码复杂度我先约定游戏参数配置项值说明画布宽高800 × 600演示用分辨率可自适应世界大小2000 × 1500球的活动范围玩家初始面积800半径约 15.9Ariel 初始面积900初始速度略快于玩家食物数量240刷新在世界上玩家速度公式3000 / Math.sqrt(玩家面积 / Math.PI)半径越大移动越慢地图大小可根据屏幕响应式调整但世界大小保持不变这样能保证游戏空间感。5.2 文件结构直接复制以下内容保存为index.html双击即可运行。代码分四大块CSS 样式、DOM 结构、全局变量与初始化、更新与绘制逻辑。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title最强球球鏖战 - Ariel篇 (8-18)/title style * { margin: 0; padding: 0; box-sizing: border-box; } body { background: #0b1a2e; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; font-family: Microsoft YaHei, sans-serif; } h1 { color: #e8f0ff; font-size: 24px; margin: 12px 0 6px; letter-spacing: 2px; } p.hint { color: #8fa3c0; font-size: 14px; margin-bottom: 12px; } canvas { background: #123; border-radius: 12px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); cursor: none; } #info { color: #aac8ff; margin-top: 10px; font-size: 13px; } /style /head body h1最强球球鏖战 · Ariel篇/h1 p classhint方向键 / WASD 移动吃掉食物变大击败AI/p canvas idgameCanvas width800 height600/canvas div idinfo玩家体积800 Ariel体积900/div script // 全局变量 const canvas document.getElementById(gameCanvas); const ctx canvas.getContext(2d); const infoDiv document.getElementById(info); const WORLD_WIDTH 2000; const WORLD_HEIGHT 1500; const FOOD_COUNT 240; const keys {}; // 玩家、Ariel、其他AI、食物集合 let player null; let ariel null; let enemyList []; let foods []; // 摄像机 let camera { x: 0, y: 0, zoom: 1 }; let lastTime 0; // 工具函数 function getRadiusFromArea(area) { return Math.sqrt(area / Math.PI); } function getRandom(min, max) { return Math.random() * (max - min) min; } // 创建实体 function createPlayer() { return { x: WORLD_WIDTH / 2, y: WORLD_HEIGHT / 2, area: 800, vx: 0, vy: 0, speed: 180, name: 玩家, color: #4ade80, isPlayer: true, alive: true }; } function createAriel() { return { x: WORLD_WIDTH / 2 300, y: WORLD_HEIGHT / 2 200, area: 900, vx: 0, vy: 0, speed: 200, name: Ariel, color: #f87171, isPlayer: false, alive: true, decisionTimer: 0, randomAngle: 0 }; } function createEnemy(name, x, y, area, color) { return { x, y, area, vx: 0, vy: 0, speed: 190, name, color, isPlayer: false, alive: true, decisionTimer: 0 }; } function createFood(x, y) { return { x, y, radius: 5, area: 4, alive: true }; } // 初始化 function initWorld() { player createPlayer(); ariel createAriel(); enemyList [ ariel, createEnemy(Bella, 400, 300, 700, #facc15), createEnemy(Cora, WORLD_WIDTH - 400, WORLD_HEIGHT - 300, 1100, #c084fc) ]; foods []; for (let i 0; i FOOD_COUNT; i) { foods.push(createFood( getRandom(20, WORLD_WIDTH - 20), getRandom(20, WORLD_HEIGHT - 20) )); } updateInfo(); } // 输入处理 window.addEventListener(keydown, (e) { keys[e.code] true; if ([ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Space].includes(e.code)) { e.preventDefault(); } }); window.addEventListener(keyup, (e) { keys[e.code] false; }); // 边界约束 function keepInBounds(entity, minX, minY, maxX, maxY) { if (entity.x minX) entity.x minX; if (entity.x maxX) entity.x maxX; if (entity.y minY) entity.y minY; if (entity.y maxY) entity.y maxY; } // 更新逻辑 function updatePlayer(dt) { let dx 0; let dy 0; if (keys[ArrowLeft] || keys[KeyA]) dx - 1; if (keys[ArrowRight] || keys[KeyD]) dx 1; if (keys[ArrowUp] || keys[KeyW]) dy - 1; if (keys[ArrowDown] || keys[KeyS]) dy 1; if (dx ! 0 || dy ! 0) { const len Math.hypot(dx, dy); player.vx dx / len; player.vy dy / len; } else { player.vx 0; player.vy 0; } const playerRadius getRadiusFromArea(player.area); // 速度随体积增大而减小 const speed Math.min(260, 180 * (30 / (playerRadius 20))); player.x player.vx * speed * dt; player.y player.vy * speed * dt; keepInBounds(player, playerRadius, playerRadius, WORLD_WIDTH - playerRadius, WORLD_HEIGHT - playerRadius); } function updateArielAI(ai, dt) { const aiRadius getRadiusFromArea(ai.area); const playerRadius getRadiusFromArea(player.area); const dx player.x - ai.x; const dy player.y - ai.y; const dist Math.hypot(dx, dy) || 1; ai.decisionTimer - dt; if (ai.decisionTimer 0) { ai.decisionTimer 0.15 Math.random() * 0.1; let targetVx 0; let targetVy 0; let speed 80; if (aiRadius playerRadius * 1.05 dist 700) { // 大球追击 targetVx dx / dist; targetVy dy / dist; speed 190; } else if (aiRadius playerRadius * 0.9) { // 小球逃跑 targetVx -dx / dist; targetVy -dy / dist; speed 210; } else if (dist 300) { // 体型相近时谨慎环绕 targetVx -dy / dist; targetVy dx / dist; speed 130; } else { // 游荡 const randomAngle Math.random() * Math.PI * 2; targetVx Math.cos(randomAngle); targetVy Math.sin(randomAngle); speed 70; } ai.vx targetVx; ai.vy targetVy; ai.speed speed; } ai.x ai.vx * ai.speed * dt; ai.y ai.vy * ai.speed * dt; keepInBounds(ai, aiRadius, aiRadius, WORLD_WIDTH - aiRadius, WORLD_HEIGHT - aiRadius); } function updateFood() { const playerRadius getRadiusFromArea(player.area); const arielRadius getRadiusFromArea(ariel.area); for (let f of foods) { if (!f.alive) continue; const ALL_ENTITIES [player, ariel, ...enemyList]; for (let entity of ALL_ENTITIES) { if (!entity.alive) continue; const r getRadiusFromArea(entity.area); const dx f.x - entity.x; const dy f.y - entity.y; const dist Math.hypot(dx, dy); // 如果圆心距离小于半径视为吃到食物 if (dist r) { entity.area f.area; f.alive false; respawnFood(f); break; } } } } function respawnFood(food) { food.x getRandom(20, WORLD_WIDTH - 20); food.y getRandom(20, WORLD_HEIGHT - 20); food.alive true; } function checkEatEachOther() { // 先处理AI之间的吞噬 const allBalls [player, ...enemyList]; for (let i 0; i allBalls.length; i) { const a allBalls[i]; if (!a.alive) continue; for (let j i 1; j allBalls.length; j) { const b allBalls[j]; if (!b.alive) continue; if (a b) continue; const ra getRadiusFromArea(a.area); const rb getRadiusFromArea(b.area); const dx b.x - a.x; const dy b.y - a.y; const dist Math.hypot(dx, dy); if (ra rb * 1.05) { const eatDist ra - rb * 0.4; if (dist eatDist) { a.area b.area * 0.6; b.alive false; respawnBall(b); } } else if (rb ra * 1.05) { const eatDist rb - ra * 0.4; if (dist eatDist) { b.area a.area * 0.6; a.alive false; respawnBall(a); } } } } } function respawnBall(ball) { // 玩家死亡后重生在世界中央AI死亡后随机到角落等待重生 ball.area 800; ball.alive true; ball.x getRandom(200, WORLD_WIDTH - 200); ball.y getRandom(200, WORLD_HEIGHT - 200); ball.speed 180; if (ball.isPlayer) { ball.x WORLD_WIDTH / 2; ball.y WORLD_HEIGHT / 2; } } // 摄像机与渲染 function updateCamera() { camera.x player.x; camera.y player.y; const playerRadius getRadiusFromArea(player.area); camera.zoom Math.max(0.35, Math.min(1.2, 600 / (playerRadius 40))); } function worldToScreenX(wx) { return (wx - camera.x) * camera.zoom canvas.width / 2; } function worldToScreenY(wy) { return (wy - camera.y) * camera.zoom canvas.height / 2; } function drawWorldBoundary() { ctx.save(); ctx.strokeStyle #5289b5; ctx.lineWidth 4; ctx.strokeRect( worldToScreenX(0), worldToScreenY(0), WORLD_WIDTH * camera.zoom, WORLD_HEIGHT * camera.zoom ); ctx.restore(); } function drawFood(food) { const sx worldToScreenX(food.x); const sy worldToScreenY(food.y); if (sx -20 || sx canvas.width 20 || sy -20 || sy canvas.height 20) return; ctx.save(); ctx.beginPath(); ctx.arc(sx, sy, food.radius * camera.zoom, 0, Math.PI * 2); ctx.fillStyle #a5d8ff; ctx.fill(); ctx.restore(); } function drawBall(ball) { const sx worldToScreenX(ball.x); const sy worldToScreenY(ball.y); const r Math.max(1, getRadiusFromArea(ball.area) * camera.zoom); if (!ball.alive) return; ctx.save(); // 阴影 ctx.shadowBlur 14; ctx.shadowColor ball.color; // 填充 ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2); ctx.fillStyle ball.color; ctx.fill(); ctx.shadowBlur 0; ctx.lineWidth 2; ctx.strokeStyle rgba(255,255,255,0.35); ctx.stroke(); // 名字 ctx.fillStyle #ffffff; ctx.font ${Math.max(12, r * 0.25)}px Microsoft YaHei, sans-serif; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(ball.name, sx, sy - r - 10); ctx.restore(); } function drawInfo() { const playerRadius getRadiusFromArea(player.area); const arielRadius getRadiusFromArea(ariel.area); if (player.alive) { infoDiv.textContent 玩家体积${Math.floor(player.area)} Ariel体积${Math.floor(ariel.area)} 玩家半径${Math.floor(playerRadius)}; } else { infoDiv.textContent 你被击败了正在重生...; } } // 主循环 function update(dt) { // 玩家死亡后等待复活 if (player.alive) { updatePlayer(dt); } else { player.respawnTimer (player.respawnTimer || 0) - dt; if (player.respawnTimer 0) { respawnBall(player); player.respawnTimer 0; } } // 玩家默认视为存活如果被吃则进入死亡逻辑 if (player.alive) { updateArielAI(ariel, dt); for (let i 1; i enemyList.length; i) { updateArielAI(enemyList[i], dt); } updateFood(); checkEatEachOther(); } updateCamera(); drawInfo(); } function render() { ctx.clearRect(0, 0, canvas.width, canvas.height); // 背景网格线帮助玩家感知移动 ctx.strokeStyle rgba(255,255,255,0.06); ctx.lineWidth 1; const gridSize 80; const startX Math.floor((camera.x - canvas.width / 2 / camera.zoom) / gridSize) * gridSize; const startY Math.floor((camera.y - canvas.height / 2 / camera.zoom) / gridSize) * gridSize; const endX camera.x canvas.width / 2 / camera.zoom; const endY camera.y canvas.height / 2 / camera.zoom; ctx.beginPath(); for (let gx startX; gx endX; gx gridSize) { const sx worldToScreenX(gx); ctx.moveTo(sx, 0); ctx.lineTo(sx, canvas.height); } for (let gy startY; gy endY; gy gridSize) { const sy worldToScreenY(gy); ctx.moveTo(0, sy); ctx.lineTo(canvas.width, sy); } ctx.stroke(); drawWorldBoundary(); for (let f of foods) { if (f.alive) drawFood(f); } const allBalls [player, ...enemyList].sort((a, b) a.area - b.area); for (let ball of allBalls) { if (ball.alive) drawBall(ball); } } function gameLoop(timestamp) { if (!lastTime) lastTime timestamp; const dt Math.min((timestamp - lastTime) / 1000, 0.05); lastTime timestamp; update(dt); render(); requestAnimationFrame(gameLoop); } // 启动 initWorld(); window.addEventListener(resize, () { canvas.width Math.min(800, window.innerWidth - 20); canvas.height Math.min(600, window.innerHeight - 80); }); requestAnimationFrame(gameLoop); /script /body /html5.3 代码结构解读这份代码虽然写在单个文件里但内部已经按职责分成了多个函数。初始化函数initWorld负责生成玩家、Ariel、其他 AI 和食物。玩家移动函数updatePlayer读取键盘方向使用矢量归一化方式计算移动方向并控制朝八方向移动时速度保持一致避免斜向移动比横向移动更快的问题。Ariel 的行为被封装在updateArielAI中除了 Ariel 之外其他 AI 也复用了同样的 AI 行为函数。这里有一个细节代码里的enemyList既包含 Ariel 又包含另外两个 AI调用updateArielAI时会遍历整个列表因此我们单独用updateArielAI(ariel, dt)更新一次后又把列表从索引 1 开始循环更新了一次没有重复更新 Ariel。这里需要注意如果后续增加 AI 数量建议把所有 AI 统一放进一个列表里循环而不是手动分两次。渲染时所有元素都通过worldToScreenX和worldToScreenY转换成屏幕坐标后再绘制。绘制顺序是先食物、再体积极小的球、最后绘制体积极大的球这样大球能压在小球上方视觉上更合理。这段代码预设的情况是玩家死亡会自动重生Ariel 如果被吃掉也会在随机位置重生成 800 面积的小球。不过为了避免玩家和 AI 互相刷体积代码在吞噬结算时只继承对方0.6倍的面积而不是全部。这样即使某一方连续击败多只球也不会立刻达到碾压全场的程度。5.4 运行与验证将上述代码完整粘贴到index.html文件双击用浏览器打开应该看到深蓝色背景的游戏画布中央是绿色玩家小球。键盘方向键控制移动场地中会随机出现蓝白色小食物碰到就会增长。Ariel 是红色球名字显示为 Ariel。当玩家面积小于它时它会主动靠过来一旦玩家面积超过它它就会逃跑或者绕圈。如果页面打开后没有任何反应优先检查浏览器控制台是否存在 JavaScript 报错。最常见的问题是把Math.hypot当成Math.hypot写错、或者少复制了某个函数。完整代码末尾有initWorld()和requestAnimationFrame(gameLoop)的调用这两句不能遗漏。6. 常见问题与排查思路问题现象常见原因解决思路打开页面后画布全黑未执行initWorld()检查脚本末尾是否调用初始化函数按方向键没有反应输入监听未绑定或焦点不在页面上点击页面区域后再按键查看控制台是否有输入报错玩家移动速度过快/过慢未使用dt做帧时间补偿检查是否用固定步长法或requestAnimationFrame的timestamp玩家不断被 Ariel 追上玩家速度公式和体积之间不平衡调低 AI 追击速度或让玩家速度计算公式中的常数变大游戏运行时页面掉帧食物与球执行双重循环量级过大且无剔除增加食物剔除判断远离屏幕的食物不绘制大球无法吞噬小球吞噬判定半径系数过严检查eatDist计算放宽为ra - rb * 0.4到ra - rb * 0.6之间Canvas 适配手机端困难固定宽高 800×600用resize监听动态修改画布宽高并重新适配缩放6.1 为什么会出现“卡一下然后瞬移”这是一个非常典型的 Canvas 游戏问题。如果浏览器标签页切到后台requestAnimationFrame会暂停计时返回时timestamp可能跳出一大截如果不做Math.min(dt, 0.05)截断球会在一帧内移动很长的距离直接越过边界或撞进其他球里。虽然代码里已经加了这个保护但如果你还要做更严格的逻辑应该使用固定时间步长let accumulator 0; const step 1 / 60; function loop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.1); lastTime timestamp; accumulator dt; while (accumulator step) { update(step); accumulator - step; } render(); requestAnimationFrame(loop); }固定时间步长可以让逻辑计算以固定频率更新渲染仍然按屏幕刷新率执行这是多数游戏推荐的做法。6.2 为什么食物有时候没被吃掉就消失了如果同一帧里多个球同时检测食物可能会发生两个球都判断自己吃到了同一个食物然后食物被重置到新位置导致没有被真正吃掉。单机示例里这通常不影响体验但如果后续做多人联机食物由服务器统一分配或者采用“食物被吃后进入一小段不可见冷却时间”的方案更好。6.3 如何让 Ariel 更有挑战性Ariel 当前的追击逻辑比较直白大就追小就跑。你可以在游戏里增加一个“嗜血值”当 Ariel 连续吃掉食物后追击速度提高或者让 Ariel 在玩家体积大于自己时不会单纯逃跑而是先去附近搜刮食物积累到一定体积后再回头找玩家。这就变成了一个“目标优先级”问题。在 AI 决策里可以增加第三种行为模式寻找食物。用nearestFood找出附近的食物坐标当 Ariel 的状态是蓄力时它的目标从玩家切换为食物点速度略低于追击速度。这会让 AI 显得更有策略性。7. 最佳实践与工程建议7.1 将渲染与逻辑分开不要把坐标更新、输入处理和 Canvas 绘制全部写在一个巨型函数里。哪怕项目很小也应该至少拆成update和render两套流程。这样做的好处是以后如果要加入自动化测试可以只测逻辑函数不必依赖 Canvas 环境。如果后续要将单机逻辑迁移到服务端也只需要把render剥离掉保留update部分。7.2 初期就采用面积而非半径作为核心属性很多新手会直接使用radius代表球的大小但吞噬之后半径应该怎么增长如果按radius 小球的radius大球会瞬间变得巨大游戏失去平衡。使用面积作为核心属性后球的成长路径更平滑后续如果想要加入食物链、道具效果也只需操作面积。绘制时再通过Math.sqrt(area / Math.PI)转换半径输出上没有额外成本。7.3 注意区域查询与碰撞优化本文中的碰撞是简单双重循环在只有几个球和有限数量食物时没有问题但如果扩展到几十上百个球简单的O(n^2)碰撞检测就会成为性能瓶颈。建议把世界划分成格子每个格子记录该格子内的球和食物。碰撞检测时只检测同一格子和相邻格子的对象而不是全量遍历。食物数量达到几千个时也应该使用同样的空间划分。绘制时如果食物在世界坐标中距离镜头过远可以直接跳过绘制这是成本最低的裁剪手段。7.4 参数集中管理游戏里的速度系数、AI 追击距离、AI 决策间隔、吞噬继承比例等建议统一抽到一个配置对象CONFIG中方便调整手感。例如const CONFIG { playerBaseSpeed: 180, playerMaxSpeed: 260, aiAggressiveDistance: 700, aiDecisionInterval: 0.2, eatInheritRate: 0.6, foodArea: 4, foodCount: 240 };这样后续做难度梯度时可以按不同关卡覆盖配置不用在逻辑里到处找魔法数字。7.5 增加视觉反馈Ariel 在决定追击或逃跑时可以通过颜色、光圈或名字旁的状态图标让玩家感知到它的意图。比如追击时名字变红逃跑时名字变淡。这种视觉反馈对于提升游戏“紧张感”很关键因为玩家其实无法直接看到 AI 的内部状态他们只能通过视觉判断。本文 Demo 里已经通过阴影和名字让 Ariel 比较醒目实际迭代时可以增加一行状态文字提示。7.6 日志与调试输出开发期建议在界面上显示玩家的坐标、当前速度、Ariel 的 AI 决策状态、当前帧率等调试信息// 在 drawInfo 中临时输出 AI 状态 if (ariel.decisionState) { console.log(Ariel state: ${ariel.decisionState}, timer: ${ariel.decisionTimer}); }生产版本再把调试信息隐藏。通过日志你能快速理解 AI 为什么突然追击或逃跑而不是靠“感觉”去调参。8. 总结与下一步方向这篇“最强球球鏖战!「Ariel篇」(8-18)”完成了三件关键事情第一把一个球球吞噬玩法从零拆成世界坐标、镜头缩放、体积增长、吞噬判定若干模块第二通过一个可复制的 HTML 文件让读者直接在浏览器里运行体验第三重点实现了 Ariel 这个 AI 角色的分层决策逻辑让它能根据与玩家的体型差异选择追击、逃跑、环绕或游荡从而形成有对抗感的“鏖战”。如果你是从零开始学习 Canvas 游戏开发建议先改两类参数一类是玩家速度公式一类是 AI 的决策距离和速度。这两个参数直接影响“手感”和“难度”。改完以后运行你会立刻感觉到游戏节奏变化。接下来可以尝试三件事把食物对象池化减少 JOSN 垃圾回收压力把电脑 AI 从 1 个增加到 10 个并优化碰撞检测再进一步利用前面提到的代码结构把玩家对局数据用图表方式输出出来完成一场“球球战报”复盘。Ariel篇只是这套球球系列的一部分。下一篇可以围绕多人同屏、团队协作或排行榜展开思路是先把当前单机 Demo 的每个模块跑通再逐步加入网络同步和状态管理这样每一步都有可验证的成果。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进