---
url: /column-hexazen/02-hex-grid-and-soundscape.md
description: 如何讓六角積木組合出千變萬化的自然音效？讓我們從座標系統開始聊起。✧⁑｡٩(ˊᗜˋ*)و✧⁕｡
---

![hexazen-cover](/hexazen-cover.webp){.cover}

# HexaZen - EP02：六角格與聲景系統

上一章讓我們可以建立積木，但只有積木沒辦法拚在一起

這次要來實作六角格座標系統，讓積木可以排列在六角網格上。

同時基於積木特性建立聲景（Soundscape）系統，讓不同的積木組合自動產生對應的自然音效！%◝( •ω• )◟%

## 六角格座標系統

六角格看似簡單，但座標計算其實有不少眉角。

### 三種座標系統

六角格常見的座標系統有 Offset、Axial、Cube 三種，各有優缺點。

#### Offset 座標

最直覺的方式：用 `(col, row)` 對應 2D 陣列索引。

看起來很簡單，但問題馬上出現，奇數行和偶數行的鄰居偏移不同：

```typescript
// Offset 座標找鄰居：需要判斷奇偶行 😵
function getNeighbors(col: number, row: number) {
  const isEvenRow = row % 2 === 0
  if (isEvenRow) {
    return [
      [col, row - 1],
      [col + 1, row - 1], // 上方兩格
      [col - 1, row],
      [col + 1, row], // 左右
      [col, row + 1],
      [col + 1, row + 1], // 下方兩格
    ]
  }
  else {
    return [
      [col - 1, row - 1],
      [col, row - 1], // 上方兩格
      [col - 1, row],
      [col + 1, row], // 左右
      [col - 1, row + 1],
      [col, row + 1], // 下方兩格
    ]
  }
}
```

