前面幾天談的是「資料怎麼流動」,這篇要談玩家實際看到、點到的介面本身。
文字冒險的 UI 很容易被低估。它看起來只是對話框、選項、幾個按鈕,但玩家其實是透過 UI 在判斷「現在能做什麼」。介面沒有講清楚,玩家不會覺得是系統複雜,只會覺得自己卡住。
src/components/ 底下目前有 21 個 Vue 元件。切分原則是「一個系統一個元件」而不是「一個畫面一個元件」:
| 元件 | 負責的系統 |
|---|---|
DialogueBox |
對話框與打字機效果 |
ChoiceMenu |
選項(含四種特殊模式) |
FreeActionMap |
第一章自由行動地圖 |
EvidenceModal |
證據彈窗 |
DebatePlanner |
文辯小遊戲 |
LogisticsPlanner |
算籌小遊戲 |
DuelArena |
武鬥小遊戲 |
SystemPanel |
遊戲中系統選單(存讀檔、設定、歷史等入口) |
SettingsPanel |
遊戲設定面板 |
GalleryPanel |
圖鑑 |
EndingScreen |
結局畫面 |
StatsPanel |
數值面板 |
MainMenu |
主選單 |
LoadingScreen |
載入畫面 |
OrientationOverlay |
手機方向提示 |
ChapterLockScreen |
章節密碼鎖 |
CharacterPortrait |
角色立繪 |
GameCanvas |
PixiJS 畫布容器 |
ConfirmModal |
通用確認對話框 |
AdminPanel |
開發者面板(生產環境隱藏) |
SystemToasts |
Toast 通知 |
每個元件主要讀寫自己負責的那塊 Pinia 狀態,互動盡量走 store,而不是讓元件彼此互相呼叫。這延續 Day8 提過的單向資料流:劇情推進改變 store,Vue 元件再依照 reactive state 更新畫面。
如果 src/components/ 是零件庫,src/views/GameView.vue 就是把零件裝起來的殼層。它同時掛載 PixiJS 畫布、角色立繪、對話框、選項、自由行動地圖、小遊戲面板、系統面板、Toast、證據彈窗、結局畫面與 loading 畫面。
這個檔案的重點不是畫面長相,而是「誰該在什麼狀態出現」。例如:
MainMenu
GameCanvas、CharacterPortrait、DialogueBox
ChapterLockScreen 擋住底層互動這些條件如果分散在各個元件裡,debug 會很痛苦。集中在 GameView.vue 的好處是很直觀:要查「為什麼某個 UI 沒出現」,先看殼層條件,再看元件內部邏輯。
21 個元件疊在一起,層次要清楚。src/components/ 裡出現的 z-index 值是刻意設計的分層:
| z-index | 元件 | 說明 |
|---|---|---|
| 0–30 | 畫布、對話框、選項、立繪、證據彈窗等 | 基礎遊戲 UI |
| 100 | ConfirmModal |
通用確認視窗 |
| 1000 | LoadingScreen |
蓋住一般遊戲內容 |
| 9999 | ChapterLockScreen |
章節鎖定時擋住底層遊戲 |
| 10000 | SystemToasts |
Toast 高於面板與鎖定畫面 |
| 99999 | OrientationOverlay |
手機方向提示不可被其他元件蓋掉 |
Toast 通知設在 10000,確保不管系統面板、確認視窗或章節鎖定畫面開著,都還能看到關係變化、存檔錯誤這類短提示。OrientationOverlay 設在 99999,是整個堆疊的天花板,因為手機直向提示出現時,底層遊戲本來就不該再被操作。
src/components/ 裡共有 15 個 @media 規則,分散在各個元件的 <style scoped> 裡。其中 14 個是寬度斷點,另 1 個是 hover 裝置判斷。寬度斷點主要落在 720px,小遊戲與自由行動地圖還有 920px、640px,系統面板則多一個 480px 微調。
這裡沒有靠一個全域 RWD 框架縮放整個畫面,原因是文字冒險遊戲的介面元素密度差很多:
用單一縮放規則套所有元件,會讓其中某幾個在特定裝置上看起來過大或過小,逐元件調整雖然程式碼比較多,但每個元件可以為自己的內容密度量身訂做。
這件事在文字冒險裡很明顯:對話框太小,玩家讀起來累;選項太大,畫面一次只看得到一兩個;小遊戲格子太擠,又會變成誤觸。RWD 不只是讓畫面「塞得下」,還要讓玩家能舒服地讀和點。
OrientationOverlay.vue 處理手機直向持握時的提示畫面,這裡有兩個容易被忽略的細節:
焦點管理:元件出現時用 watch 監聽 active prop,一變成 true 就在 nextTick 之後把焦點移到 overlay 本身(overlayEl.value?.focus())。這代表鍵盤使用者或螢幕閱讀器的焦點不會停在底層遊戲的某個按鈕上——不主動移焦點的話,鍵盤仍然在操作被蓋住的遊戲內容。
inert 屬性:提示顯示的同時,App.vue 會在底層的 .game-shell 加上 inert 屬性跟 aria-hidden="true":
<!-- App.vue -->
<div
class="game-shell"
:inert="orientationBlocked ? true : undefined"
:aria-hidden="orientationBlocked ? 'true' : undefined"
>
inert 是 HTML 原生屬性,會讓元素(及其所有子元素)完全無法被點擊、聚焦、或被輔助工具感知——這比 pointer-events: none 或 visibility: hidden 更徹底,連鍵盤 Tab 鍵都繞過去。aria-hidden="true" 則讓螢幕閱讀器直接略過底層內容。
SystemPanel.vue 是另一個容易低估的元件。表面上它只是存檔、讀檔、設定、歷史、證據、角色事件、圖鑑與後台的入口,但實作上它還負責一件很重要的事:焦點管理。
面板打開時,程式會記住原本的 document.activeElement,等 DOM 更新後把焦點移到關閉按鈕。面板關閉時,再把焦點還回原本的位置。這個細節對滑鼠玩家幾乎沒有存在感,但鍵盤玩家會很有感:按 Esc 關掉面板後,焦點不會迷路。
SystemPanel 也攔截 Tab 鍵,把焦點圈在面板裡。使用者一路按 Tab 時,焦點會在面板內的可操作元素之間循環,不會跑到底層的對話框、存檔按鈕或其他被遮住的 UI。這和前面 OrientationOverlay 的 inert 是同一個思路:畫面上不可操作的東西,鍵盤和輔助工具也不應該碰得到。
GameView.vue 還有一層全域快捷鍵:遊戲進行中按 A 可以切自動模式,沒有面板時按 Escape 會開設定;如果面板已經開了,Esc 關閉則交給 SystemPanel 處理。這樣分工後,快捷鍵不會互相打架。

這次做 UI 時,我一直把「玩家現在該看哪裡」放在第一順位。對話框永遠是閱讀焦點,選項只在需要決策時出現;證據、地圖、小遊戲這些資訊量比較大的介面,則用 modal 或獨立面板承接,不混進主對話區。這樣做的好處是玩家不需要同時理解太多系統:讀劇情時就讀劇情,做選擇時才看選項,蒐證時才進入證據介面。
另一個小原則是,不把教學文字寫得太重。像存檔、設定、圖鑑這些功能,盡量靠固定位置、清楚按鈕狀態、以及元件本身的命名讓玩家理解,而不是每個畫面都塞一段說明。文字冒險已經有大量正文,如果 UI 再一直說明自己,玩家會很快疲乏。好的介面應該把注意力還給故事,而不是一直提醒玩家「這裡有一個系統」。
Day13 回頭看 UI,最重要的收穫其實不是某個按鈕長得好不好看,而是介面規則要能被維護。哪些元件負責閱讀、哪些元件負責決策、哪些元件會蓋住其他東西、焦點該去哪裡,這些都寫清楚之後,後面加功能才不會把畫面堆成一團。
