
提起“最强球球鏖战”这六个字大多数玩家的第一反应是“一群圆球在场上互相吞噬比谁滚得大”。但如果你和我一样需要自己动手复刻一个可运行的玩法制样你会发现“鏖战”的观感背后藏着两套完全不同的工程问题一套是球体运动、碰撞分离、视野缩放这类确定性物理逻辑另一套是AI如何判断危险、追踪目标、保存自己的决策逻辑。真正决定一个Demo好不好玩的通常不是美术画了多少个球而是这两套逻辑是否在同一个帧循环里跑得足够稳。这篇文章以“Ariel篇”的8-18版本为主线梳理一个可复现的球球对战原型实现过程。Ariel在这里是可控角色的代号8-18是本次迭代的版本序列。文章会先拆解核心玩法结构再进入完整代码最后给出运行验证、常见问题与工程建议。如果你正在做2D多人对战游戏原型、游戏AI小Demo或者想理解“许多圆形物体同时运动和碰撞”时应该怎么设计这篇文章可以作为一份可以照着改的起点。1. Ariel篇到底要解决什么问题很多刚接触小型游戏项目的人会有一种错觉场景里只要有几个圆形精灵让它们根据鼠标方向移动就能算是“球球对战”。其实从工程角度看这只是第一步远没有构成“战斗”。真正的球球对战至少要解决三个层次的问题第一运动层。角色怎么移动加速度和摩擦力如何作用玩家输入如何映射到物理上如果角色只有一个速度而没有加速度过程手感会非常“生硬”。第二碰撞层。两个球体碰到一起什么时候应该互相推开什么时候应该发生“吞噬”如果一个球比另一个球大出一定比例该不该允许直接吃掉碰撞后半径和质量如何变化这些判断一旦写错就会出现“明明没碰到却被吃掉”“大球穿过小球”的视觉事故。第三决策层。NPC什么时候追击、什么时候逃跑它根据什么信息判断对手强还是弱“鏖战”好不好看很大程度上依赖于AI是否表现得有策略性而不是像随机布朗运动一样无意义乱滚。“Ariel篇”作为单个角色的战斗篇章正好可以用最小规模把三层问题全部走通一个玩家角色、若干AI角色、地图边界、刷新食物。它不追求商业游戏的数值深度而是追求一个经验丰富的人能在30分钟内理解原型、跑起来、改参数、看效果。如果这三层都能稳定工作后面再加技能系统、多人网络同步、局外成长只是在同一架构上做增量。与其等到地图做大了再补物理不如先让一个球的玩法闭环。所谓“最强”在Demo阶段不是指数值爆炸而是指每一帧的判定都符合直觉。2. 球球对战的基础概念与核心机制在动手写代码前有几组概念必须掰扯清楚。如果这个阶段模糊调试会浪费大量时间。2.1 球体实体不只是“画一个圆”从数据结构看一个可运动、可碰撞、可吞噬的球至少要维护以下状态位置x、y速度vx、vy半径r质量或“体型值”用于计算碰撞后的效果归属标识是玩家、AI还是食物有人会问为什么不直接让位置改变而是引入速度因为真实感来自运动惯性。如果每帧直接让球跳到鼠标位置角色就变成了“瞬移”没有追击和拉开距离的空间。引入速度后玩家输入控制的是加速度程序根据加速度改变速度再根据速度改变位置手感才会顺滑。2.2 大球吃小球的数学处理“吞噬”不能简单地判断两个圆心距离小于半径之和就触发否则两个同等大小的球相遇会被错误地吃掉。更安全的做法是这样计算两个球体的实际距离。如果距离小于两者半径之和说明发生了接触。如果其中一方半径大于另一方半径乘以某个阈值比如大于1.2倍才允许被吞噬。吞噬后幸存方的新半径根据面积守恒原则计算newR Math.sqrt(r1 * r1 r2 * r2);这是球体对战中比较常见的处理方式比直接做加法更接近“面积守恒”的直觉。因为圆的面积和半径平方成正比两个圆合并后的面积等于二者面积之和所以反推半径时要用平方根。如果阈值判断不满足比如双方差距不够大就不要触发吞噬而是执行“推开分离”。这也是很多新手容易踩的坑把所有接触都当成吞噬导致大球被小球“粘住”或者同级别球互相重叠抖动。2.3 碰撞后的推开与分离两个球不能吞噬时起码要满足“互不穿透”。最简单的方法如下计算碰撞法线方向即从A球心指向B球心的单位向量。计算重叠深度等于半径之和减去圆心距离。把A和B分别沿法线反方向推开推开距离按各自质量比例分配。质量大的球被推得少质量小的球被推得多这会带来很真实的“体积感”。如果两队质量相近则各推一半。2.4 视野与战斗规模的关系“最强球球鏖战”这个名字很容易让人想到超大地图和几十个单位同屏。但在单页原型中我建议先固定画布尺寸不急着做镜头缩放。原因很实际一旦引入镜头跟随和缩放坐标系就要区分“世界坐标”和“屏幕坐标”调试成本会马上翻倍。8-18版本的核心建议是先把固定画布内的战斗逻辑跑稳。地图边界、食物刷新、AI行为、吞噬判定都只依赖世界坐标渲染时再做一次坐标映射。这样逻辑层和表现层边界清晰后续要加镜头也只是增加一次“渲染偏移计算”不会污染AI和碰撞代码。3. 玩法规则与AI决策设计一个对战的Demo不能没有目标。Ariel篇的玩法目标定位为15秒内在战场中存活并尽量成长。场上会有三类对象玩家控制的一个小球若干AI小球随机出现的食物点食物点的作用是为玩家提供最基础的成长来源。AI也会主动吃食物这让整个战场不会变成“玩家单方面屠杀AI”的静态模式。3.1 AI的“认知半径”AI不可能看到全图所有信息它应该有一个感知半径。设计成AI每帧从所有对象中找出自己感知范围内的对象。在感知范围内找出比自己弱且离自己最近的食物或小球。如果感知范围内出现比自己强大的球则判断是否需要逃离。这种“局部感知”设计很重要。它一方面避免AI像开了透视一样直接冲向玩家另一方面也把每帧搜索范围限制住为后续大地图预留性能空间。3.2 AI的状态切换AI的行为可以通过一个极简状态机表达如果敌人太强逃跑如果前方有可吃目标追击如果无目标漫游状态切换用“半径对比”完成而非复杂神经网络。原因是在2D离散球体战场里半径基本决定了胜负关系超过一定比例的大球直接吞噬是符合直觉的规则。写一个智能状态机比训练一个模型更容易解释、更容易调参。if (dangerR myR * 1.10) { // 逃 } else if (food.x) { // 吃 } else { // 随机走 }这个阈值可以配置。实际试玩时会发现阈值太高会让AI显得“头铁”明明打不过还要硬碰阈值太低会让AI过于胆小双方永远打不起来。这是整个项目中最值得花时间调优的地方。4. 代码结构与关键技术实现为了让读者能够完整跑通这里直接用一个单文件HTML实现所有逻辑。无需安装Node无需构建工具用浏览器打开即可运行。4.1 核心代码文件完整可运行的HTML以下代码是一个完整示例覆盖了运动、碰撞、AI、吞噬、食物刷新和基础UI。!DOCTYPE html html langzh-CN head meta charsetUTF-8 / title最强球球鏖战 - Ariel篇 (8-18)/title style html, body { margin: 0; height: 100%; background: #1a1a2e; overflow: hidden; font-family: Microsoft YaHei, sans-serif; } #topbar { position: fixed; top: 12px; left: 16px; color: #fff; font-size: 14px; z-index: 10; background: rgba(0,0,0,0.4); padding: 8px 14px; border-radius: 8px; } canvas { display: block; } /style /head body div idtopbar 分数span idscore0/span半径span idradius18/spanFPSspan idfps0/span br /鼠标移动控制Ariel空格键刷新食物R键重新开始 /div canvas idarena/canvas script (function () { // ---------- 基础配置 ---------- const canvas document.getElementById(arena); const ctx canvas.getContext(2d); const scoreEl document.getElementById(score); const radiusEl document.getElementById(radius); const fpsEl document.getElementById(fps); canvas.width 960; canvas.height 640; // 核心配置8-18版本参数集中管理 const CONFIG { playerStartR: 18, aiCount: 6, foodCount: 30, friction: 0.98, maxSpeed: 5.5, eatFactor: 1.15, // 大于该比例即可吞噬 foodRespawnTime: 800 }; // ---------- 全局状态 ---------- let balls []; let foods []; let player null; let mouse { x: 600, y: 400 }; let animationId null; let lastTime 0; let frames 0; let fpsTimer 0; let foodTimer 0; // ---------- 工具函数 ---------- function randomRange(min, max) { return Math.random() * (max - min) min; } function distance(a, b) { const dx a.x - b.x; const dy a.y - b.y; return Math.sqrt(dx * dx dy * dy); } // ---------- 球体工厂 ---------- function createBall(x, y, r, type, name) { return { x: x, y: y, vx: 0, vy: 0, r: r, targetX: x, targetY: y, type: type, name: name || ball, isAlive: true, dangerCooldown: 0 }; } function resetGame() { balls []; foods []; player createBall(canvas.width / 2, canvas.height / 2, CONFIG.playerStartR, player, Ariel); balls.push(player); // 生成 AI for (let i 0; i CONFIG.aiCount; i) { const r randomRange(12, 32); const x randomRange(40, canvas.width - 40); const y randomRange(40, canvas.height - 40); const ai createBall(x, y, r, ai, AI- (i 1)); balls.push(ai); } // 生成食物 for (let i 0; i CONFIG.foodCount; i) { spawnFood(); } } function spawnFood() { if (foods.length 80) { return; } const f { x: randomRange(15, canvas.width - 15), y: randomRange(15, canvas.height - 15), r: randomRange(3, 6) }; foods.push(f); } function applyEat(winner, loser) { winner.r Math.sqrt(winner.r * winner.r loser.r * loser.r); loser.isAlive false; } // ---------- 输入控制 ---------- canvas.addEventListener(mousemove, function (e) { const rect canvas.getBoundingClientRect(); mouse.x e.clientX - rect.left; mouse.y e.clientY - rect.top; }); window.addEventListener(keydown, function (e) { if (e.code Space) { e.preventDefault(); for (let i 0; i 5; i) { spawnFood(); } } if (e.code KeyR) { location.reload(); } }); // ---------- 运动更新 ---------- function updatePlayer(dt) { if (!player || !player.isAlive) return; const dx mouse.x - player.x; const dy mouse.y - player.y; const len Math.sqrt(dx * dx dy * dy); const speed 350; if (len 1) { const ax (dx / len) * speed; const ay (dy / len) * speed; player.vx ax * dt; player.vy ay * dt; } // 限制最大速度 const sp Math.sqrt(player.vx * player.vx player.vy * player.vy); const maxSp CONFIG.maxSpeed player.r * 0.05; if (sp maxSp) { player.vx (player.vx / sp) * maxSp; player.vy (player.vy / sp) * maxSp; } // 摩擦 player.vx * Math.pow(CONFIG.friction, dt * 60); player.vy * Math.pow(CONFIG.friction, dt * 60); player.x player.vx; player.y player.vy; clampToArena(player); } function clampToArena(b) { const margin b.r; if (b.x margin) { b.x margin; b.vx 0; } if (b.x canvas.width - margin) { b.x canvas.width - margin; b.vx 0; } if (b.y margin) { b.y margin; b.vy 0; } if (b.y canvas.height - margin) { b.y canvas.height - margin; b.vy 0; } } // ---------- AI 更新 ---------- function updateAI(ball, dt) { if (!ball.isAlive || ball.type ! ai) return; // 先找感知范围内的威胁和食物 let nearestFood null; let nearestFoodDist Infinity; let nearestEnemy null; let nearestEnemyDist Infinity; let dangerBall null; // 感知半径 const sightR 160; for (let i 0; i foods.length; i) { const f foods[i]; const d distance(ball, f); if (d sightR d nearestFoodDist) { nearestFood f; nearestFoodDist d; } } for (let i 0; i balls.length; i) { const other balls[i]; if (other ball || !other.isAlive) continue; const otherR other.r; const d distance(ball, other); if (d sightR) { // 危险判断对手明显更大 if (otherR ball.r * CONFIG.eatFactor) { if (d nearestEnemyDist) { dangerBall other; nearestEnemy other; nearestEnemyDist d; } } else if (otherR ball.r / CONFIG.eatFactor) { // 猎物明显比自己小 if (d nearestEnemyDist) { nearestEnemy other; nearestEnemyDist d; } } } } let targetX ball.x; let targetY ball.y; if (dangerBall) { // 逃跑方向为远离危险球 const dx ball.x - dangerBall.x; const dy ball.y - dangerBall.y; const len Math.sqrt(dx * dx dy * dy) || 1; targetX ball.x (dx / len) * 200; targetY ball.y (dy / len) * 200; } else if (nearestEnemy) { // 追击弱小的球 targetX nearestEnemy.x; targetY nearestEnemy.y; } else if (nearestFood) { // 吃食物 targetX nearestFood.x; targetY nearestFood.y; } else { // 漫游设置一个较远的随机点 if (Math.random() 0.02) { targetX randomRange(30, canvas.width - 30); targetY randomRange(30, canvas.height - 30); } } const dx targetX - ball.x; const dy targetY - ball.y; const len Math.sqrt(dx * dx dy * dy); const speed 120; if (len 2) { const ax (dx / len) * speed; const ay (dy / len) * speed; ball.vx ax; ball.vy ay; } const sp Math.sqrt(ball.vx * ball.vx ball.vy * ball.vy); const maxSp CONFIG.maxSpeed * 0.75 ball.r * 0.02; if (sp maxSp) { ball.vx (ball.vx / sp) * maxSp; ball.vy (ball.vy / sp) * maxSp; } ball.x ball.vx * dt * 60 * 0.016; ball.y ball.vy * dt * 60 * 0.016; clampToArena(ball); } // ---------- 碰撞与吞噬处理 ---------- function handleCollisions() { const aliveBalls balls.filter(function (b) { return b.isAlive; }); for (let i 0; i aliveBalls.length; i) { for (let j i 1; j aliveBalls.length; j) { const a aliveBalls[i]; const b aliveBalls[j]; const dx b.x - a.x; const dy b.y - a.y; const dist Math.sqrt(dx * dx dy * dy); const minDist a.r b.r; if (dist minDist) continue; if (dist 0) continue; const nx dx / dist; const ny dy / dist; // 吞噬判断 if (a.r b.r * CONFIG.eatFactor) { applyEat(a, b); continue; } if (b.r a.r * CONFIG.eatFactor) { applyEat(b, a); continue; } // 普通碰撞推开 const overlap minDist - dist; const totalR a.r b.r; const ratioA b.r / totalR; const ratioB a.r / totalR; a.x - nx * overlap * ratioA; a.y - ny * overlap * ratioA; b.x nx * overlap * ratioB; b.y ny * overlap * ratioB; } } } function handleFoodEating() { if (!player || !player.isAlive) return; for (let fi foods.length - 1; fi 0; fi--) { const f foods[fi]; for (let bi 0; bi balls.length; bi) { const b balls[bi]; if (!b.isAlive) continue; const d distance(b, f); if (d b.r f.r) { b.r Math.sqrt(b.r * b.r f.r * f.r); foods.splice(fi, 1); break; } } } } // ---------- 食物自动补充 ---------- function updateFoodTimer(dt) { foodTimer dt * 1000; if (foodTimer CONFIG.foodRespawnTime) { if (foods.length CONFIG.foodCount) { spawnFood(); } foodTimer 0; } } // ---------- 渲染 ---------- function drawShadow(ball) { ctx.beginPath(); ctx.arc(ball.x 2, ball.y 2, ball.r 2, 0, Math.PI * 2); ctx.fillStyle rgba(0,0,0,0.2); ctx.fill(); } function drawFood(f) { ctx.beginPath(); ctx.arc(f.x, f.y, f.r, 0, Math.PI * 2); ctx.fillStyle #c9f73f; ctx.fill(); } function drawBall(ball) { if (!ball.isAlive) return; let color #ff6b6b; if (ball.type player) { color #4ecdc4; } else if (ball.type ai) { color #f9ca24; } drawShadow(ball); ctx.beginPath(); ctx.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); ctx.fillStyle color; ctx.fill(); ctx.strokeStyle rgba(255,255,255,0.6); ctx.lineWidth 2; ctx.stroke(); // 字体大小超过5就显示名字 if (ball.r 10) { ctx.fillStyle #fff; ctx.font bold 14px Arial; ctx.textAlign center; ctx.textBaseline middle; ctx.fillText(ball.name, ball.x, ball.y); } } function render() { ctx.fillStyle #0f0e17; ctx.fillRect(0, 0, canvas.width, canvas.height); // 网格线方便观察地图边界 ctx.strokeStyle #222; ctx.lineWidth 1; for (let x 0; x canvas.width; x 60) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); } for (let y 0; y canvas.height; y 60) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); } for (let i 0; i foods.length; i) { drawFood(foods[i]); } for (let i 0; i balls.length; i) { drawBall(balls[i]); } if (player) { scoreEl.innerText Math.floor(player.r * player.r * Math.PI); radiusEl.innerText Math.floor(player.r); } } // ---------- 主循环 ---------- function gameLoop(timestamp) { const dt Math.min((timestamp - lastTime) / 1000, 0.05); lastTime timestamp; updatePlayer(dt); for (let i 0; i balls.length; i) { if (balls[i].type ai) { updateAI(balls[i], dt); } } updateFoodTimer(dt); handleCollisions(); handleFoodEating(); // 移除已死亡对象 balls balls.filter(function (b) { return b.isAlive; }); if (!player || !player.isAlive) { // 玩家重生 player createBall(canvas.width / 2, canvas.height / 2, CONFIG.playerStartR, player, Ariel); balls.push(player); } // FPS 统计 frames; fpsTimer dt; if (fpsTimer 1) { fpsEl.innerText frames; frames 0; fpsTimer 0; } render(); animationId requestAnimationFrame(gameLoop); } // ---------- 启动 ---------- resetGame(); lastTime performance.now(); animationId requestAnimationFrame(gameLoop); })(); /script /body /html4.2 为什么这样组织代码上面这段代码有意把“状态初始化”“运动更新”“AI更新”“碰撞处理”“渲染”拆成了不同函数。这样拆分不是为了显得专业而是为了便于调试。如果你想单独试AI参数不需要碰渲染代码如果你发现碰撞抖动只需要排查handleCollisions中的分离逻辑如果你想增加一个新食物类型直接在spawnFood和drawFood里改即可。有一点容易忽视在updateAI中使用了一个简化速度更新方法。AI没有像玩家那样走“加速度-摩擦”的方式而是直接设置一个合理速度然后乘以固定时间因子。这么做是因为AI不需要精细的操作手感更重要的是响应速度直接让AI稳定朝目标运动能避免机器人出现原地画圈或抖动。4.3 时间步长为什么要做上限截断requestAnimationFrame在不同显示器刷新率下回调频率不同且标签页切后台再回来时timestamp会发生突变。如果没有Math.min(dt, 0.05)这一行阻拦物理计算会因为“一帧等于好几秒”而出现两个球直接穿模或者飞出地图的情况。把dt上限设为0.05秒是一个比较通用的工程防御手段。它不会解决所有问题但能保证最坏情况下不会因为浏览器切后台导致游戏崩溃。5. 运行方法与预期效果验证5.1 运行步骤将上述代码保存为ariel-8-18.html。使用Chrome或Edge直接双击打开该文件。也可以启动一个本地静态服务器python3 -m http.server 8080然后访问http://localhost:8080/ariel-8-18.html。页面加载后你会看到一个暗色战场中央附近出现一个青绿色小球右下角显示分数、半径和FPS。从浏览器直接打开和通过本地服务器打开在功能上差别不大。但如果你后续要加网络联机或模块化加载建议从一开始就用本地服务器开发。5.2 验证结果运行成功后预期表现如下鼠标移动时Ariel能平滑跟踪鼠标方向而不是瞬移。战场中会随机出现多个黄色AI球和绿色食物点。Arial 移动到小食物附近时食物消失本球半径增加。A球碰到比自己小很多的AI球时AI球消失Ariel半径明显增大。AI球之间也会相互吞噬或者躲避。当AI球比Ariel大时AI会主动追击并尝试吞噬Ariel如果Ariel被吃掉会在中心点重新生成。FPS接近60页面长时间运行无报错。如果这些行为都能出现说明8-18版本的核心闭环已经跑通。如果不满足请先打开浏览器控制台看有没有红色报错。没有报错时再看是不是因为碰撞阈值或感知半径设得过大导致AI行为不符合预期。6. 如何调整参数做出不同的“鏖战”感觉完整代码跑通之后你一定会想调整玩法。这个版本的参数全部集中在CONFIG对象中改起来很方便。这里给出几个常用的调参方向。eatFactor当前为1.15意思是球A半径大于球B半径的1.15倍时A可以吃掉B。改成1.05战斗会更激烈一个很小的体形差就能决定生死改成1.3则比赛更拖沓小体积球有更多逃生机会。friction当前为0.98影响玩家松手后滑行的距离。摩擦值越接近1球越滑越接近0球越“粘滞”。aiCountAI数量越多战斗越混乱但碰撞计算量也越大。当前6个AI在960×640画布上比较平衡。sightR这是AI的感知半径当前在updateAI中写死为160。感知半径越大AI越聪明越小AI越像傻子。但它也影响每帧查找的目标数量在实际项目中应该提取成配置。调参的核心方法是“一次只改一个值”。如果你同时修改了摩擦、AI数量和吞噬阈值就很难判断行为变化来自哪一个参数。7. 常见问题与排查思路很多第一次把这个Demo跑起来的人会遇到类似下面的现象。这里整理了一份排查表按优先级从高到低排列。问题现象可能原因排查方式解决方案小球被吞噬后画面闪动移除逻辑和碰撞顺序不严格打开控制台确认没有报错观察闪动是否发生在balls.filter之后先过滤死亡对象再进入渲染碰撞循环中也跳过已死对象玩家球被卡在地图边缘不停抖动摩擦值过小或边界速度清零后仍累积检查updatePlayer与clampToArena执行顺序在边界处同时把速度设为0并适当增大摩擦食物刷新越来越少食物生成逻辑只在上次空位时生成但消耗速度大于生成速度打印foods.length调整foodRespawnTime或取消最多80个的上限AI像无头苍蝇一样乱跑感知半径太小或漫游概率太高打印AI的目标坐标增大sightR并降低漫游概率FPS明显偏低单帧中嵌套循环过多打开浏览器性能面板看CPU占用减少AI数量或者把碰撞检测改为空间网格比如把地图分成多个格子只检查同一格子及相邻格子大球穿过小球dt超过物理阈值检查gameLoop中是否遗漏时间截断保留Math.min(dt, 0.05)限制第四行“AI像无头苍蝇”在高版本中经常被误解为“AI bug”但它未必是代码错误而是感知和决策参数叠加后的正常现象。感知半径小AI只看得见眼前的东西没有目标时它随机漫游一旦随机漫游点更新频率高看起来就像无头苍蝇。所以排查时要先判断是“逻辑跑飞”还是“参数语义不对”。8. 从单文件Demo到真实项目的工程建议当你把单文件Demo调成自己满意的玩法后下一步面临的问题是怎么继续往真实项目演进。下面的建议能帮你少走一些弯路。8.1 把参数从“魔法数字”全部抽成配置代码里的sightR160、foodCount30、maxSpeed5.5都应该放到一个配置文件或配置类里。不要让它们散落在各个函数中。8-18版本集中使用CONFIG对象就是为后续扩展做准备。理想的配置应该支持JSON序列化这样你可以保存多组配置比如“保守AI”“激进AI”“8-18正式版”。在调参时把配置文件和复盘记录放到同一个目录比靠记忆改代码码更可靠。8.2 分离逻辑层和表现层当前Demo把游戏逻辑与Canvas渲染放在同一个函数栈里。小球项目这个规模完全够用但如果要加服务端同步、断线重连、观战系统就必须把“逻辑更新”和“表现插值”分离。一个常见的引擎抽象是GameWorld负责更新位置、碰撞、吞噬不依赖渲染。Renderer负责把GameWorld中的数据画到屏幕上。InputController负责把鼠标键盘事件转换为游戏指令。当逻辑层不依赖渲染时你才能方便地写单元测试或者把同一套逻辑跑在服务器上做权威计算。8.3 使用空间网格优化碰撞在单一面板中几十个球的O(n²)碰撞判断还可以接受。但一旦球的数量超过一两百每帧执行的二重循环很可能成为性能瓶颈。更通用的方案是空间网格。把地图按固定大小切成格子每个碰撞体放进所属的格子每帧只检查同一格子和相邻格子中的对象。对球类游戏格子大小可以取所有球中最大直径的整数倍。这样可以显著减少无效的距离计算。8.4 为AI行为增加日志当AI明明有危险球却还在漫游时只看画面很难定位问题。建议把AI每帧的“状态”打印或录制下来例如“当前半径、危险球半径、最近食物、当前状态”。复盘战斗时录一段“决策轨迹”比截图效果好得多。8.5 引入测试后再调数值数值调优看起来不需要测试其实相反。如果你改了某个阈值导致AI总是逃到地图角落你希望从回放数据里发现而不是一次次手工试玩。最简单的方法是在每个AI对象上附加一个事件日志数组记录“何时发起追踪”“何时逃跑”“何时被吞噬”。之后的回归测试可以断言在半径差超过吞噬阈值的模拟中AI不应该主动冲向强敌。8.6 版本迭代建议像“8-18”这种版本号适合作为一次里程碑命名。建议在每次版本变化时至少做三件事保存运行前代码或Tag。记录本次版本的目标规则是“AI更聪明”“手感更顺滑”还是“吃食物更爽”。用一个固定测试脚本跑一遍关键路径例如“玩家出生、吃食物、撞AI、被大AI追击”五个动作。当你的改动破坏了以前能跑的核心行为时能从历史记录里快速比对差异。9. 总结与下一步可以做哪些事本文中的“Ariel篇”更像是一个最小可玩模型它把“最强球球鏖战”的轮廓浓缩在单个HTML文件中。通过这个项目你应该已经理解了一件事这类玩法的灵魂不在“圆球像不像”而在运动手感、吞噬判定和AI决策是否让玩家觉得战斗有来有回。如果继续往下深入有几个方向值得投入加入镜头缩放与地图卷动让游玩的视野从960×640扩展到任意大小。引入更丰富的AI感知方式比如视野遮挡、噪声误差让AI更像生物而不是全知地图。把参数变成运行时动态调整设计排行榜和不同角色特性让不同角色“手感”完全不同。尝试加入网络同步把Ariel和AI的更新放到服务端浏览器只做渲染与输入。建议在动手下一个版本前先把本文代码跑通并且亲手改一次eatFactor和sightR。观察同一段代码在不同参数下的行为差异要比阅读十篇理论文章更能帮助你建立对游戏AI和物理碰撞的直觉。把这篇文章的代码保存到本地作为后续实验的备用地基它的价值会随着你每一次调参和重构逐渐放大。