【OpenHarmony/HarmonyOS】Canvas 坦克碰撞与反弹弹道:防穿墙、墙角处理和自伤保护

在迷宫坦克游戏中,碰撞系统决定了操作手感。本文从连续移动、九点墙体检测、滑墙处理、圆形命中、可破坏墙和子弹反弹讲起,并分析高速对象最常见的穿透与卡墙问题。💥

一、碰撞系统需要处理哪些关系?

项目中的碰撞并不是单一的“两个矩形相交”,而是几类不同问题:

  • 坦克与迷宫墙:大体积、低速、需要沿墙滑动;
  • 子弹与墙:小体积、高速、不可破坏墙反弹;
  • 子弹与砖墙:命中后销毁墙体和子弹;
  • 子弹与坦克:圆形近似,区分队伍、所有者与护盾;
  • 坦克与道具/晶石/传送门:圆形触发区域;
  • 地图边界:统一视为不可穿越实体。

不同对象的速度、形状和反馈目标不同,因此项目没有强行使用同一种检测方式。

二、坦克移动必须乘以 deltaTime

输入摇杆输出 [-1, 1] 的二维向量,坦克先归一化,再按每秒速度计算本次位移:

const length = inputVector.length();
if (length > 0) {
  const scale = (currentSpeed * deltaTime) / length;
  const stepX = inputVector.x * scale;
  const stepY = inputVector.y * scale;

  const nextX = this.position.x + stepX;
  const nextY = this.position.y + stepY;
}

归一化可以避免斜向输入的长度达到 sqrt(2),导致斜走比横走更快。乘 deltaTime 则确保设备帧率变化时每秒移动距离不变。

坦克朝向使用:

this.rotation = Math.atan2(inputVector.y, inputVector.x);

atan2 能覆盖四个象限,Canvas 绘制时再根据素材默认朝向补偿 Math.PI / 2

三、为什么检测四个角仍会穿墙?

许多入门实现只检测坦克包围盒四角所在格子。项目中的坦克直径约为 2 个格子,而墙只有 1 格厚。当一面竖墙正好从坦克中心穿过时,四个角可能都落在墙两侧的空地,检测结果仍为“未碰撞”。

为此项目检测四角、四边中点和中心共九个点:

const pointsX = [
  x - halfSize, x + halfSize,
  x - halfSize, x + halfSize,
  x, x,
  x - halfSize, x + halfSize,
  x
];

const pointsY = [
  y - halfSize, y - halfSize,
  y + halfSize, y + halfSize,
  y - halfSize, y + halfSize,
  y, y,
  y
];

每个采样点映射到迷宫格:

const gridX = Math.floor(pointX / cellSize);
const gridY = Math.floor(pointY / cellSize);

if (gridY < 0 || gridY >= maze.length ||
    gridX < 0 || gridX >= maze[0].length) {
  return true;
}

const cell = maze[gridY][gridX];
if (cell === 1 || cell === 2 || cell === 4) {
  return true;
}

这种采样仍不是精确的矩形与网格求交,但对规则格子和固定坦克尺寸成本很低,已经能修复“薄墙穿过碰撞盒中心”的典型问题。

四、碰撞后不要简单地完全停止

如果斜向移动碰到墙,直接取消整个 (x, y) 位移,会让坦克像被粘在墙上。项目先尝试完整移动,失败后分别尝试 X 轴和 Y 轴:

if (!this.checkCollision(nextX, nextY, maze)) {
  this.position.set(nextX, nextY);
} else if (!this.checkCollision(nextX, this.position.y, maze)) {
  this.position.x = nextX;
} else if (!this.checkCollision(this.position.x, nextY, maze)) {
  this.position.y = nextY;
}

这会产生自然的滑墙效果。例如向右下移动撞到竖墙时,X 分量被阻止,Y 分量仍能继续。

要注意轴顺序会带来轻微偏好。更严谨的实现可根据位移绝对值先处理较大轴,或使用 swept AABB 计算最早碰撞时间。

五、子弹为什么使用中心加四边采样?

子弹是圆形、小体积对象。项目先检查中心点,再检查圆周上下左右四点:

let tile = this.getTileAt(x, y, maze);
if (tile) return tile;

tile = this.getTileAt(x + this.radius, y, maze);
if (tile) return tile;
tile = this.getTileAt(x - this.radius, y, maze);
if (tile) return tile;
tile = this.getTileAt(x, y + this.radius, maze);
if (tile) return tile;
return this.getTileAt(x, y - this.radius, maze);

这比只检测中心更早接触墙面,也比进行圆形与每个墙格精确求交更便宜。地图外统一返回类型 1,相当于一圈不可破坏边界墙。

六、区分不可破坏墙和砖墙 🧱

子弹更新得到碰撞格后,根据类型进入不同分支:

if (collision.type === 2) {
  this.hitBreakableWall = collision;
  this.isDead = true;
} else {
  this.handleBounce(maze, deltaTime);
}

Bullet 不直接修改迷宫,而是记录 hitBreakableWallGameEngine 在统一更新阶段完成墙体销毁和特效:

if (bullet.hitBreakableWall) {
  const { gridX, gridY } = bullet.hitBreakableWall;
  if (this.maze[gridY][gridX] === 2) {
    this.maze[gridY][gridX] = 0;
    this.spawnEffect(worldX, worldY, '#795548', 15, 2,
      bullet.ownerId === 'player1');
  }
}

