仙泉.香布纏.續配樂、音效與天氣光影

配樂、音效與天氣光影

這一章講《續》怎麼讓人聽到、看到時間在走:前作的配樂怎麼一幕一幕接上,第八章那首新曲怎麼產,馬蹄聲怎麼用程式合成,引擎的雪為什麼關掉、改成自己做,火怎麼動,還有地圖上的光和天氣參數。Larch RPG 在這幾件事上都有上限,每一節寫碰到哪一條、最後怎麼做,指令和檔名照原樣放。

前作的曲子,一幕一幕接上

遊戲裡現在有 36 首曲子:35 首沿用前作《仙泉.香布纏》,1 首是第八章新產的。設計檔一開始就寫了:沿用前作的配樂,曹營那一章可能要一兩首新的。

怎麼對是 AI 定的(2026-10-09 的決定紀錄):前作哪一幕在劇情上對得到,就用那一幕的曲子。第一章城破接前作第二卷序那首,第五章巷口斷後接前作〈百零七騎〉那首。標題畫面和不喝那條路的尾聲用同一首〈A Hearth at the Frontier〉,這是前作尾聲那首,前作第二卷開頭那段塤在裡面再吹一次,整部頭尾接起來。

章 用到的曲子
序 碗 Gathered at the Hearth
第零幕 Courtyard Laughter、Hearthlight in the Valley、A Promise at Twilight、Oath at the Edge of Winter
第一章 Beacon Fires at Dusk、A Thousand Winters Deep、One Bowl at Dawn、Return to the Mountain Gate
第二章 Return to the Mountain Gate、Cold Iron at the Border、Hearthlight in the Valley、A Thousand Winters Deep、Beacon Fires at Dusk
第三章 Cold Iron at the Border、Oath at the Edge of Winter、Morning in the Valley
第四章 Behind the Painted Screen、Before the Candle Fades
第五章 Before the City Burned、Sunlight on the Mirror、Iron and Silk、Dawn Over The Eastern Gate
第六章 Below the Floorboards、Arrow Carved In Silence、Beneath the Burning Gate、Small Bow Long Winter、Beyond the Stone Gate
第七章 Return to the Hearth、The Archer’s Return、Courtyard Laughter、The Third Lamp、Sorrow at the Gates、Untying the Red Cord、The Midnight Crossing、Crimson Ink on the Scroll
第八章 Twenty Five Winters(新曲)
第九章 A Lifetime of Loyalty、Vow in the Hidden Spring、Gathered at the Hearth、Where the Seasons Rest、By the Fading Hearth
終之一 喝 Light Beneath the Snow
終之二 不喝 A Promise at Twilight,墓前起換 A Hearth at the Frontier

曲子寫在卡片上

AI 寫的 xu/game/src/music.py 裡,SCENES 是一張「卡片 id → 曲名」的表,現在 124 筆。建置時 apply() 把 bgm、bgmVolume、bgmLoop 寫進每一張卡,音量 0.3,照前作。

引擎在這裡有兩個行為要知道:

前作的曲檔有一些沒進版控,所以 python3 src/music.py sync 會硬連結一份進 xu/game/assets/bgm/,跟著《續》一起收進 repo,前作那邊清掉也不會跟著不見。

檢查有兩道。tests/check_static.py 擋三種漏掉:地圖卡沒有配樂、章節字卡沒有配樂、標題畫面沒有配樂。node tests/title_music.mjs 開瀏覽器看標題畫面、序、第一幕各在播哪一首,負控制是進第一幕地圖以後,序那首必須停掉。

還掛著兩件:同一首會在不同章出現(〈A Thousand Winters Deep〉在第一章的五原、第二章的墳都有),等我聽過覺得重複再換;戰鬥沒有另外配戰鬥曲,沿用那張地圖的曲子。

第八章那首新曲

第八章是許都曹營的二十五年。我定的方向是畫面和音樂一年比一年淡。

前作的配樂是用 Gemini 產的,新曲照同一個做法:python3 art/music.py gen [N] 一次產 N 首候選,純音樂,存在 art/music_raw/(不進版控),選定的那首複製到 assets/bgm/。產曲服務會間歇性逾時,程式每首最多試四次。

提示詞是 AI 照前作的樂器分工寫的。前作裡塤是五原、是布,古琴是家,胡笳和低音鼓是戰場。這一首讓古琴撥弦一圈一圈轉,悶的手鼓像馬廄裡換腳的馬,塤只在很遠的地方起個頭就散掉。程式註解寫了理由:他不能往北看太久。

