【OpenHarmony/HarmonyOS】游戏数学工具的类型陷阱:Vector2、距离计算与 ArkTS 约束
【OpenHarmony/HarmonyOS】游戏数学工具的类型陷阱:Vector2、距离计算与 ArkTS 约束
二维向量只有 x、y 两个数,看起来不值得专门写文章。但它会流经坦克位置、摇杆输入、子弹速度、AI 路径、粒子运动、摄像机和碰撞检测。一个命名错误可能潜伏很久:项目当前
dot()返回Vector2,执行的是分量乘法,而数学中的点积应返回标量。因为暂时没有调用点,编译不会报警;一旦用于视野角度,逻辑就会失真。本篇从 API 契约、可变性、数值边界和高频分配四个方面审视 Vector2。📐
一、Vector2 在项目中的角色
项目中至少有 27 处 new Vector2(),覆盖:
| 场景 | 向量语义 | 是否高频变化 |
|---|---|---|
| Tank.position | 世界坐标 | 是 |
| Bullet.position/velocity | 位置与速度 | 是 |
| Particle.position/velocity | 位置与速度 | 是,大量 |
| EnemyTank.currentDir | 单位移动方向 | 是 |
| Pathfinding 节点 | 网格坐标 | 路径计算时 |
| VirtualJoystick 输出 | -1~1 输入向量 | 触摸时 |
| cameraPosition | 摄像机世界偏移 | 每帧 |
| Portal/PowerUp/Crystal.position | 静态或低频坐标 | 低 |
同一个类同时承载连续世界坐标和离散网格坐标,使用方便,但类型系统无法阻止把“格坐标”直接当成“像素坐标”。更大型项目可以引入语义包装或命名约定,如 GridPoint 与 WorldPosition。
二、当前 API 分为新对象和原地修改两组
export class Vector2 {
x: number;
y: number;
add(v: Vector2): Vector2 {
return new Vector2(this.x + v.x, this.y + v.y);
}
addInPlace(v: Vector2): Vector2 {
this.x += v.x;
this.y += v.y;
return this;
}
multiply(scalar: number): Vector2 {
return new Vector2(
this.x * scalar,
this.y * scalar
);
}
multiplyScalarInPlace(scalar: number): Vector2 {
this.x *= scalar;
this.y *= scalar;
return this;
}
}
| 方法 | 是否修改原对象 | 返回 |
|---|---|---|
add |
否 | 新 Vector2 |
addInPlace |
是 | this |
subtract |
否 | 新 Vector2 |
multiply |
否 | 新 Vector2 |
multiplyScalarInPlace |
是 | this |
set |
是 | this |
copy |
是 | this |
normalize |
否 | 新 Vector2 |
clone |
否 | 新 Vector2 |
InPlace 后缀清楚表达副作用,这是好习惯。set() 和 copy() 虽然没有后缀,但常见语义就是修改自身。调用链可以写 direction.copy(target).multiplyScalarInPlace(0.5)。
需要保持对称性:如果常用 subtractInPlace 或 normalizeInPlace,可以补充;不要为了 API 看起来齐全而实现无人使用的方法。
三、最严重的契约问题:dot 不是点积 ⚠️
当前实现:
dot(v: Vector2): Vector2 {
return new Vector2(
this.x * v.x,
this.y * v.y
);
}
它返回 (x1*x2, y1*y2),数学上叫分量乘积或 Hadamard product。标准点积是:
a · b = ax × bx + ay × by
应返回 number:
dot(v: Vector2): number {
return this.x * v.x + this.y * v.y;
}
举例:
a = (1, 0), b = (0, 1)
正确点积 = 0,表示垂直
当前 dot = (0, 0),类型完全不同
a = (2, 3), b = (4, 5)
正确点积 = 2×4 + 3×5 = 23
当前 dot = (8, 15)
项目当前没有任何 .dot() 调用,所以问题暂时没有影响运行。它是“潜伏的 API 缺陷”:名字和返回类型都合法,只有数学语义错误。应把当前行为重命名为 multiplyComponents(),并实现真正的 dot。
四、点积在游戏里能做什么
点积不是抽象公式,它能解决常见玩法:
判断目标在前方还是后方
const toTarget = target.subtract(position).normalize();
const facing = new Vector2(
Math.cos(rotation),
Math.sin(rotation)
);
const alignment = facing.dot(toTarget);
// >0 前方,<0 后方,接近 0 侧面
视野锥判断
单位向量点积等于夹角余弦。设半视角 45°:
const visible = alignment >= Math.cos(Math.PI / 4);
反射与投影
子弹速度 v 相对法线 n 的反射:
r = v - 2(v·n)n
如果 dot() 返回 Vector2,上述公式无法自然表达,类型应该尽早阻止错误。
五、length 和 distance 的正确性与成本
当前长度:
length(): number {
return Math.sqrt(
this.x * this.x + this.y * this.y
);
}
距离:
distance(v: Vector2): number {
const dx = this.x - v.x;
const dy = this.y - v.y;
return Math.sqrt(dx * dx + dy * dy);
}
两者数学上正确。需要真实距离时必须开方;只比较远近或碰撞时,可以比较平方,避免 sqrt:
distanceSquared(v: Vector2): number {
const dx = this.x - v.x;
const dy = this.y - v.y;
return dx * dx + dy * dy;
}
const hit = a.distanceSquared(b) < radius * radius;
项目中的道具碰撞已经使用平方距离,晶石拾取仍使用 Math.sqrt(),说明相同数学规则有多种写法。提供 distanceSquared() 能统一高频路径,也让方法名提醒调用者返回值不能直接当像素距离显示。
六、normalize 对零向量做了保护
normalize(): Vector2 {
const len = this.length();
if (len === 0) {
return new Vector2(0, 0);
}
return new Vector2(
this.x / len,
this.y / len
);
}
没有零判断时 (0,0) 会除以 0,产生 NaN,再污染位置、摄像机和 Canvas。返回新的零向量是安全选择。
但还可以考虑极小数和非有限输入:
normalizeSafe(epsilon: number = 1e-8): Vector2 {
const lenSq = this.x * this.x + this.y * this.y;
if (!Number.isFinite(lenSq) ||
lenSq <= epsilon * epsilon) {
return new Vector2(0, 0);
}
const inverse = 1 / Math.sqrt(lenSq);
return new Vector2(
this.x * inverse,
this.y * inverse
);
}
这是演进示例。游戏输入通常范围有限,是否需要 epsilon 取决于数据来源和性能要求。
七、EnemyTank 为什么使用原地归一化
AI 跟随路径时:
this.currentDir.set(dx, dy);
const length = this.currentDir.length();
if (length > 0) {
this.currentDir.multiplyScalarInPlace(
1 / length
);
}
它没有调用返回新对象的 normalize(),而是在复用 currentDir。AI 每帧更新,原地修改减少短命 Vector2 分配。对象所有权清楚时,这是合理优化。
反例是把共享位置引用直接赋给另一个实体再原地修改。传送门代码使用:
tank.position = target.position.clone();
如果写成 tank.position = target.position,坦克和传送门会共享同一个 Vector2;之后坦克移动就会把门也带走。clone() 在这里是必要的所有权隔离。
八、copy 和引用赋值的差异
EnemyTank 记录上一帧位置:
this.lastPos.copy(this.position);
lastPos 对象本身保持不变,只复制数值。若写成:
this.lastPos = this.position;
两个字段将指向同一对象,下一帧比较移动距离永远接近 0,卡住检测失效。
| 写法 | 结果 | 适用场景 |
|---|---|---|
a = b |
共享引用 | 明确需要共享同一状态 |
a.copy(b) |
保留 a,复制分量 | 循环内复用对象 |
a = b.clone() |
新对象复制 | 转移为独立所有权 |
向量 API 的价值之一,就是把引用语义显式写进方法名。
九、可变和不可变 API 混用时的阅读规则
下面两段结果相同,分配行为不同:
// 不可变风格:创建中间对象
const next = position
.add(direction.multiply(speed * dt));
// 高频可变风格:手工更新
position.x += direction.x * speed * dt;
position.y += direction.y * speed * dt;
项目在 Tank 更新中选择手工数值计算,并注释说明避免 Vector2 分配。粒子也复用 position、velocity 字段。路径构建和低频配置则可以使用新对象,提高可读性。
规则可以是:
- 默认使用返回新对象的方法,减少意外共享;
- 已确认的每帧热点使用 InPlace 或直接数值更新;
- 不在同一表达式混合会修改和不修改的方法;
- 性能优化前后用帧时间/分配数据验证。
十、网格坐标与世界坐标的类型风险
A* 使用 Vector2 保存整数格坐标,坦克位置则是像素世界坐标:
const startNode = new Vector2(
Math.floor(position.x / CELL_SIZE),
Math.floor(position.y / CELL_SIZE)
);
路径节点重新转世界中心:
const targetX = node.x * CELL_SIZE + CELL_SIZE / 2;
const targetY = node.y * CELL_SIZE + CELL_SIZE / 2;
两者类型相同,漏转换编译器不会发现。可使用接口区分:
interface GridPoint {
col: number;
row: number;
}
interface WorldPoint {
x: number;
y: number;
}
或者为转换建立唯一函数 gridToWorldCenter()、worldToGrid(),减少散落公式。
十一、旋转单位也需要类型契约
Tank.rotation 使用弧度,Canvas rotate() 也接收弧度;ArkUI .rotate({ angle }) 常以角度表达。项目中 TankShape 写 -15,Canvas 射击散射写 0.26(约 15°)。
如果把 ArkUI 的 -15 直接传给 Canvas,它不是 -15°,而是 -15 弧度。建议命名:
function degreesToRadians(degrees: number): number {
return degrees * Math.PI / 180;
}
const SHOTGUN_SPREAD_RADIANS =
degreesToRadians(15);
单位进入名称比写一条注释更可靠。
十二、输入向量的范围和校验
摇杆输出理论范围在单位圆内:
new Vector2(
dx / maxDistance,
dy / maxDistance
);
Tank.update 又计算长度并规范移动步长,所以斜向移动不会比横向快。但 GameEngine 的公共 setInputVector(x,y) 没有钳制外部值。网络、键盘或测试代码若传入巨大数值,长度归一化后方向仍正常;传 NaN 则会污染整个位置。
系统边界应校验:
setInputVector(x: number, y: number): void {
if (!Number.isFinite(x) || !Number.isFinite(y)) {
this.inputVectorP1.set(0, 0);
return;
}
this.inputVectorP1.set(x, y);
}
十三、建议单元测试 🧪
it('computes scalar dot product', 0, () => {
const a = new Vector2(2, 3);
const b = new Vector2(4, 5);
expect(a.dot(b)).assertEqual(23);
});
it('normalizes zero safely', 0, () => {
const result = new Vector2(0, 0).normalize();
expect(result.x).assertEqual(0);
expect(result.y).assertEqual(0);
});
当前 dot 测试会因返回对象而失败,这正是测试应暴露的问题。还应覆盖:
| 方法 | 关键断言 |
|---|---|
| add | 原对象不变,结果正确 |
| addInPlace | 原对象改变,返回 this |
| clone | 分量相等、引用不同 |
| copy | 目标改变、引用保持 |
| length | (3,4) 返回 5 |
| distance | 同点为 0,对称性成立 |
| normalize | 非零结果长度约等于 1 |
| 非有限输入 | 按契约拒绝或安全回退 |
| grid/world 转换 | 往返落在预期格子 |
浮点断言应使用容差,而不是要求所有小数严格相等。
十四、迁移 dot API 时如何避免破坏
虽然当前没有调用点,仍建议按以下步骤:
- 全局搜索
.dot(,确认没有隐藏消费者; - 为当前分量乘法增加
multiplyComponents(); - 把
dot()返回类型改为 number; - 添加数学契约测试;
- 重新编译所有 ArkTS 模块;
- 后续代码审查禁止把向量点积当向量使用。
如果库已经被外部模块使用,应该先标记旧方法弃用并提供迁移期;当前项目内部未使用,修正成本很低。
十五、总结 ✨
Vector2 是游戏项目中典型的“小类、大影响面”。当前实现已经提供新对象与 InPlace 两种操作、零向量归一化保护、clone/copy 所有权工具,也在 AI 和传送门中正确利用原地复用与克隆。
最重要的缺口是 dot() 名称、返回类型和数学语义不一致:它实现的是分量乘积,而不是标量点积。除此之外,还应补充平方距离、非有限数防护、单位命名以及网格/世界坐标边界。数学工具的目标不是方法越多越好,而是每个方法都有不可误解的契约、明确的副作用和覆盖边界的测试。基础向量可靠,碰撞、AI、摄像机和输入才能建立在稳固坐标系上。🚀
推荐标签: OpenHarmony HarmonyOS ArkTS 游戏数学 Vector2 类型安全 Canvas 单元测试

更多推荐



所有评论(0)