本文属于系列「游戏中的数学:从坐标到可同步运动」第 2 / 15 篇

游戏中的数学 02:一个位置为什么需要向量

从位移开始

第一章已经把长度统一为米。现在移动体从原点依次走 (3,0)(0,4)(-1,-1):位置是世界中的点,步骤是两个点之差,路程是每段长度之和,净位移才是三段向量之和。它们都能写成两个数,但拥有者和运算规则不同。

// $WORK_DIR/.tmp/game-math-lab/Program.cs · Chapter02
Vector2[] steps = [
    new(3, 0),
    new(0, 4),
    new(-1, -1)
];

var points = new List<P> { new(0, 0) };
foreach (var s in steps)
    points.Add(new(points[^1].X + s.X, points[^1].Y + s.Y));

三段位移的首尾相接构造

移动系统拥有当前位置,输入或寻路系统提交位移;数学层不保存任何对象状态,只定义加减、长度和归一化。这个边界允许服务端、Unity 或 Godot 复用同一组固定输入,而不把 Transform、Node 或网络实体塞进基础类型。

四种语义

点加位移得到点,点减点得到位移;位移可以累加,位置相加通常没有玩法语义。System.Numerics.Vector2 不会替我们区分这些概念,因此变量名、接口参数和测试必须守住边界。

// Program.cs · Chapter02 reductions
var totalDistance = steps.Sum(v => v.Length());

var net = steps.Aggregate(
    Vector2.Zero,
    (accumulator, step) => accumulator + step);

var finalPosition = Vector2.Zero + net;
var netDistance = net.Length();

固定输入得到终点 (2,3) 米,但总路程 8.414213 米,净距离只有 3.605551 米。把路程写成 finalPosition.Length() 会把折返路径抹掉;把位移长度逐段相加后再当方向,也会把单位从米悄悄改成无意义的标量。

正常时序

正常路径是“输入步骤 → 累加位置 → 分别归约路程和净位移 → 写出数据”。终点断言不依赖渲染帧,也不读取引擎对象;固定步骤因此可以成为后续确定性运动的最小证据。

// Program.cs · Chapter02 normal check and output
Check(
    Vector2.Distance(net, new(2, 3)) < 1e-6,
    "vector addition preserves displacement");

WriteData(2, new {
    final = V(net),
    total_distance = totalDistance,
    net_distance = net.Length(),
    zero_normalize = false
});

Done(2,
    $"final=({net.X:F3},{net.Y:F3})m " +
    $"path={totalDistance:F6}m net={net.Length():F6}m");

零向量失败

方向需要单位长度,但零位移没有方向。直接调用归一化会让无效值继续进入朝向、点积或矩阵;本工程把它设计成 Try 分支,调用者必须选择保持旧朝向、停止移动或报告输入错误。

// Program.cs · TryNormalize
static bool TryNormalize(Vector2 value, out Vector2 unit)
{
    if (value.LengthSquared() < 1e-12)
    {
        unit = default;
        return false;
    }

    unit = Vector2.Normalize(value);
    return true;
}

Check(
    TryNormalize(Vector2.Zero, out _) == false,
    "zero vector normalization fails explicitly");

这里没有给零向量偷偷指定 (1,0)。默认方向会制造一次不存在的玩家输入,而且不同系统可能选择不同默认值。显式失败多一个分支,却换来跨端一致的状态转换:没有位移,就没有新方向。

长度对照

平方长度省去开方,适合距离排序和阈值比较;真实距离保留米。两者数值不能混用:长度为 4 米的向量,平方长度是 16 平方米。实验图同时画三段长度与平方长度,提醒调用点必须在变量名中保留单位。

{
  "final": { "x": 2, "y": 3 },
  "total_distance": 8.414213,
  "net_distance": 3.6055512,
  "zero_normalize": false
}
point    x_m  y_m
start      0    0
after_1    3    0
after_2    3    4
after_3    2    3

segment  dx  dy  length_m  length_squared_m2
1         3   0  3.000000  9.000000
2         0   4  4.000000  16.000000
3        -1  -1  1.414214  2.000000
sum/path         8.414213
net       2   3  3.605551  13.000000
path_minus_net   4.808662
path_ge_net      true
zero_normalize   false

三段位移的长度与平方长度对照

当天运行

同一进程执行全部章节,本章固定终点断言与零向量失败断言均通过。输出把米直接写进字段和日志,避免后续复制数据时失去尺度。

cd "$WORK_DIR"
dotnet run --project .tmp/game-math-lab/GameMathLab.csproj \
  -- --out .tmp/game-math-lab/out
CH02 PASS final=(2.000,3.000)m
  path=8.414213m net=3.605551m
TOTAL pass=15 fail=0 skip=0 chapters=15 elapsed_ms=112.8
EXIT code=0

取舍与接口

本章复用运行时自带 Vector2,没有为点、位移、速度各造包装类型;优点是代码短、与引擎桥接直接,代价是语义只能由接口和测试约束。当坐标跨越协议、编辑器和多语言边界,误用已经无法靠评审控制时,再引入强类型。

交给下一章的是 Vector2、米制长度、平方长度和“单位向量要么长度为一、要么构造失败”的不变量。下一章会用点积回答前后关系,用叉积回答左右关系,再把任意向量拆成投影与拒绝分量。