[Instrumental only, no vocals] A numb, patient, repetitive piece for a man doing the same chores in an enemy army's stables and granary, year after year, for twenty-five years. A plain, steady plucked Guqin ostinato that turns like the seasons and never changes, a soft muted frame drum like hooves shifting in a stable, a little dry wind. Now and then a lonely Xun melody begins very faintly, far away, like a memory of the northern frontier, and fades out before it can finish. Restrained, grey, quietly enduring, emotionally held back; no climax, no big strings, no battle drums. Eastern Han dynasty China, dark classical film score style, small ensemble. Slow tempo, around 60 BPM. Seamless loop.

兩輪產了四首,AI 讓 Gemini 代聽打分,選分數高的那首,存成 assets/bgm/Twenty_Five_Winters.mp3。代聽有個問題:它分不出古琴和吉他,連前作的曲子都有一半被判成吉他。所以上架前要確認的清單上,這首還掛著一條:我要親耳聽過再定。

一年比一年淡,做到每一年

2026-10-09 的第一版,照魏續的走路圖換年紀,分四個時代做地圖卡:198 到 204、205 到 212、213 到 222、223。每個時代一張春夏秋的卡,冬天另一張雪景(223 年只有春夏秋)。時代越後面越暗、暗角越重、空氣裡的光塵越少,配樂一個時代比一個時代小聲。

問題出在地圖卡是同一個時代共用的,卡上的音量是固定值,一個時代裡每一年都一樣大聲。2026-10-14 AI 改成逐年:198、205、213、223 四年當錨點,音量 0.30、0.22、0.15、0.09,中間每一年線性插值(music.year_volume())。每次換年、換季,一個條件事件 h-env 跑兩個步驟:weather 把畫面暗度設成那一年的值,music 把同一首曲子的音量設成那一年的值。暗度從 198 年的 0.04 慢慢加到 223 年的 0.26,冬天再加 0.4。

第八章的配樂音量和畫面暗度,198 到 223 年
第八章的配樂音量和畫面暗度,198 到 223 年

驗收:node tests/vol8_probe.mjs 把起點分別放在四個時代的地圖卡,量播放器裡 <audio> 實際的音量,要一個比一個小;check_static 擋逐年音量沒有一直往下。2026-10-14 那一輪,第八章一年一年過的路線整章跑了 1,402 秒,每一季的音量斷言都過。

音效:鈴聲不用,馬蹄聲自己合成

做前作的時候(2026-10-03)我說過:「鈴聲很難聽 不論哪一卷 都拿掉」。《續》照這條,check_static 在專案裡看到 chime 就擋。

第五章要兩種馬蹄聲。劇本的寫法是:門檻邊劈柴時,遠處偶爾有馬蹄聲,大多是別的馬;〈私宅〉那一夜畫面上不提示是誰,聽過前面那些別的馬的玩家,自己聽得出來。手邊沒有現成的馬蹄音效,AI 寫了 xu/game/art/sfx.py,用 numpy 和 scipy 直接合成:

一下蹄聲疊兩層:一個很快衰減的低頻正弦波當悶響,一段帶通雜訊當碎響,最後加四個延遲的回音,再用 ffmpeg 轉成 96k 的 mp3。

上:別的馬,兩拍小跑,經過不停。下:赤兔,三拍跑近、走三步、停住、打響鼻
上:別的馬,兩拍小跑,經過不停。下:赤兔,三拍跑近、走三步、停住、打響鼻

怎麼放也是 AI 定的:門檻邊第一次劈柴一定是別的馬,接一句「不是她。」;再劈一次,就是〈私宅〉那一夜的赤兔。不做隨機,測試和玩家都不會卡。

第六章加了一個 knock:指節敲在地窖的厚木蓋板上,短、悶、有木頭的共鳴。在柴房按一下動作鍵就敲一下,停 1.5 秒算一次,剛好敲兩下蓋板才開,一下、三下都不開。同一個聲音後來又用了兩次:〈搜屋夜〉藏米的那段時限裡,每 2.2 秒響一下,畫面跟著輕震;第七章划船時 700 毫秒內連划兩格,音量壓到 0.22 響一下,配一道橘色閃光,表示驚動了曹營。

戰鬥裡的揮刀聲是引擎自己的。

天氣和光:先用引擎的參數

2026-10-05 的素材清單就這樣規劃:天氣和時間用地圖的環境參數做,不另外畫;第八章的一年比一年淡、四季,都靠這組參數加同一張底圖。《續》用到的參數:

參數 做什麼 《續》用在哪
darkness 整張變暗 每一張夜景;第八章逐年變暗
lights 一盞一盞的光:位置、半徑、顏色、會不會閃(flicker) 門燈、火盆、營火、月光
fog 霧的濃度和顏色 五原城破、逃難的路
particles 飄在空中的粒子:光塵、花瓣、火星 嚴家的花瓣、城破的火星
rays 斜照的光束 白天的院子、林子
clouds 地上的雲影 嚴家的夏天
vignette 四角暗角 幾乎每一張
tint、tintStrength 整張染一層顏色 嚴家的春夏秋
critters 蝴蝶、鳥 嚴家的春夏秋

