开局先聊点实际的。很多刚接触“球球对战”玩法的人,第一反应是这不过是一款靠手速吃饭的休闲小游戏;可真要自己动手把它做出来才发现,里面既有坐标系与镜头缩放的数学问题,又有碰撞检测、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这里摄像机坐标取玩家坐标即可。不过如果玩家体积增大,视野要适当拉远,否则屏幕会被玩家的大圆占满。于是需要引入缩放值 zoom:
zoom = 初始缩放 / 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”时触发吞噬。这个系数可以按手感调整,数值越小,吞噬判定越宽松。如果两个球半径接近,比如玩家半径 80,AI 半径 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.9 |
| Ariel 初始面积 | 900 | 初始速度略快于玩家 |
| 食物数量 | 240 | 刷新在世界上 |
| 玩家速度公式 | 3000 / Math.sqrt(玩家面积 / Math.PI) | 半径越大移动越慢 |
地图大小可根据屏幕响应式调整,但世界大小保持不变,这样能保证游戏空间感。
5.2 文件结构
直接复制以下内容保存为index.html,双击即可运行。代码分四大块:CSS 样式、DOM 结构、全局变量与初始化、更新与绘制逻辑。
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.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 class="hint">方向键 / WASD 移动,吃掉食物变大,击败AI</p> <canvas id="gameCanvas" width="800" height="600"></canvas> <div id="info">玩家体积: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> </html>5.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 的每个模块跑通,再逐步加入网络同步和状态管理,这样每一步都有可验证的成果。