游戏中的数学 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=148.7
EXIT code=0
类型选择
实现复用运行时提供的 Vector2,不为点、位移和速度分别增加包装类型;该选择保持代码与引擎桥接简洁,代价是语义必须由接口和测试约束。当坐标跨越协议、编辑器和多语言边界,且误用无法仅由评审控制时,再引入强类型。
第三章的点积和叉积仅使用 Vector2、米制长度、平方长度,以及“单位向量要么长度为一、要么构造失败”的不变量;这些运算用于判定前后与左右,并将任意向量分解为投影和拒绝分量。