樹葉光斑(dapple)這次沒用。後院和城南宅白灰牆上的樹影是畫在牆的圖裡的;2026-10-14 還把原本鏡射接長、看得出左右對稱的樹影,重產成不重複的斑駁影子。

嚴家的四季

嚴家一年有四張地圖卡。2026-10-15 我留的回饋是:「一年至少春夏秋冬各一次自由活動,完整的 4 季地圖,4 季應該要有一定程度的變化」。冬天用原本有雪的那張;春夏秋三季共用一張去掉雪的新圖(a2g),三季的差別全靠環境參數(xu/game/src/map_a2.py 的 ENV):

暗度調過的幾處

油燈、火盆、營火都開 flicker,月光和天快亮時的天光不開。光源半徑只收 0.5 到 12 格,而且要在地圖裡:2026-10-09 有一盞設了 14,整個播放器壞掉,現在 check_static 擋。

光也拿來當玩法

第五章〈躲巡兵〉:宵禁的長安街上,兩個西涼兵各走各的路,走到停靠點就停下來,火把在地上照出一片黃光,光裡的格子一碰就被看見。引擎的事件不能跟著人走,所以光是一張固定的圖:art/glow.py 畫的柔邊橢圓,8×7 格,邊上一圈細亮線,看得出光照到哪裡。每個停靠點一張,巡兵停在哪一點,就亮哪一張。被看見的格子是鋪在光上的棋盤格觸碰事件,任何四向的走法都會踩到至少一格。

第六章城外的夜:營火照得到的範圍是一個橢圓(橫向半徑 5.6 格、縱向 4.6 格),邊緣那一圈格子一碰就把人頂回黑處,要繞著光走到糧車拿半袋米。

第六章城外的夜:營火照得到的那一圈,一碰就被頂回黑處
第六章城外的夜:營火照得到的那一圈,一碰就被頂回黑處

第七章划船出城:曹營兩個火盆照得到的水面也是一碰就頂回去;划太急,就是上一節那聲敲擊和橘色閃光。

第六、七章的自動玩家走路時,拿同一個範圍當「要避開的格子」,地圖程式和測試共用一個範圍。

下雪:引擎的雪不自然,就自己做

最早是 2026-10-08,嚴家試過一直下小雪,我玩了覺得怪,同一晚就拿掉,只留地上的雪。之後的雪景都做在物件上:〈荃〉的大雪夜、〈快馬〉雪還沒化、第八章的冬天,是把既有物件的選定圖送去改圖,同一個物件蓋上厚雪、拿掉落葉,設計檔照抄原版一份。屋裡不下雪。

2026-10-20 我玩第八章,留了一則「下雪不自然」。AI 量了引擎的雪:固定半徑 1.5 到 3.5 像素的小圓點,不隨解析度放大,全部用同一個速度往下飄(每秒 25 像素),能調的只有數量。我回:「想辦法調整,不行就自己設計」。

外掛 HUD 鋪一層雪做不到:HUD 的寬高被夾在 640 像素以內,只能貼四個角。AI 改用引擎的角色動畫,一張很高的動畫圖當一個「角色」,一塊一塊鋪滿地圖:

雪花分遠、中、近三層:越近越大、越亮、落得越快(每秒 1.1、1.9、3.0 格),左右輕飄,再加一點往右的風。每片雪只在自己的壽命裡出現,頭尾三格淡入淡出;壽命跨過最後一格就接回第 0 格,所以循環沒有接縫。程式註解寫的理由是:俯視角看雪,本來就是出現、往下飄一段、落地不見。

左:引擎的雪。右:自製的雪。下排把約一秒內連拍的八張疊在一起:引擎的雪每一片一樣大、一樣快;自製的雪大小、快慢、左右飄都不同
左:引擎的雪。右:自製的雪。下排把約一秒內連拍的八張疊在一起:引擎的雪每一片一樣大、一樣快;自製的雪大小、快慢、左右飄都不同

踩到的坑有三個:

  1. 第一版一格畫面 256×960 像素、24 格動畫,整張地圖只剩「這張地圖的資料無法讀取」。引擎的角色圖每格寬高最多 512 像素、最多 16 格,超過一個就整張不讀。改成現在的 8×10 格、16 格。同一天第七章傷兵的道具圖(384×677)也踩到同一條。現在 check_static 的 9n 檢查全專案每一張角色圖,負控制是重建前抓到 26 處。
  2. 自製的雪是角色圖,夜色會連它一起壓暗;引擎自己的雪畫在夜色上面。夜裡的雪地上偏淡,密度從 0.30 提到 0.55,中、近兩層放大。上面那張是〈荃〉的雪夜,暗度 0.66,截圖裡自製的雪還是比引擎的暗。
  3. 同一晚我玩第三章,留了一則「雪粒有一點太大,再細一點點」。三層的半徑各縮兩到三成,片數不變,七張下雪的地圖一起變細。

