# 朝向不等於位置：遊戲裡的向量、內積、叉積與座標系

> 遊戲裡判斷目標在前方還是側面、往哪邊轉、撞牆後怎麼滑，靠的是內積、2D 叉積與座標轉換。用一艘虛構採礦船把每個量代表什麼講清楚，附 Python 實跑結果。

原文：https://sheng.page/posts/game-math-vectors-and-coordinate-spaces/ · 發布：2026-10-08 · 作者：Sheng

飛船要決定開不開火、往哪邊轉，得先知道目標在不在前方、在左還是右、有沒有超過射程。這幾件事各對應一個向量運算，內積給角度，2D 叉積給左右，長度給距離。再加上座標轉換，砲口才會跟著船身一起轉；撞牆時把速度拆成兩個方向，船才會沿著牆滑過去。

公式本身不難，容易弄混的是每個量代表什麼。位置、位移、方向和速度在程式裡都是兩個浮點數，意思卻完全不同。「用數學描述遊戲世界」系列共用一個虛構的太空採礦遊戲，第一篇就從這艘船的空間運算開始。

## 四種長得一樣的量

在 2D 遊戲裡，下面四個東西都是 `(x, y)`：

- **位置**：某個點在世界裡的座標，例如船在 `(10, 5)`。
- **位移**：從一點到另一點的差，`目標位置 - 船的位置`，帶方向也帶距離。
- **方向**：長度為 1 的向量，只說「往哪邊」，不說多遠。
- **速度**：每秒的位移，長度是速率。

這個區分在程式裡沒有型別幫你擋。位置加位置沒有意義，位置減位置得到位移，位移除以自己的長度得到方向，混著用不會報錯，船卻可能往奇怪的地方飛。後面做座標轉換時，位置和方向也得分開處理，轉成世界座標用的式子不同。

## 內積：目標在不在視野內

兩個向量的內積 `a·b = ax*bx + ay*by`，等於兩者長度相乘再乘上夾角的 cos。當兩邊都是長度為 1 的方向，內積就是夾角的 cos：1 是正前方，0 是正側面，-1 是正後方。

所以視野判定不需要算角度。船的朝向 `forward` 和「船指向目標的方向」做內積，跟視野半角的 cos 比較就好。Godot 的向量教學用殭屍看玩家當例子，判斷式就是內積大於 0（夾角小於 90°）。cos 可以先算好存著，迴圈裡只剩乘法和加法。

內積的另一個用法是投影。把一個向量和某個方向做內積，得到它在那個方向上的分量。接近速度就是這樣算的：兩船的相對速度投影到連線方向上，正值代表距離在縮短，負值代表在拉開。

## 2D 叉積：往左轉還是往右轉

3D 的叉積會得到一個垂直於兩者的向量；在 2D 只取它的 z 分量，`a×b = ax*by - ay*bx`。正負號告訴你 b 在 a 的哪一邊：在 y 軸朝上的座標系裡，正值是 b 在 a 的左邊（逆時針），負值是右邊。

決定轉向時，目標在左就逆時針轉，在右就順時針轉；叉積剛好是 0，代表目標在正前方或正後方，要另外處理，不然浮點誤差會讓船在兩個方向之間來回抖。螢幕座標常常 y 軸朝下，左右也會跟著顛倒，同一個專案裡要先講好用哪一種。

## 局部座標與世界座標

砲口裝在船頭右前方，在船自己的座標系裡是 `(4, 1)`，砲管朝向是 `(1, 0)`。船轉了 90° 又移到 `(10, 5)` 之後，要知道砲口在世界裡的哪裡、子彈往哪飛。

轉換點的座標，要先旋轉再加上船的位置；方向只旋轉，不加位置，因為它沒有「在哪裡」。Unity 把這兩件事分成兩個 API：`TransformPoint` 處理位置，`TransformDirection` 處理方向，文件寫明後者不受 transform 的位置與縮放影響，回傳的向量長度不變。把方向也當成點來轉，算出的朝向就會有錯。

## 撞牆後沿牆滑動

船以 `(20, 0)` 撞上一面 45° 的牆，牆的法向量是 `n`（長度 1，指向牆外）。如果撞到就把速度歸零，玩家會覺得船被黏住。比較好的做法是只拿掉往牆裡面的那一份：

```text
v_into  = (v·n) n
v_slide = v - v_into
```

`v·n` 是負的代表正在往牆裡衝，減掉這一份之後剩下沿著牆面的分量。反彈則是減兩次，`v - 2(v·n)n`，Godot 的 `bounce()` 做的就是這件事。

## 零向量不能正規化