这个边界很有价值:子弹只负责自身物理响应,引擎负责修改共享世界、播放声音和创建粒子。

七、反弹方向怎样判断?

对轴对齐墙体,最实用的方法是分别测试 X 和 Y 方向的移动:

const nextX = currX + this.velocity.x * deltaTime;
const nextY = currY + this.velocity.y * deltaTime;

const hitX = this.checkCollision(nextX, currY, maze);
const hitY = this.checkCollision(currX, nextY, maze);

if (hitX) this.velocity.x = -this.velocity.x;
if (hitY) this.velocity.y = -this.velocity.y;

if (!hitX && !hitY) {
  this.velocity.x = -this.velocity.x;
  this.velocity.y = -this.velocity.y;
}

命中竖直墙面时翻转 X 速度,命中水平墙面时翻转 Y 速度。斜向移动碰到恰好墙角,而两个单轴测试都未命中,则同时翻转两个分量。

从向量物理角度,理想反射公式是:

R = I - 2(I · N)N

其中 I 为入射向量,N 为墙面法线。对轴对齐墙,翻转某个速度分量正好等价于该公式,而且省去了法线求解。

八、如何防止子弹卡在墙里?

项目发生碰撞后不采用导致碰撞的目标位置,本帧只改变速度。下一帧子弹会沿反方向离开墙面。这比“先进入墙体再修正”更稳定。

同时设置最大反弹次数:

this.bounces++;
if (this.bounces >= this.maxBounces) {
  this.isDead = true;
}

最大反弹既是玩法规则,也是安全阀,防止某颗子弹永久存在或在狭小区域无限抖动。

不过高速子弹仍可能发生 tunneling:如果单帧位移大于墙厚,它可能从墙的一侧直接跳到另一侧,目标点仍在空地。可选改进包括:

  • 根据位移长度把一次更新拆成多个子步;
  • 从当前位置到下一位置进行 DDA 网格遍历;
  • 使用 swept circle/AABB 求最早碰撞时间;
  • 约束 bulletSpeed * dt 小于墙厚。

固定时间步长已经降低了穿透概率,但不能从数学上完全消除。

九、子弹命中坦克为什么用平方距离?

坦克和子弹都近似为圆形,只需判断圆心距离是否小于半径和:

const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
const distanceSquared = dx * dx + dy * dy;
const radiusSum = r1 + r2 * 1.2;
return distanceSquared < radiusSum * radiusSum;

无需调用 Math.sqrt,批量检测时更省。这里把目标半径适度放大,使触发反馈更明确,是游戏手感中的“宽容命中盒”。数值应通过实机测试调整,而不是盲目追求几何完全一致。

十、队伍、自伤和出生保护

命中判定还要处理归属关系:

  • 同队子弹不造成伤害;
  • 自己的子弹经过反弹后可以伤到自己;
  • 子弹刚离开炮口时不能立刻命中发射者。

项目用子弹存活时间提供 0.2 秒保护:

if (bullet.ownerId === 'player1') {
  isFriendly = bullet.lifeTime < 0.2;
} else if (bullet.teamId === playerTank.teamId) {
  isFriendly = true;
}

这种设计保留了“反弹误伤”的战术深度,同时解决炮口与坦克碰撞体重叠造成的出生即自爆。

十一、护盾和反馈应放在哪一层?

碰撞成立后,引擎先让子弹死亡,再判断护盾:

bullet.isDead = true;
if (!tank.hasShield) {
  tank.isDead = true;
  this.spawnEffect(tank.position.x, tank.position.y,
    tank.color, 50, 5, true);
} else {
  AudioManager.getInstance().playSound('shield_hit');
}

物理事实、玩法规则和感官反馈虽然发生在同一事件中,但最好保持顺序:

  1. 确认几何碰撞;
  2. 过滤阵营和保护期;
  3. 消耗子弹;
  4. 应用护盾或死亡规则;
  5. 更新统计;
  6. 播放粒子、声音和振动。

这样以后增加穿透弹、反射盾、无敌帧或伤害数值时,不需要重写底层几何判断。

十二、性能细节:倒序遍历与交换删除

子弹死亡后需要从数组移除。项目倒序遍历,并用尾元素覆盖当前位置:

this.bulletPool.release(bullet);
const lastIndex = this.bullets.length - 1;
if (index !== lastIndex) {
  this.bullets[index] = this.bullets[lastIndex];
}
this.bullets.pop();

它是 O(1) 删除,不会像 splice(index, 1) 那样搬移后续元素。代价是数组顺序改变,但子弹更新不依赖顺序,因此非常适合。

十三、总结 🎯

迷宫坦克的碰撞系统用了几种简单但针对性很强的方法:

  • 坦克用九点采样解决薄墙穿过大碰撞盒;
  • 完整位移失败后分别尝试 X/Y,获得滑墙手感;
  • 子弹用中心和四边采样检测墙体;
  • 单轴测试判断反弹方向,墙角同时翻转;
  • 可破坏墙通过事件交给引擎修改;
  • 圆形平方距离处理子弹、坦克、道具和传送门;
  • 队伍、所有者和生命周期共同决定是否造成伤害;
  • 对象池与交换删除控制高频对象开销。

没有引入重型物理引擎,也能建立清晰、稳定且富有玩法的碰撞系统。关键是根据对象特性选择合适的近似,并为边界情况留下明确规则。✨


推荐标签: HarmonyOS OpenHarmony Canvas 碰撞检测 弹道反射 游戏物理

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