下雪的地圖現在七張:第零幕過年、第一章逃難的路、第三章〈快馬〉和〈荃〉、第八章三個時代的冬天。要更密、更大、更慢,都在 art/snow.py 開頭的 LAYERS、WIND、DENSITY 調。check_static 的 9m 檢查每張下雪的地圖都鋪滿、事件都在每一段最下面一列;某一格附近都被別的事件佔了,那一塊就少一片雪,檢查會數出來。

會動的火

2026-10-08 我問火能不能做成動圖:循環、有時間差,不要大家一起閃。

AI 先試動態 WebP:引擎用 canvas 畫圖,canvas 只畫動圖的第一格(HTML 規格就是這樣,實測也不會動)。所以改用引擎的角色動畫:一團火一張橫排的動畫圖,事件開 animate 和 fps。

第一版是從畫好的牆上找出火(照火心的亮黃白色找),把那塊牆補回去,再讓原本那團火搖起來。同一晚又改了兩次:先是框火的範圍把牆上的火光一起框進去扭,改成只讓火舌動;接著我定了:牆上不要畫火,火另外做成物件,才不會互相影響。

現在的做法在 xu/game/art/fires.py。牆線產圖時用「去掉火」的版本,火由三層疊成:

  1. 光暈:暖色,中心最亮、往外淡掉,強度跟著閃。
  2. 火架:不動。地上的鐵火盆、牆上的火把籃,還有燒焦的梁木堆、營火、油燈,收在共用的 art/fireset/,每張地圖切圖時直接拿。火架已經畫在物件上的(望樓台上、營門柱頂),就只放火焰和光暈。
  3. 火焰:生成的一團火,8 格動畫,往上拉長縮短、上半部左右擺、亮度閃,底部壓在火口不動。
三種火架上的火,各八格動畫
三種火架上的火,各八格動畫

錯開的做法:每團火從不同的格開始(第 i 團從第 i×3 格),每秒 7 到 9 格輪著,同一張地圖上的火不會一起閃。動畫圖寬度超過 2 格,引擎就不幫它畫腳下的影子。寬度也不能超過 8 格:2026-10-09 有一團灶火的事件格離火太遠,圖被撐到 8 格以上,引擎的角色倍率只收 0.25 到 8,整個播放器壞掉。分層地圖上現在一共 75 團火,季節版、雪景版各自那一份也算在內。

驗收用 node tests/fire_anim.mjs <地圖> <鍵> <主角 x> <主角 y>:截一張停 130 毫秒,連截 12 張,比每團火附近的畫面,每團火都要在變;負控制是牆上沒有火的一塊不能變。丁原營那張的營火光暈整片在閃,自動判斷抓不出火,改成一區一區比對。

五原城破那張圖。左:連拍的第一張。右:約五秒內連拍 12 張,有變動的像素塗成洋紅,只有火焰那幾塊;零星的小點是飄著的火星
五原城破那張圖。左:連拍的第一張。右:約五秒內連拍 12 張,有變動的像素塗成洋紅,只有火焰那幾塊;零星的小點是飄著的火星

有的火只在某些段落點著。第五章城南宅門邊那盞油燈,事件條件是變數 c5_lamp 等於 0,演到「然後燈滅了。」那一下改掉這個變數,燈就熄了。燈的樣子我定過:只有油燈和火把,不要燈籠,也不要玻璃燈。

試過、沒留下來的

做法 為什麼不用 改成
嚴家一直下小雪(2026-10-08) 我玩了覺得怪 只留地上的雪
用動態 WebP 做會動的火 canvas 只畫第一格 引擎的角色動畫
從畫好的牆上摳火來動 牆上的火光一起被扭 牆不畫火,火架、火焰、光暈另外做
用外掛 HUD 鋪一層雪 HUD 寬高最多 640 像素,只能貼四個角 很高的動畫角色圖鋪滿地圖
第一版自製雪(每格 256×960、24 格) 超過角色圖上限,整張地圖讀不出來 8×10 格、16 格
引擎的雪 固定大小的小圓點,同一個速度 強度設 0,換成自製的
第八章照時代換音量 共用的地圖卡表達不了每一年 四個錨點之間逐年插值
只靠 Gemini 代聽選曲 分不出古琴和吉他 等我親耳聽

接下來

← 上一章下一章 →