正規化是除以長度，長度是 0 就沒有方向可言。目標和船在同一點、搖桿沒推、兩個物件剛好重疊，都會得到零向量。Godot 文件特別寫了 GDScript 的 `normalized()` 遇到零向量會原樣回傳，不會報錯；其他引擎或自己寫的版本可能回傳 NaN，之後所有跟它運算的值都會變成 NaN。比較穩的做法是讓呼叫端決定「沒有方向」時該怎麼辦，例如保持原本的朝向。

## 範例

把這些運算放進同一個小程式，船在原點，朝向 +x，視野 60°，射程 100。下方程式與輸出使用 Python 3.13.16：

```python
import math
import sys
from dataclasses import dataclass


@dataclass(frozen=True, slots=True)
class V2:
    x: float
    y: float

    def __add__(self, o): return V2(self.x + o.x, self.y + o.y)
    def __sub__(self, o): return V2(self.x - o.x, self.y - o.y)
    def __mul__(self, k): return V2(self.x * k, self.y * k)
    def dot(self, o): return self.x * o.x + self.y * o.y
    def cross(self, o): return self.x * o.y - self.y * o.x   # 2D: z component only
    def length(self): return math.hypot(self.x, self.y)

    def normalized(self, eps=1e-9):
        n = self.length()
        if n < eps:
            return None        # caller decides what "no direction" means
        return V2(self.x / n, self.y / n)

    def __repr__(self): return f"({self.x:.2f}, {self.y:.2f})"


def heading(deg):
    r = math.radians(deg)
    return V2(math.cos(r), math.sin(r))


# The ship sits at the origin, nose pointing along +x (heading 0 deg).
ship_pos, ship_fwd = V2(0, 0), heading(0)
FOV_HALF = 30                      # 60 deg cone
RANGE = 100
cos_half = math.cos(math.radians(FOV_HALF))

print("== 1. in front, to the side, or out of range ==")
for name, target in [("A", V2(80, 20)), ("B", V2(40, 60)),
                     ("C", V2(-50, 5)), ("D", V2(150, 0))]:
    to_t = target - ship_pos
    dist = to_t.length()
    d = to_t.normalized()
    forward = ship_fwd.dot(d)                # cos of the angle
    c = ship_fwd.cross(d)
    side = "left" if c > 1e-6 else "right" if c < -1e-6 else "none"
    in_cone = forward >= cos_half and dist <= RANGE
    print(f"{name} {target}: dist={dist:6.1f} cos={forward:+.3f} "
          f"angle={math.degrees(math.acos(forward)):5.1f} turn={side:5} in_cone={in_cone}")

print("\n== 2. closing speed: project relative velocity onto line of sight ==")
ship_vel = V2(30, 0)
for name, pos, vel in [("rock", V2(100, 0), V2(-10, 0)),
                       ("drone", V2(100, 0), V2(0, 40)),
                       ("probe", V2(100, 0), V2(35, 0))]:
    los = (pos - ship_pos).normalized()
    closing = (ship_vel - vel).dot(los)      # >0 means the gap is shrinking
    print(f"{name:5}: closing speed {closing:+6.1f} m/s")

print("\n== 3. local to world: a point and a direction transform differently ==")
def to_world_point(local, pos, angle_deg):
    c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
    return V2(local.x * c - local.y * s, local.x * s + local.y * c) + pos

def to_world_dir(local, angle_deg):
    c, s = math.cos(math.radians(angle_deg)), math.sin(math.radians(angle_deg))
    return V2(local.x * c - local.y * s, local.x * s + local.y * c)  # no translation

muzzle_local = V2(4, 1)            # gun barrel, in ship coordinates
muzzle_dir_local = V2(1, 0)
for pos, ang in [(V2(0, 0), 0), (V2(10, 5), 90), (V2(10, 5), 135)]:
    print(f"ship at {pos} heading {ang:3d}: muzzle point {to_world_point(muzzle_local, pos, ang)}"
          f"  muzzle dir {to_world_dir(muzzle_dir_local, ang)}"
          f"  wrong dir (translated) {to_world_point(muzzle_dir_local, pos, ang)}")

print("\n== 4. hitting a wall: keep the tangential part ==")
wall_normal = V2(-1, 1).normalized()          # a 45 deg wall
v = V2(20, 0)
into = v.dot(wall_normal)
slide = v - wall_normal * into if into < 0 else v
print(f"v={v} n={wall_normal} v.n={into:.2f} -> slide={slide} |slide|={slide.length():.2f}")

print("\n== 5. zero vector ==")
print("normalized((0,0)) ->", V2(0, 0).normalized())
print(f"Python {sys.version.split()[0]}")
```

