【OpenHarmony/HarmonyOS】Canvas 坦克碰撞与反弹弹道:防穿墙、墙角处理和自伤保护
【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 不直接修改迷宫,而是记录 hitBreakableWall。GameEngine 在统一更新阶段完成墙体销毁和特效:
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');
}
物理事实、玩法规则和感官反馈虽然发生在同一事件中,但最好保持顺序:
- 确认几何碰撞;
- 过滤阵营和保护期;
- 消耗子弹;
- 应用护盾或死亡规则;
- 更新统计;
- 播放粒子、声音和振动。
这样以后增加穿透弹、反射盾、无敌帧或伤害数值时,不需要重写底层几何判断。
十二、性能细节:倒序遍历与交换删除
子弹死亡后需要从数组移除。项目倒序遍历,并用尾元素覆盖当前位置:
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 碰撞检测 弹道反射 游戏物理

更多推荐


所有评论(0)