Day13 談過整體 UI 設計原則:元件拆分、Z-index 管理、各元件自己處理響應式。
Day18 往下鑽一層,看實際的斷點數字,以及手機、平板、桌面到底怎麼分流。
先講一個我後來才比較確定的判斷:文字冒險遊戲的 RWD,不是把桌面畫面等比例縮小。它更像在保護閱讀節奏。對話框不能太小,選項不能擠成一團,系統面板不能讓按鈕跑出畫面,手機直向也不能硬撐到玩家看不清字。
repo 裡不只一套介面。src/portal/style.css 是入口/展示頁,有自己的斷點;Day18 這篇主要看遊戲本體,也就是 src/components/*.vue、src/views/GameView.vue 和 src/App.vue 這些 runtime 畫面。
在 runtime Vue 元件裡,目前 @media (max-width: ...) 主要集中在四個數字:
| 斷點 | 用在哪裡 | 主要用途 |
|---|---|---|
920px |
FreeActionMap.vue、LogisticsPlanner.vue、DuelArena.vue |
卡片型介面從 3 欄改 2 欄 |
720px |
DialogueBox.vue、ChoiceMenu.vue、GameView.vue、CharacterPortrait.vue、SystemPanel.vue 等 |
核心遊戲 UI 縮小留白、字級或欄位 |
640px |
地圖、小遊戲規劃器、文辯/武鬥介面 | 卡片型介面改成單欄 |
480px |
SystemPanel.vue |
存檔格從 2 欄再縮成 1 欄 |
另外還有一個 768px,不是一般排版斷點,而是 App.vue 用來判斷手機直向是否要顯示方向提示:
window.matchMedia('(max-width: 768px) and (orientation: portrait)').matches
所以如果只說「專案有四個斷點」會不夠精準。比較正確的說法是:遊戲元件的版面斷點集中在 920 / 720 / 640 / 480;裝置方向判斷另用 768。
這個專案沒有建立一套像 Bootstrap 那樣的全域斷點系統,也沒有在 style.css 放一堆共用 RWD class。大部分響應式規則都寫在各元件自己的 <style scoped> 裡。
這不是因為全域設計系統不好,而是文字冒險的畫面差異太大:
把這些全塞進同一個 RWD 規則,會很快變成互相牽制。這也是 Day13 提到「逐元件響應式」的原因:讓每個元件照自己的內容密度調整。
index.html 的 viewport meta 只有最基本的一行:
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
它沒有加 user-scalable=no。這個小細節我覺得很重要。很多遊戲網頁會想把縮放鎖住,讓畫面看起來更像 app;但文字冒險是閱讀量很高的遊戲,玩家如果需要放大,瀏覽器原生縮放不應該被拿掉。
這跟視覺完整性有一點拉扯。允許縮放,代表畫面可能不是每次都維持設計稿裡那個比例;但可讀性比「畫面永遠不變形」重要。尤其《九重燼》這種文字密度高的專案,字看不舒服,其他演出做再多都沒用。
App.vue 負責判斷手機直向是否要擋住遊戲:
function updateOrientationBlocked() {
if (typeof window === 'undefined') return
orientationBlocked.value = window.matchMedia('(max-width: 768px) and (orientation: portrait)').matches
}
onMounted(() => {
updateOrientationBlocked()
window.addEventListener('resize', updateOrientationBlocked)
window.addEventListener('orientationchange', updateOrientationBlocked)
})
這裡不是用 matchMedia(...).addEventListener('change')。目前做法是監聽 resize 和 orientationchange,每次事件發生時再查一次 media query 的 matches。
orientationBlocked 會同時做兩件事。第一,顯示 OrientationOverlay;第二,把底下的遊戲 shell 加上 inert 和 aria-hidden:
vue
<div
class="game-shell"
:inert="orientationBlocked ? true : undefined"
:aria-hidden="orientationBlocked ? 'true' : undefined"
>
<GameView />
</div>
<OrientationOverlay :active="orientationBlocked" />
這裡不是只蓋一塊黑幕而已。inert 讓底下按鈕不能被鍵盤或滑鼠操作,aria-hidden 讓輔助工具不要讀到底層遊戲內容。overlay 本身則用 role="dialog"、aria-modal="true"、aria-labelledby、aria-describedby,並在啟用時把焦點移到 overlay。
手機直向要完整支援當然可以,但成本會很高。與其讓玩家勉強在窄直螢幕裡看被壓扁的文字,不如明確告訴他「這個遊戲需要橫向」。
DialogueBox.vue 在桌面狀態下,對話框最小高度是 9rem,內距是 1.5rem 2rem,正文 font-size 是 1.15rem:
.dialogue-box {
min-height: 9rem;
padding: 1.5rem 2rem;
}
.text {
font-size: 1.15rem;
line-height: 1.85;
}
到 720px 以下,它只做很小的調整:
@media (max-width: 720px) {
.dialogue-box {
padding: 1.2rem 1.5rem;
min-height: 8rem;
}
.text { font-size: 1.05rem; }
.name-tag { font-size: 1.05rem; }
}
注意它沒有改 DOM 結構,也沒有把說話者名牌、游標、推進提示拆到別的位置。原因很簡單:對話框是玩家最常看的東西,結構一變,閱讀肌肉記憶就會被打斷。
小螢幕的問題通常不是「放不下對話框」,而是單行字數太少。一句話被切成太多行,讀起來會碎。把正文從 1.15rem 降到 1.05rem,再稍微縮內距,比重做版面更穩。
min(),高度用內部捲動GameView.vue 的底部 UI 不是固定寬度,而是:
.bottom-ui {
width: min(56rem, calc(100% - 2rem));
}
桌面最多 56rem,窄螢幕則保留左右各 1rem 的安全空間。這比寫死 width: 900px 好很多,也比每個斷點都改一次寬度乾淨。
選項清單還處理了另一個常見問題:手機橫向高度很矮,選項如果太多,很容易往上爆出畫面。ChoiceMenu.vue 直接限制高度並啟用內部捲動:
.choice-menu {
max-height: calc(100vh - 13rem);
overflow-y: auto;
scrollbar-width: thin;
}
調查模式的 .investigation-menu 也用同樣策略。這其實比縮小所有選項還好,因為按鈕仍然保有可點擊面積;多出來的內容交給捲動處理。
ChoiceMenu.vue 裡的調查模式預設是兩欄:
.investigation-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
到 720px 以下改成單欄,卡片最小高度也從 5rem 降到 4.5rem:
@media (max-width: 720px) {
.investigation-grid {
grid-template-columns: 1fr;
}
.investigation-choice {
min-height: 4.5rem;
}
}
這裡的取捨跟地圖卡片不同。調查選項是文字判斷,不是瀏覽型卡片;窄螢幕硬塞雙欄會讓每張卡的文字太窄,反而更難讀。改單欄雖然變長,但配合內部捲動,閱讀和點擊都比較穩。
FreeActionMap.vue 是比較典型的卡片式 RWD。桌面是三欄:
.map-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
920px 以下改兩欄,640px 以下改單欄:
@media (max-width: 920px) {
.map-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.map-grid {
grid-template-columns: 1fr;
}
}
LogisticsPlanner.vue 和 DuelArena.vue 也是同一種節奏:桌面三欄、920px 兩欄、640px 單欄。DebatePlanner.vue 則從兩欄開始,到 640px 改單欄。
這種卡片型介面適合用欄數處理,因為每張卡片都有標題、說明、狀態或效果 chip。卡片太窄時,不只是不好看,資訊層級也會亂掉。
SystemPanel.vue 比對話框麻煩,因為它裡面有存檔、讀檔、設定、證據、事件等不同內容。它在 720px 以下做幾個調整:
@media (max-width: 720px) {
.evidence-list {
grid-template-columns: 1fr;
}
.manual-slots-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.save-slot {
flex-wrap: wrap;
gap: 0.75rem;
}
.slot-actions {
flex: 1 1 100%;
flex-direction: row;
justify-content: flex-end;
}
}
到 480px 以下,手動存檔格再降成一欄:
@media (max-width: 480px) {
.manual-slots-grid {
grid-template-columns: 1fr;
}
}
這裡沒有把按鈕縮到很小,而是讓 .save-slot 換行,讓操作按鈕自己佔一整排。小螢幕最怕的是按鈕看得到但點不到,尤其存讀檔這種高風險操作,寧可佔空間,也不要擠。
CharacterPortrait.vue 的處理也很有趣。桌面寬度是:
width: min(47rem, 62vw);
到 720px 以下變成:
@media (max-width: 720px) {
.portrait-stage {
top: 3rem;
bottom: 1.6rem;
width: 86vw;
opacity: 0.78;
}
.portrait-stage.is-right { right: -12vw; }
.portrait-stage.is-left { left: -12vw; }
}
它不是單純縮小,而是讓立繪寬到 86vw,再往左右推出一點,透明度降到 0.78。這樣角色仍然有存在感,但不會跟底部對話框搶得太兇。
文字冒險的立繪很容易陷入兩難:太小,角色像背景裝飾;太大,玩家讀字會被干擾。這段 CSS 其實是在找中間值。
這幾組斷點不是「設計稿上看起來差不多」就結束。真正要看的,是幾個操作場景:
這些都不是單看桌面瀏覽器就能抓完的。尤其手機橫向,高度常常比寬度更麻煩;如果只看寬度斷點,很容易漏掉選項清單爆出視口這種問題。
Day18 做完後,我對 RWD 的想法更保守了一點。不是每個畫面都需要一套宏大的響應式系統;有時候更好的做法,是先搞清楚每個元件真正痛的是什麼。
對話框痛在閱讀節奏,所以只微調字級和留白。卡片介面痛在欄寬,所以用 3 / 2 / 1 欄。系統面板痛在操作安全,所以讓按鈕換行。手機直向成本太高,先用明確的方向提示擋住。
這些決定都不炫,但玩家不會因為 RWD 炫而留下來。玩家只會在某個小螢幕上按下一個選項,發現它剛好看得清、點得到,然後繼續讀下一句。