```text
== 1. in front, to the side, or out of range ==
A (80.00, 20.00): dist=  82.5 cos=+0.970 angle= 14.0 turn=left  in_cone=True
B (40.00, 60.00): dist=  72.1 cos=+0.555 angle= 56.3 turn=left  in_cone=False
C (-50.00, 5.00): dist=  50.2 cos=-0.995 angle=174.3 turn=left  in_cone=False
D (150.00, 0.00): dist= 150.0 cos=+1.000 angle=  0.0 turn=none  in_cone=False

== 2. closing speed: project relative velocity onto line of sight ==
rock : closing speed  +40.0 m/s
drone: closing speed  +30.0 m/s
probe: closing speed   -5.0 m/s

== 3. local to world: a point and a direction transform differently ==
ship at (0.00, 0.00) heading   0: muzzle point (4.00, 1.00)  muzzle dir (1.00, 0.00)  wrong dir (translated) (1.00, 0.00)
ship at (10.00, 5.00) heading  90: muzzle point (9.00, 9.00)  muzzle dir (0.00, 1.00)  wrong dir (translated) (10.00, 6.00)
ship at (10.00, 5.00) heading 135: muzzle point (6.46, 7.12)  muzzle dir (-0.71, 0.71)  wrong dir (translated) (9.29, 5.71)

== 4. hitting a wall: keep the tangential part ==
v=(20.00, 0.00) n=(-0.71, 0.71) v.n=-14.14 -> slide=(10.00, 10.00) |slide|=14.14

== 5. zero vector ==
normalized((0,0)) -> None
Python 3.13.16
```

B 在射程內，但夾角 56.3° 超過 30° 的半角，不在視野裡；D 在正前方，距離 150 卻超過射程。視野判定要同時看角度和距離，兩個條件各自來自不同的運算。D 也剛好示範了轉向判斷的邊界：叉積是 0，結果是 `none`，如果只寫 `> 0` 判斷左右，正前方的目標就會算到右邊，讓船多轉一下。

接近速度那一段，drone 的速度完全垂直於連線，對接近速度沒有貢獻，算出來的 30 m/s 全部來自自己的船速。probe 跑得比船快，接近速度是負的，距離在拉開。

座標轉換則要留意第一組結果：船在原點、朝向 0° 時，把方向當成點來轉也會得到正確答案，因為加上的位置剛好是 0。只在原點測試會漏掉這個錯誤，把船移開才看得到。

撞牆後速率從 20 剩 14.14，方向沿著牆面。拿掉的是法向分量，長度也是 14.14；兩個分量互相垂直，要用畢氏定理算：√(20² - 14.14²) ≈ 14.14。至於這些位置與速度如何隨時間更新，下一篇〈[同一艘船在 30 FPS 和 144 FPS 開起來不一樣：積分方法與固定步長](/posts/frame-rate-and-game-physics-integration/)〉會接著處理。

## 補充筆記

- 位置減位置得到位移，位移除以長度得到方向，速度則表示每秒的位移。這些量在程式裡長得一樣，用途仍要分清楚；轉換時，位置要旋轉再平移，方向只旋轉，只在原點測試會漏掉錯誤。
- 視野判定可以直接比較兩個單位向量的內積與預先算好的 cos，因為內積就是夾角的 cos，不必再算角度。投影到某個方向也用內積。
- 2D 叉積的正負號給左右，等於 0 時要另外處理，螢幕座標 y 軸朝下時左右會顛倒。
- 撞牆後要沿牆滑動，就減掉速度在法向量上的分量；要反彈則減兩次。
- 正規化前先檢查零向量，它沒有方向，後續怎麼處理由呼叫端決定。

## 延伸閱讀

- Godot Engine〈[Vector math](https://docs.godotengine.org/en/stable/tutorials/math/vector_math.html)〉：內積做視野判定、零向量不能正規化、`bounce()` 的反射公式，官方教學文件。
- Unity〈[Transform.TransformDirection](https://docs.unity3d.com/ScriptReference/Transform.TransformDirection.html)〉：方向轉換不受位置與縮放影響，與 `TransformPoint` 的分工，官方 API 文件。
- Wikipedia〈[Dot product](https://en.wikipedia.org/wiki/Dot_product)〉與〈[Cross product](https://en.wikipedia.org/wiki/Cross_product)〉：定義與幾何意義，百科條目，適合查公式，不作為引擎行為的依據。
- Adams、Dormans《Game Mechanics: Advanced Game Design》第 1 章：物理機制與經濟機制、連續與離散機制的區分，遊戲設計教科書，這個系列後面幾篇也會用到。

> 想法與技術判斷出自 Sheng，和 Claude 一起起草 · 範例在 Python 3.13.16 實測。