news 2026/9/4 1:54:47

从碰撞检测到AI决策:手把手实现一个球球对战游戏原型

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从碰撞检测到AI决策:手把手实现一个球球对战游戏原型

提起“最强球球鏖战”这六个字,大多数玩家的第一反应是“一群圆球在场上互相吞噬,比谁滚得大”。但如果你和我一样,需要自己动手复刻一个可运行的玩法制样,你会发现“鏖战”的观感背后藏着两套完全不同的工程问题:一套是球体运动、碰撞分离、视野缩放这类确定性物理逻辑;另一套是AI如何判断危险、追踪目标、保存自己的决策逻辑。真正决定一个Demo好不好玩的,通常不是美术画了多少个球,而是这两套逻辑是否在同一个帧循环里跑得足够稳。

这篇文章以“Ariel篇”的8-18版本为主线,梳理一个可复现的球球对战原型实现过程。Ariel在这里是可控角色的代号,8-18是本次迭代的版本序列。文章会先拆解核心玩法结构,再进入完整代码,最后给出运行验证、常见问题与工程建议。如果你正在做2D多人对战游戏原型、游戏AI小Demo,或者想理解“许多圆形物体同时运动和碰撞”时应该怎么设计,这篇文章可以作为一份可以照着改的起点。

1. Ariel篇到底要解决什么问题

很多刚接触小型游戏项目的人会有一种错觉:场景里只要有几个圆形精灵,让它们根据鼠标方向移动,就能算是“球球对战”。其实从工程角度看,这只是第一步,远没有构成“战斗”。真正的球球对战,至少要解决三个层次的问题:

第一,运动层。角色怎么移动?加速度和摩擦力如何作用?玩家输入如何映射到物理上?如果角色只有一个速度而没有加速度过程,手感会非常“生硬”。

第二,碰撞层。两个球体碰到一起,什么时候应该互相推开,什么时候应该发生“吞噬”?如果一个球比另一个球大出一定比例,该不该允许直接吃掉?碰撞后半径和质量如何变化?这些判断一旦写错,就会出现“明明没碰到却被吃掉”“大球穿过小球”的视觉事故。

第三,决策层。NPC什么时候追击、什么时候逃跑?它根据什么信息判断对手强还是弱?“鏖战”好不好看,很大程度上依赖于AI是否表现得有策略性,而不是像随机布朗运动一样无意义乱滚。

“Ariel篇”作为单个角色的战斗篇章,正好可以用最小规模把三层问题全部走通:一个玩家角色、若干AI角色、地图边界、刷新食物。它不追求商业游戏的数值深度,而是追求一个经验丰富的人能在30分钟内理解原型、跑起来、改参数、看效果。如果这三层都能稳定工作,后面再加技能系统、多人网络同步、局外成长,只是在同一架构上做增量。

与其等到地图做大了再补物理,不如先让一个球的玩法闭环。所谓“最强”,在Demo阶段不是指数值爆炸,而是指每一帧的判定都符合直觉。

2. 球球对战的基础概念与核心机制

在动手写代码前,有几组概念必须掰扯清楚。如果这个阶段模糊,调试会浪费大量时间。

2.1 球体实体不只是“画一个圆”

从数据结构看,一个可运动、可碰撞、可吞噬的球至少要维护以下状态:

  • 位置:xy
  • 速度:vxvy
  • 半径:r
  • 质量或“体型值”:用于计算碰撞后的效果
  • 归属标识:是玩家、AI,还是食物

有人会问,为什么不直接让位置改变,而是引入速度?因为真实感来自运动惯性。如果每帧直接让球跳到鼠标位置,角色就变成了“瞬移”,没有追击和拉开距离的空间。引入速度后,玩家输入控制的是加速度,程序根据加速度改变速度,再根据速度改变位置,手感才会顺滑。

2.2 大球吃小球的数学处理

“吞噬”不能简单地判断两个圆心距离小于半径之和就触发,否则两个同等大小的球相遇会被错误地吃掉。更安全的做法是这样:

  • 计算两个球体的实际距离。
  • 如果距离小于两者半径之和,说明发生了接触。
  • 如果其中一方半径大于另一方半径乘以某个阈值,比如大于1.2倍,才允许被吞噬。
  • 吞噬后,幸存方的新半径,根据面积守恒原则计算:
newR = Math.sqrt(r1 * r1 + r2 * r2);

这是球体对战中比较常见的处理方式,比直接做加法更接近“面积守恒”的直觉。因为圆的面积和半径平方成正比,两个圆合并后的面积等于二者面积之和,所以反推半径时要用平方根。

如果阈值判断不满足,比如双方差距不够大,就不要触发吞噬,而是执行“推开分离”。这也是很多新手容易踩的坑:把所有接触都当成吞噬,导致大球被小球“粘住”或者同级别球互相重叠抖动。

2.3 碰撞后的推开与分离

两个球不能吞噬时,起码要满足“互不穿透”。最简单的方法如下:

  1. 计算碰撞法线方向,即从A球心指向B球心的单位向量。
  2. 计算重叠深度,等于半径之和减去圆心距离。
  3. 把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 lang="zh-CN"> <head> <meta charset="UTF-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 id="topbar"> 分数:<span id="score">0</span>|半径:<span id="radius">18</span>|FPS:<span id="fps">0</span> <br />鼠标移动控制Ariel,空格键刷新食物,R键重新开始 </div> <canvas id="arena"></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> </html>

4.2 为什么这样组织代码

上面这段代码有意把“状态初始化”“运动更新”“AI更新”“碰撞处理”“渲染”拆成了不同函数。这样拆分不是为了显得专业,而是为了便于调试。