距離計算也得分奇偶處理，旋轉更是噩夢。%(›´ω\`‹ )%

#### Axial 座標

把六角格攤在兩條斜軸 `(q, r)` 上：

鄰居偏移變得一致了，不用再判斷奇偶：

```typescript
// Axial 座標的六方向：固定偏移量，不分奇偶
const axialDirections = [
  [+1, 0],
  [+1, -1],
  [0, -1],
  [-1, 0],
  [-1, +1],
  [0, +1],
]
```

但距離計算仍然需要推導第三個隱含的座標：

```typescript
// Axial 距離：需要推算出 s
function axialDistance(a: [number, number], b: [number, number]) {
  const dq = a[0] - b[0]
  const dr = a[1] - b[1]
  const ds = -dq - dr // 隱含的第三軸
  return Math.max(Math.abs(dq), Math.abs(dr), Math.abs(ds))
}
```

#### Cube 座標

三軸 `(q, r, s)` 並且 **q + r + s = 0 恆成立**，這個約束讓所有運算都變得對稱且優雅。

對比三者的操作複雜度：

| 操作     | Offset     | Axial      | Cube                          |
| -------- | ---------- | ---------- | ----------------------------- |
| 找鄰居   | 判斷奇偶行 | 查表       | 向量加法                      |
| 算距離   | 分段公式   | 推導第三軸 | `max(\|dq\|, \|dr\|, \|ds\|)` |
| 旋轉     | 非常複雜   | 需要推導   | 排列 + 取反                   |
| 對稱性   | 無         | 部分       | 三軸完全對稱                  |
| 儲存空間 | 2 個值     | 2 個值     | 2 個值（s 可推算）            |

Cube 座標的距離就是向量在三軸上的最大分量：

```typescript
// Cube 距離：一行搞定
function cubeDistance(a: Hex, b: Hex) {
  return Math.max(
    Math.abs(a.q - b.q),
    Math.abs(a.r - b.r),
    Math.abs(a.s - b.s),
  )
}
```

旋轉 60° 也只需要排列三個值再取反：

```typescript
// Cube 旋轉：排列 + 取反，優雅到不行
function rotateRight(hex: Hex): Hex {
  return new Hex(-hex.r, -hex.s, -hex.q)
}
function rotateLeft(hex: Hex): Hex {
  return new Hex(-hex.s, -hex.q, -hex.r)
}
```

雖然儲存三個值看似冗餘，但 `s = -q - r` 隨時可以推算，實際只需要兩個值。

這種數學上的對稱性對後面的 BFS 連通偵測和座標轉換大有幫助。%( •̀ ω •́ )✧%

### Hex 類別

讓我們建立核心的 `Hex` 類別。

`content\aquarium\hexazen\domains\hex-grid\index.ts`

```typescript
export class Hex {
  public q: number
  public r: number
  public s: number

  constructor(q: number, r: number, s: number) {
    // 用 epsilon 容忍浮點誤差
    const sum = q + r + s
    if (Math.abs(sum) > 1e-6) {
      throw new Error('Hex invariant violated: q + r + s must be 0')
    }
    this.q = q
    this.r = r
    this.s = s
  }

  key() {
    return `${this.q},${this.r},${this.s}`
  }

  static fromAxial(q: number, r: number): Hex {
    return new Hex(q, r, -q - r)
  }

  add(b: Hex): Hex {
    return new Hex(this.q + b.q, this.r + b.r, this.s + b.s)
  }

  subtract(b: Hex): Hex {
    return new Hex(this.q - b.q, this.r - b.r, this.s - b.s)
  }
}
```

建構子中直接驗證 `q + r + s === 0`，如果違反就直接拋錯。

`fromAxial` 則是便利方法，只需要給 `q` 和 `r`，`s` 會自動推算。

### 找鄰居

六角格每格有 6 個共用邊的鄰居，定義方向向量後，找鄰居就是一次向量加法。

```typescript
// 六方向（cube）
static readonly directions: ReadonlyArray<Hex> = Object.freeze([
  new Hex(1, 0, -1),
  new Hex(1, -1, 0),
  new Hex(0, -1, 1),
  new Hex(-1, 0, 1),
  new Hex(-1, 1, 0),
  new Hex(0, 1, -1),
])

/** 取得共用邊的鄰居 */
neighbor(direction: number): Hex {
  return this.add(Hex.direction(direction))
}
```

另外還有 6 個對角鄰居（共用頂點、距離為 2），之後判斷蛙鳴等音效時會用到。

### 世界座標轉換

有了 Hex 座標後，需要 `HexLayout` 把 Hex 座標轉成 Babylon.js 的世界座標。

```typescript
export class HexLayout {
  constructor(
    public orientation: Orientation,
    public size: number,
    public origin: Vector3 = Vector3.Zero(),
  ) {}

  /** Hex -> 世界座標（X,Z） */
  hexToWorld(hex: Hex, yOverride?: number): Vector3 {
    const M = this.orientation
    const x = (M.f0 * hex.q + M.f1 * hex.r) * this.size + this.origin.x
    const z = (M.f2 * hex.q + M.f3 * hex.r) * this.size + this.origin.z
    const y = yOverride ?? this.origin.y
    return new Vector3(x, y, z)
  }

  /** 世界座標 -> 最近格 Hex（已 round） */
  worldToHexRounded(world: Vector3): Hex {
    return this.worldToHexFractional(world).round()
  }
}
```

這裡使用 pointy-topped（上下頂點朝上）的六角形方向，透過矩陣變換在 Hex 座標和世界座標之間轉換。

`worldToHexRounded` 把滑鼠點擊的世界座標轉回最近的 Hex 格，放置積木時非常有用。

::: tip
六角格的數學參考了 Red Blob Games 的 [Hexagonal Grids](https://www.redblobgames.com/grids/hexagons/) 文章

文章中有許多即時互動的範例，強力推薦！%ദ്ദി ˉ͈̀꒳ˉ͈́ )%
:::

## 積木與特性

有了六角格，接著需要定義各種積木與它們的「特性（Trait）」。

### 特性系統

```typescript
enum TraitTypeEnum {
  GRASS,
  TREE,
  CAMPFIRE,
  BUILDING,
  ALPINE,
  RIVER,
  WATER,
  SAND,
}
```

每個積木可以擁有一至多個特性，例如：

* 草地（g1～g6）：`grass`
* 樹木（t1～t3）：`tree`
* 港口（b6）：`building` + `water`
* 水車（r5）：`river` + `building`
* 山中小屋（b7）：`building` + `alpine`

這種多特性設計讓積木可以同時參與不同音效的判斷，例如港口既能觸發市集音效，也能貢獻海浪音效。%◝(≧∀≦)◟%

### 區域規模

光是計算單個積木不夠，還需要知道「有多少同類積木連在一起」，也就是「區域規模」。

這就需要 BFS（廣度優先搜尋）來找出連通區域。

`content\aquarium\hexazen\domains\block\trait-region\index.ts`

```typescript
export function calcTraitRegionList(
  blocks: Map<string, BlockSnapshot> | BlockSnapshot[],
): TraitRegion[] {
  const regionList: TraitRegion[] = []
  const traitToKeys = new Map<TraitType, Set<string>>()

  // 先將每個 block 的 trait 分類
  for (const [key, block] of blockMap) {
    for (const trait of blockDefinitions[block.type].traitList) {
      if (!traitToKeys.has(trait)) {
        traitToKeys.set(trait, new Set())
      }
      traitToKeys.get(trait)!.add(key)
    }
  }

  // 對每個 trait 獨立做 BFS 連通分量分析
  for (const [trait, keySet] of traitToKeys) {
    const unvisited = new Set(keySet)

    while (unvisited.size > 0) {
      const startKey = unvisited.values().next().value as string
      const startBlock = blockMap.get(startKey)!
      unvisited.delete(startKey)

      const hexMap = new Map<string, Hex>([[startKey, startBlock.hex]])
      const queue: Hex[] = [startBlock.hex]
      let head = 0

      while (head < queue.length) {
        const current = queue[head]!
        head++

        for (let direction = 0; direction < 6; direction++) {
          const neighbor = current.neighbor(direction)
          const neighborKey = neighbor.key()

          if (!unvisited.has(neighborKey))
            continue

          const neighborBlock = blockMap.get(neighborKey)!
          unvisited.delete(neighborKey)
          hexMap.set(neighborKey, neighborBlock.hex)
          queue.push(neighborBlock.hex)
        }
      }

      regionList.push({ trait, hexMap, size: hexMap.size })
    }
  }

  return regionList
}
```

幾個值得注意的細節：

* 使用 `head` 游標取代 `shift()`，避免破壞陣列記憶體連續性
* 以 `Map`（O(1)）取代 `Array`（O(n)）來查找鄰格
* 一個 block 若有多個 trait，會分別參與不同 trait 的連通分析

## 聲景系統

終於來到最有趣的部分了！%੭ ˙ᗜ˙ )੭%

### 規則驅動的音效生成

聲景系統的核心是一組「規則（Rule）」，每個規則包含：

* `predicate`：判斷是否觸發（依據積木組合與天氣）
* `transform`：產生對應的音效

```typescript
interface SoundscapeRule {
  type: SoundscapeType;
  predicate: (data: {
    traitRegionList: TraitRegion[];
    blockMap: Map<string, Block>;
    weather?: Weather;
  }) => boolean;
  transform: (soundscapeList: Soundscape[]) => Soundscape[];
}
```

### 生態系規則

這些規則設計出了一套小型生態系統，積木數量越多，音效越豐富：

| 條件             | 音效     | 說明             |
| ---------------- | -------- | ---------------- |
| 樹木 ≥ 3         | 風吹樹梢 | 林間基本的沙沙聲 |
| 草地 ≥ 4         | 蟲鳴     | 蟋蟀與蟈蟈的合唱 |
| 樹木 ≥ 6         | 鳥叫     | 黑鶇、交喙的歌聲 |
| 樹木 ≥ 10        | 獸鳴     | 鼠兔的叫聲       |
| 河流 ≥ 2         | 流水聲   | 潺潺水流         |
| 水域 ≥ 15        | 海浪     | 壯闊的海洋聲     |
| 水域 ≥ 15 + 有岸 | 潮汐     | 海浪拍打岸邊     |
| 高山 ≥ 10        | 高山風   | 凜冽的山風       |
| 建築 ≥ 5         | 市集     | 熙攘的人聲       |
| 有營火           | 營火     | 柴火劈啪聲       |

例如「風吹樹梢」的規則：

```typescript
{
  type: 'rustle',
  predicate: ({ traitRegionList, weather }) => {
    if (weather === 'rain') {
      return false
    }
    return traitRegionList.some(
      (region) => region.trait === 'tree' && region.size >= 3,
    )
  },
  transform: concat([{
    id: getId(),
    title: 'The Whisper of the Treetops',
    type: 'rustle',
    mode: { value: 'loop' },
    soundList: [{
      src: 'hexazen/sounds/rustle-tree.mp3',
      volume: 0.3,
    }],
  }]),
},
```

### 天氣影響

下雨時大部分自然聲音會受抑制，換成各種雨聲：

* 草地的雨聲（基礎）
* 森林的雨聲（樹木 ≥ 2）
* 屋頂的雨聲（建築 ≥ 5）
* 雷聲（隨機間隔 10～30 秒）

這讓同一個積木配置在晴天和雨天會有完全不同的聽覺體驗。%(\*´∀\`)~♥%

### 特殊規則

蛙鳴的觸發條件比較特別：需要一個水域格，且它的 6 個鄰格中至少有 5 個是非水域積木。

```typescript
predicate({ traitRegionList, blockMap }) {
  const waterRegionList = traitRegionList.filter(
    (region) => region.trait === 'water',
  )

  for (const waterRegion of waterRegionList) {
    for (const [, waterHex] of waterRegion.hexMap) {
      let nonWaterCount = 0

      for (let direction = 0; direction < 6; direction++) {
        const neighborHex = waterHex.neighbor(direction)
        const neighborBlock = blockMap.get(neighborHex.key())

        if (!neighborBlock) continue

        const isNotWater = !blockDefinitions[neighborBlock.type]
          .traitList.includes('water')

        if (isNotWater) {
          nonWaterCount++
          if (nonWaterCount >= 5) return true
        }
      }
    }
  }
  return false
},
```

也就是說，青蛙只會出現在「幾乎被陸地包圍的小池塘」旁邊！%ヾ(◍'౪\`◍)ﾉﾞ%

### 解析流程

最後，`resolveSoundscape` 將所有規則串接起來：

```typescript
export function resolveSoundscape(data: {
  traitRegionList: TraitRegion[];
  blockMap: Map<string, Block>;
  weather?: Weather;
}) {
  const validRuleList = soundscapeRuleList.filter((rule) =>
    rule.predicate(data),
  )

  const soundscapeList = pipe(
    validRuleList,
    reduce((acc, rule) => {
      return rule.transform(acc)
    }, [] as Soundscape[]),
  )

  return soundscapeList
}
```

流程很直覺：過濾出所有滿足條件的規則，然後依序呼叫 `transform` 將音效累加起來。

每次積木變動或天氣改變時，系統會重新計算連通區域、解析音效，自動更新播放中的聲景。

## 總結 🐟

以上程式碼可以[在此取得](https://github.com/Codfisher/cod-aquarium/tree/main/content/aquarium/hexazen)

* 建立基於 Cube 座標的六角格系統
* 積木的多特性設計讓音效判斷更有彈性
* BFS 連通區域偵測讓「規模」成為音效觸發的關鍵因子
* 規則驅動的聲景系統，用簡單的 predicate + transform 組合出豐富的聽覺體驗
* 天氣系統為同一場景帶來截然不同的聲音氣氛

下一章讓我們來聊聊音效播放引擎與分享編碼的實作吧。%♪( ◜ω◝و(و%

::: tip
因為篇幅問題，某些部分簡單帶過。

若想看到更詳細的解釋，請不吝留言或寫信給我喔！%(\*´∀\`)~♥%
:::
