Skip to content

hexazen-cover

HexaZen - EP02:六角格與聲景系統

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

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

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

六角格座標系統

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

三種座標系統

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

Offset 座標

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

0,01,02,00,11,12,10,21,22,2row 0row 1row 2

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

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) 上:

0,-11,-1-1,00,01,0-1,10,1qr

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

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 恆成立,這個約束讓所有運算都變得對稱且優雅。

+q+r+s0-111-10-10100010-1-11001-1q + r + s = 0

對比三者的操作複雜度:

操作OffsetAxialCube
找鄰居判斷奇偶行查表向量加法
算距離分段公式推導第三軸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 則是便利方法,只需要給 qrs 會自動推算。

找鄰居

六角格每格有 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 文章

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

積木與特性

有了六角格,接著需要定義各種積木與它們的「特性(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 將音效累加起來。

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

總結 🐟

以上程式碼可以在此取得

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

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

TIP

因為篇幅問題,某些部分簡單帶過。

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