如果你想单独试AI参数,不需要碰渲染代码;如果你发现碰撞抖动,只需要排查handleCollisions中的分离逻辑;如果你想增加一个新食物类型,直接在spawnFooddrawFood里改即可。

有一点容易忽视:在updateAI中使用了一个简化速度更新方法。AI没有像玩家那样走“加速度-摩擦”的方式,而是直接设置一个合理速度,然后乘以固定时间因子。这么做是因为AI不需要精细的操作手感,更重要的是响应速度,直接让AI稳定朝目标运动能避免机器人出现原地画圈或抖动。

4.3 时间步长为什么要做上限截断

requestAnimationFrame在不同显示器刷新率下回调频率不同,且标签页切后台再回来时,timestamp会发生突变。如果没有Math.min(dt, 0.05)这一行阻拦,物理计算会因为“一帧等于好几秒”而出现两个球直接穿模或者飞出地图的情况。

dt上限设为0.05秒,是一个比较通用的工程防御手段。它不会解决所有问题,但能保证最坏情况下不会因为浏览器切后台导致游戏崩溃。

5. 运行方法与预期效果验证

5.1 运行步骤

  1. 将上述代码保存为ariel-8-18.html
  2. 使用Chrome或Edge直接双击打开该文件。也可以启动一个本地静态服务器:
python3 -m http.server 8080

然后访问http://localhost:8080/ariel-8-18.html

  1. 页面加载后,你会看到一个暗色战场,中央附近出现一个青绿色小球,右下角显示分数、半径和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,球越“粘滞”。
  • aiCount:AI数量越多,战斗越混乱,但碰撞计算量也越大。当前6个AI在960×640画布上比较平衡。
  • sightR:这是AI的感知半径,当前在updateAI中写死为160。感知半径越大,AI越聪明;越小,AI越像傻子。但它也影响每帧查找的目标数量,在实际项目中应该提取成配置。

调参的核心方法是“一次只改一个值”。如果你同时修改了摩擦、AI数量和吞噬阈值,就很难判断行为变化来自哪一个参数。

7. 常见问题与排查思路

很多第一次把这个Demo跑起来的人,会遇到类似下面的现象。这里整理了一份排查表,按优先级从高到低排列。

问题现象可能原因排查方式解决方案
小球被吞噬后画面闪动移除逻辑和碰撞顺序不严格打开控制台确认没有报错,观察闪动是否发生在balls.filter之后先过滤死亡对象,再进入渲染;碰撞循环中也跳过已死对象
玩家球被卡在地图边缘不停抖动摩擦值过小或边界速度清零后仍累积检查updatePlayerclampToArena执行顺序在边界处同时把速度设为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 把参数从“魔法数字”全部抽成配置

代码里的sightR=160foodCount=30maxSpeed=5.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的更新放到服务端,浏览器只做渲染与输入。

建议在动手下一个版本前,先把本文代码跑通,并且亲手改一次eatFactorsightR。观察同一段代码在不同参数下的行为差异,要比阅读十篇理论文章更能帮助你建立对游戏AI和物理碰撞的直觉。把这篇文章的代码保存到本地,作为后续实验的备用地基,它的价值会随着你每一次调参和重构逐渐放大。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/4 1:53:41

STM32多传感器融合实战:智能垃圾桶项目设计与实现

简介&#xff1a;本资源是一套完整的STM32智能垃圾桶毕业设计/课程设计实战项目&#xff0c;面向电子信息、自动化、嵌入式等专业本科生及实践开发者&#xff0c;解决垃圾分类终端设备的软硬件协同开发痛点。压缩包共含程序源码工程&#xff08;Keil MDK&#xff09;、原理图与…

作者头像 李华
网站建设 2026/9/4 1:53:05

六边形程序化地图生成实战:从坐标系统到柏林噪声地形

做过策略游戏或者研究过《文明》这类六边形回合制的人&#xff0c;可能都有过一个共同冲动&#xff1a;能不能自己从零写一套地图生成器&#xff0c;让开局不是固定关卡&#xff0c;而是每个存档都生成一颗独一无二的行星&#xff1f;这个冲动很容易栽跟头。市面上关于 Unity 网…

作者头像 李华
网站建设 2026/9/4 1:51:50

若依框架健身房系统跑通实战:数据库脚本与权限机制深度解析

简介&#xff1a;这是一套面向计算机专业本科生的毕业设计级健身房管理系统实战项目&#xff0c;基于主流若依&#xff08;RuoYi&#xff09;前后端分离框架开发&#xff0c;专为课程设计、期末大作业及毕业设计选题打造&#xff0c;兼顾功能完整性与工程规范性&#xff0c;小白…

作者头像 李华
网站建设 2026/9/4 1:46:56

Unity动作游戏技能系统架构:从状态机到数据驱动的工程实践

动作游戏技能系统&#xff0c;往往是项目从原型走向可玩版本的第一个分水岭。前期直接写单机逻辑确实爽快&#xff1a;按下攻击键、播放动画、碰撞体生效、数值掉血&#xff0c;一套连招打下来一气呵成。但一旦技能数量从两三个膨胀到几十个&#xff0c;加 Buff、打断、连招、霸…

作者头像 李华
网站建设 2026/9/4 1:44:55

基于LiteLLM构建统一AI API网关:团队协作中的密钥管理与模型路由实践

给一个多人协作的 AI 应用团队搭过接口的朋友都知道&#xff0c;最麻烦的往往不是模型效果本身&#xff0c;而是每个成员各自存了一堆 API Key&#xff1a;模型供应商不同、Base URL 不同、调用地址散落各处&#xff0c;密钥一旦提交到 Git 仓库&#xff0c;就有被盗刷的风险。…

作者头像 李华