游戏中的数学 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、米制长度、平方长度和“单位向量要么长度为一、要么构造失败”的不变量。下一章会用点积回答前后关系,用叉积回答左右关系,再把任意向量拆成投影与拒绝分量。