
HexaZen - EP02:六角格與聲景系統
上一章讓我們可以建立積木,但只有積木沒辦法拚在一起
這次要來實作六角格座標系統,讓積木可以排列在六角網格上。
同時基於積木特性建立聲景(Soundscape)系統,讓不同的積木組合自動產生對應的自然音效!◝( •ω• )◟
六角格座標系統
六角格看似簡單,但座標計算其實有不少眉角。
三種座標系統
六角格常見的座標系統有 Offset、Axial、Cube 三種,各有優缺點。
Offset 座標
最直覺的方式:用 (col, row) 對應 2D 陣列索引。
看起來很簡單,但問題馬上出現——奇數行和偶數行的鄰居偏移不同:
// 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) 上:
鄰居偏移變得一致了,不用再判斷奇偶:
// Axial 座標的六方向:固定偏移量,不分奇偶
const axialDirections = [
[+1, 0],
[+1, -1],
[0, -1],
[-1, 0],
[-1, +1],
[0, +1],
]但距離計算仍然需要推導第三個隱含的座標:
// 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 座標的距離就是向量在三軸上的最大分量:
// 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° 也只需要排列三個值再取反:
// 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
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 個共用邊的鄰居,定義方向向量後,找鄰居就是一次向量加法。
// 六方向(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 的世界座標。
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 格,這在放置積木時非常有用。
積木與特性
有了六角格,接著需要定義各種積木與它們的「特性(Trait)」。
特性系統
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
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:產生對應的音效
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 | 市集 | 熙攘的人聲 |
| 有營火 | 營火 | 柴火劈啪聲 |
例如「風吹樹梢」的規則:
{
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 個是非水域積木。
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 將所有規則串接起來:
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
因為篇幅問題,某些部分簡單帶過。
若想看到更詳細的解釋,請不吝留言或寫信給我喔!(*´∀`)~♥