【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 静态或低频坐标

同一个类同时承载连续世界坐标和离散网格坐标,使用方便,但类型系统无法阻止把“格坐标”直接当成“像素坐标”。更大型项目可以引入语义包装或命名约定,如 GridPointWorldPosition

二、当前 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)

需要保持对称性:如果常用 subtractInPlacenormalizeInPlace,可以补充;不要为了 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 分配。粒子也复用 positionvelocity 字段。路径构建和低频配置则可以使用新对象,提高可读性。

规则可以是:

  • 默认使用返回新对象的方法,减少意外共享;
  • 已确认的每帧热点使用 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 时如何避免破坏

虽然当前没有调用点,仍建议按以下步骤:

  1. 全局搜索 .dot(,确认没有隐藏消费者;
  2. 为当前分量乘法增加 multiplyComponents()
  3. dot() 返回类型改为 number;
  4. 添加数学契约测试;
  5. 重新编译所有 ArkTS 模块;
  6. 后续代码审查禁止把向量点积当向量使用。

如果库已经被外部模块使用,应该先标记旧方法弃用并提供迁移期;当前项目内部未使用,修正成本很低。

十五、总结 ✨

Vector2 是游戏项目中典型的“小类、大影响面”。当前实现已经提供新对象与 InPlace 两种操作、零向量归一化保护、clone/copy 所有权工具,也在 AI 和传送门中正确利用原地复用与克隆。

最重要的缺口是 dot() 名称、返回类型和数学语义不一致:它实现的是分量乘积,而不是标量点积。除此之外,还应补充平方距离、非有限数防护、单位命名以及网格/世界坐标边界。数学工具的目标不是方法越多越好,而是每个方法都有不可误解的契约、明确的副作用和覆盖边界的测试。基础向量可靠,碰撞、AI、摄像机和输入才能建立在稳固坐标系上。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS 游戏数学 Vector2 类型安全 Canvas 单元测试

img

Logo

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

更多推荐