【javascript】泡泡龙游戏中反弹和查找匹配算法
引言
泡泡龙游戏的核心玩法依赖于物理碰撞与颜色匹配的算法实现。反弹效果需要模拟泡泡与边界或障碍物的弹性碰撞,确保轨迹符合物理规律;匹配算法则需快速检测相邻同色泡泡,触发消除逻辑。高效的处理方式直接影响游戏流畅度和玩家体验。
以下主要介绍反弹与匹配算法的多种实现思路和代码示例。
一、 反弹物理计算
- 几何变换法 Canvas/CSS 对称反射,无需手动计算角度,适用于垂直边界的反弹,属于镜像反射 的简化实现
(1)入射角度
计算鼠标相对与射击器中心的角度
//得到一个从射击器中心指向鼠标位置的角度值(单位:角度)
let iAngle = Math.abs((Math.atan2(ey - oy, ex - ox) * 180) / PI);
iAngle = Math.min(170, Math.max(10, iAngle)); //限制射击角度在 10° ~ 170° 范围内
iAngle = -angleToRadian(iAngle); //将角度转换回弧度,并反向处理
//补充工具函数
const normalizeAngle = (angle: number) => {// 角度可能超出 [0°, 360°),先归一化:return ((angle % 360) + 360) % 360;
}const radianTOAngle = (radian: number) => {return radian * 180 / Math.PI;
}const angleToRadian = (angle: number) => {return angle * Math.PI / 180;
}
(2)反射角度
左右反弹通过x轴方向,左边界反弹法线是x轴正方向,反射角度=-π -入射角度
右边界法线是x轴负方向,反射角度= π -入射角度
前端角度转换成数学角度,前端(CSS/Canvas)的旋转角度和数学计算角度差异(如下图),前端需要顺时针转90,方向从顺时转成逆时针取反,就得到90-前端角度=数学角度

网上还有另外一种思路,垂直反射改变水平移动速度
shooterBubble.dx表示泡泡水平移动速度 shooterBubble.dx = -shooterBubble.dx; 实现水平方向反弹, 镜像反射 的简化方式,适用于垂直边界, 若原方向为 30°,碰到右边界后变为 150°(即对称角度),实际上没有计算角度,只是改变 x 轴方向。
- 向量计算法 通用 2D/3D 反射 精确,适用于任意方向
(1)概念
利用向量的点积(dot product)和叉积(cross product)计算角度,适用于任意方向的入射角计算。
点积(内积)返回一个标量(数值),用于计算夹角、投影、相似度(光照、碰撞检测)。
叉积(外积)返回一个向量(在 3D 中),用于计算垂直方向、旋转方向、面积(法线、扭矩、几何判断)。
点积定义为 a⋅b=∣a∣⋅∣b∣⋅cosθ, ∣a∣ 和 ∣b∣ 是向量的模, θ 是两向量之间的夹角
在 2D 和 3D 中,点积的计算公式为:
(2D)a⋅b=ax bx +ay by (2D)
(3D)a⋅b=ax bx +ay by +az bz (3D)
function dotProduct(a, b) {return a.x * b.x + a.y * b.y; // 2D
}const a = { x: 1, y: 0 }; // 向右
const b = { x: 0, y: 1 }; // 向上const dot = dotProduct(a, b); // 0(垂直)
const angle = Math.acos(dot / (Math.hypot(a.x, a.y) * Math.hypot(b.x, b.y))); // 90°
(2)计算入射向量和法线向量:
入射向量 v1 = (x1, y1)
表面法线向量 n = (nx, ny)(垂直于反射面)
(3)计算反射向量(根据反射定律):反射向量=v1−2×(v1⋅n)×n
(其中 · 是点积)
(4)计算角度:
用 Math.atan2 计算反射向量与水平轴的夹角:Math.atan2(reflectedY, reflectedX)
(这里需要注意在数学计算中使用的是弧度值)
function calculateReflectionAngle(incidentVec, normalVec) {const dot = incidentVec.x * normalVec.x + incidentVec.y * normalVec.y;const reflectedX = incidentVec.x - 2 * dot * normalVec.x;const reflectedY = incidentVec.y - 2 * dot * normalVec.y;return Math.atan2(reflectedY, reflectedX); // 返回弧度
}
复习三角函数
a 表示 “arc”(弧),即通过比值反推角度
函数 | 含义 | 范围 | 场景 |
---|---|---|---|
Math.sin(x) | 正弦 (对边/斜边) | [-1, 1] | 周期性运动、旋转计算、波形生成 |
Math.cos(x) | 余弦 (邻边/斜边) | [-1, 1] | 周期性运动、旋转计算、波形生成 |
Math.tan(x) | 正切 (对边/邻边) | (-∞, ∞) | 斜率计算、视角映射、透视校正 |
Math.asin(x) | 反正弦 | [-π/2, π/2] | 角度反推、碰撞检测 |
Math.acos(x) | 反余弦 | [0, π] | 角度反推、碰撞检测 |
Math.atan(x) | 反正切 | [-π/2, π/2] | 简单斜率计算 |
Math.atan2(x) | 带象限的反正切 | [-π, π] | 精准角度计算(自动处理象限) |
二、 迭代BFS算法
- 概念
BFS(Breadth-First Search 广度优先搜索)是一种图遍历算法,它从根节点开始,先访问所有相邻节点,然后再依次访问这些相邻节点的相邻节点,以此类推,逐层扩展。
- 核心思路
队列数据结构:使用队列来存储待访问的节点(下面代码中queue)
层级遍历:按照距离起始节点的层级顺序访问
避免重复访问:使用标记数组或哈希表记录已访问节点(下面代码中visited)
- 获取六边形相邻布局节点
从左上开始按顺时针方向进行遍历,不要忘记进行边界检查
getNeighborsTiles(newPao: Tile): Tile[] {// 定义6个相邻方向(六边形网格)// 顺序:左上、右上、右、右下、左下、左const isEvenRow = (newPao.x % 2 === 0);const directions = [{ dx: -1, dy: isEvenRow ? -1 : 0, info: '左上' },{ dx: -1, dy: isEvenRow ? 0 : 1, info: '右上' },{ dx: 0, dy: 1, info: '右' },{ dx: 1, dy: isEvenRow ? 0 : 1, info: '右下' },{ dx: 1, dy: isEvenRow ? -1 : 0, info: '左下' },{ dx: 0, dy: -1, info: '左' }];const neighbors = [];// 按顺时针方向递归检查相邻泡泡for (const dir of directions) {const nx = newPao.x + dir.dx;const ny = newPao.y + dir.dy;// 边界检查if (nx < 0 || nx >= this.maxRow ||ny < 0 || ny >= (nx % 2 === 0 ? this.maxCol : this.maxCol - 1)) {continue;}const pao = this.grid.cells?.[nx]?.[ny];// console.log(`${nx}-${ny}-${dir.info}`, pao);if (pao) neighbors.push(pao)}return neighbors}
- 查找所有相邻的同色泡泡,返回需要消除的泡泡
查找过程:以当前停靠的泡泡为起点,查看周围所有与它相邻的泡泡,如果发现周围有相同颜色(数字相同)的泡泡,那么就以这个泡泡为起点,继续查看其周围相邻的泡泡…一直重复这个过程,直到周围不再有相邻的泡泡为止。时间和空间复杂度O(N)。
searchRemovePaos(newPao: Tile): Tile[] {// 记录所有相邻的泡泡,六边形布局以左上角泡泡为开始,顺时针进行查找相邻的泡泡const visited: Set<string> = new Set();//已访问集合,避免重复查找;const queue: Tile[] = [newPao]; //BFS 队列用于广度优先查找;const removePaos: Tile[] = [];// 存放最终要消除的泡泡;// 起始泡泡先加入visited.add(`${newPao.x},${newPao.y}`);removePaos.push(newPao);while (queue.length > 0) {const current = queue.pop();console.log('查找current', current);if (!current) continue;const neighbors = this.getNeighborsTiles(current);for (const neighbor of neighbors) {const key = `${neighbor.x},${neighbor.y}`;if (visited.has(key)) continue; // 已经查过,跳过visited.add(key); // 标记为已访问if (neighbor.color === current.color) {removePaos.push(neighbor); // 同色泡泡加入消除列表queue.push(neighbor); // 并继续扩散查找}}}return removePaos.length >= 3 ? removePaos : [];}
- 悬浮泡泡消除
使用BFS 遍历查找所有与顶部相连的泡泡,再次遍历整个网格,找出所有 未与顶部相连的泡泡(悬空泡泡),并返回它们,时间和空间复杂度O(M × N)
searchDropPaos() {const visited: Set<string> = new Set();//用于记录已经访问过的泡泡const connectedToTop: Set<string> = new Set();// 存储和顶部相连的泡泡const queue: Tile[] = [];// BFS 队列// 初始化队列,将顶部的泡泡加入for (let y = 0; y < (0 % 2 === 0 ? this.maxCol : this.maxCol - 1); y++) {const topPao = this.grid.cells[0][y];if (topPao) {const key = `${0},${y}`;queue.push(topPao);visited.add(key);connectedToTop.add(key);}}// BFS 遍历查找所有与顶部相连的泡泡:while (queue.length > 0) {const current = queue.shift();if (!current) continue;const neighbors = this.getNeighborsTiles(current);for (const neighbor of neighbors) {const key = `${neighbor.x},${neighbor.y}`;if (visited.has(key)) continue;visited.add(key);connectedToTop.add(key);queue.push(neighbor);}}// 只能从顶部出发,找到所有直接或间接与顶部相连的泡泡。但无法直接标记出 未与顶部相连 的泡泡// 需再次遍历整个网格,找出所有未和顶部相连的泡泡const dropPaos: Tile[] = [];for (let x = 0; x < this.boxRow; x++) {const maxColInRow = x % 2 === 0 ? this.maxCol : this.maxCol - 1;for (let y = 0; y < maxColInRow; y++) {const pao = this.grid.cells?.[x]?.[y];if (pao) {const key = `${x},${y}`;if (!connectedToTop.has(key)) {dropPaos.push(pao);}}}}return dropPaos;}
扩展
另一种图遍历算法 DFS(Depth-First Search 深度优先搜索),它沿着一条路径尽可能深入地搜索,直到无法继续前进,然后回溯并尝试其他路径。
- 核心思路
栈数据结构:使用栈(递归调用栈或显式栈)来存储待访问节点
深度优先:尽可能深地探索一条路径
回溯机制:当无法继续前进时返回上一个分叉点
- 递归DFS(隐式栈)
优点:①数据规模小(如二叉树深度<1000)②代码简洁性优先
缺陷:①栈溢出风险:深度过大时(如1万+层)会触发Maximum call stack size exceeded ②函数调用比循环消耗更多资源
// 递归DFS(邻接链表表示)
function dfsRecursive(graph, node, visited = new Set()) {console.log(node); // 访问节点visited.add(node);for (const neighbor of graph[node]) {if (!visited.has(neighbor)) {dfsRecursive(graph, neighbor, visited); // 递归调用, 这行是隐式使用调用栈(Call Stack)保存状态 visited}}
}// 使用示例
const graph2 = {'A': ['B', 'C'],'B': ['D'],'C': ['E'],'D': [],'E': []
};
dfsRecursive(graph2, 'A'); // 输出: A → B → D → C → E
- 迭代DFS(显式栈)
优点:①避免栈溢出:栈大小由堆内存控制,可处理超深结构 ②性能更好:减少函数调用开销 ③灵活控制:可随时暂停/恢复遍历
缺点:代码可读性低需手动管理状态(循环推荐手动管理stack和visited )
// 迭代DFS(显式栈)显式使用栈结构(数组)代替调用栈
function dfsIterative(graph, start) {const stack = [start]; // 显式栈,模拟调用栈无深度限制(受限于堆内存而非调用栈),通过push/pop操作实现栈操作const visited = new Set();// 减少函数调用开销,可随时暂停/恢复遍历,更灵活while (stack.length > 0) {const node = stack.pop(); // 取栈顶if (visited.has(node)) continue;console.log(node); // 访问节点visited.add(node); // 通过循环推进// 逆序压栈保证遍历顺序(与递归一致)for (let i = graph[node].length - 1; i >= 0; i--) {stack.push(graph[node][i]);}}
}// 使用相同的graph
dfsIterative(graph2, 'A'); // 输出: A → C → E → B → D
特性 | BFS | DFS |
---|---|---|
数据结构 | 队列 | 栈(递归或显式) |
空间复杂度 | O(V)(最宽层级) | O(V)(最长路径) |
最短路径 | 可以找到(无权图) | 不一定能找到 |
实现方式 | 通常迭代实现 | 递归或迭代实现 |
适用场景 | 最短路径、层级关系 | 拓扑排序、连通性检测 |
总结
泡泡反弹使用Math.atan2(mouseY - SHOOTER_Y, mouseX - SHOOTER_X)计算鼠标相对于炮台的发射角度,简单的垂直方向反射可以直接用 PI 去计算,其他方向反射用向量计算法更准确。泡泡消除查询匹配使用BFS,BFS 适合从顶部逐层向下扩展,天然符合“与顶部连接”的逻辑。更容易控制边界条件:BFS 按层处理,适合六边形网格这种结构较复杂的布局。DFS 不利于全局连通性判断:DFS 更适合路径探索或图的连通性判定,但在这种网格结构中,BFS 更直观、更高效。
参考资料
- 【泡泡龙游戏】泡泡如何发射,反弹,移动,停靠
- 【泡泡龙游戏】核心查找匹配算法