feat: accessibility settings, subtitle/QTE improvements, docs update

This commit is contained in:
2026-06-09 19:42:08 +08:00
parent 33ad26ed52
commit c9d29019a0
8 changed files with 387 additions and 11 deletions

View File

@@ -13,6 +13,7 @@ import AchievementToast from '@/components/AchievementToast.vue'
import AchievementPanel from '@/components/AchievementPanel.vue'
import EndingGallery from '@/components/EndingGallery.vue'
import ChapterRecap from '@/components/ChapterRecap.vue'
import AccessibilitySettings from '@/components/AccessibilitySettings.vue'
import { useGameEngine } from '@/composables/useGameEngine'
import { useGameStore } from '@/stores/gameStore'
import { useFullscreen } from '@/composables/useFullscreen'
@@ -33,6 +34,7 @@ const recapChapterId = ref<string | null>(null)
const hasAutoSave = ref(false)
const currentSpeed = ref(1)
const canSkip = ref(false)
const paused = ref(false)
const promptToast = ref('')
const showPromptToast = ref(false)
@@ -49,14 +51,21 @@ async function init() {
function handleStart() {
started.value = true
applyQteParams()
start()
}
async function handleResume() {
started.value = true
applyQteParams()
await resumeAutoSave()
}
function applyQteParams() {
engine.qteSystem.timeLimitMultiplier = store.qteTimeRelax ? 1.5 : 1
engine.qteSystem.singleKeyMode = store.qteSingleKey
}
function onVideoReady(elA: HTMLVideoElement, elB: HTMLVideoElement) {
videoElA.value = elA
videoElB.value = elB
@@ -117,12 +126,24 @@ watch(() => store.currentScene?.id, async (newId) => {
function onGlobalKeydown(e: KeyboardEvent) {
const key = e.key
if (key === ' ' && store.pauseEnabled && started.value && !store.gameEnded) {
const activeEl = document.activeElement
if (!activeEl || activeEl.tagName === 'BODY' || activeEl === document.body) {
e.preventDefault()
togglePause()
return
}
}
if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'ArrowLeft' || key === 'ArrowRight' ||
key === 'Enter' || key === ' ' || key === 'Tab' || key === 'w' || key === 'a' || key === 's' || key === 'd') {
store.setInputMode('keyboard')
}
if (key === 'Escape') {
if (showChapterSelect.value) {
if (store.showSettings) {
store.showSettings = false
} else if (showChapterSelect.value) {
showChapterSelect.value = false
} else if (showMenu.value) {
showMenu.value = false
@@ -133,6 +154,17 @@ function onGlobalKeydown(e: KeyboardEvent) {
}
}
function togglePause() {
if (!started.value || store.gameEnded) return
if (paused.value) {
paused.value = false
engine.videoManager.getActiveVideoElement()?.play().catch(() => {})
} else {
paused.value = true
engine.videoManager.getActiveVideoElement()?.pause()
}
}
function onGlobalMouseMove() {
store.setInputMode('mouse')
}
@@ -199,6 +231,7 @@ init()
{{ isFullscreen ? '⛶' : '⛶' }}
</button>
<button class="top-btn" @click="toggleMenu">{{ t('ui.menu') }}</button>
<button class="top-btn" @click="store.setShowSettings(true)">设置</button>
</div>
</div>
<div v-if="!started" class="start-overlay">
@@ -208,6 +241,7 @@ init()
<button v-if="store.chapters.length > 0" class="start-btn chapters-btn" @click="openChapterSelect">{{ t('ui.chapters') }}</button>
<button v-if="store.achievementDefs.length > 0" class="start-btn achievement-btn" @click="showAchievements = true">成就</button>
<button v-if="store.endings.length > 0" class="start-btn gallery-btn" @click="showEndingGallery = true">画廊</button>
<button class="start-btn settings-btn" @click="store.setShowSettings(true)">设置</button>
</div>
<div v-if="store.gameEnded" class="game-end-overlay">
<div class="game-end-text">{{ t('ui.gameEnd') }}</div>
@@ -249,6 +283,14 @@ init()
:visited-ids="store.visitedSceneIds"
@close="recapChapterId = null"
/>
<AccessibilitySettings
v-if="store.showSettings"
@close="store.setShowSettings(false)"
/>
<div v-if="paused" class="pause-overlay" @click="togglePause">
<div class="pause-text">已暂停</div>
<div class="pause-hint">点击或按 Space 继续</div>
</div>
<AchievementToast
:achievement-id="store.toastAchievementId"
:definitions="store.achievementDefs"
@@ -390,6 +432,35 @@ html, body {
color: #ce93d8;
}
.settings-btn {
border-color: rgba(255, 255, 255, 0.3);
color: #aaa;
}
.pause-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 150;
cursor: pointer;
}
.pause-text {
font-size: 36px;
color: #fff;
letter-spacing: 4px;
margin-bottom: 12px;
}
.pause-hint {
font-size: 14px;
color: #888;
}
.game-end-actions {
margin-top: 24px;
display: flex;

View File

@@ -0,0 +1,179 @@
<script setup lang="ts">
import { useGameStore } from '@/stores/gameStore'
const store = useGameStore()
const emit = defineEmits<{
close: []
}>()
const fontSizeOptions = [20, 24, 28, 32]
const bgAlphaOptions = [0, 0.3, 0.5, 0.7, 0.9]
</script>
<template>
<div class="settings-overlay" @click.self="emit('close')" @keydown.escape="emit('close')">
<div class="settings-panel">
<h2 class="settings-title">设置</h2>
<div class="settings-body">
<div class="setting-row">
<span class="setting-label">字幕字号</span>
<select :value="store.subFontSize" @change="store.setSubFontSize(+($event.target as HTMLSelectElement).value)">
<option v-for="s in fontSizeOptions" :key="s" :value="s">{{ s }}px</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">字幕背景</span>
<select :value="store.subBgAlpha" @change="store.setSubBgAlpha(+($event.target as HTMLSelectElement).value)">
<option :value="0"></option>
<option v-for="a in bgAlphaOptions.filter(v => v > 0)" :key="a" :value="a">{{ (a * 100) + '%' }}</option>
</select>
</div>
<div class="setting-row">
<span class="setting-label">QTE 时限放宽 (×1.5)</span>
<label class="toggle">
<input type="checkbox" :checked="store.qteTimeRelax" @change="store.setQteTimeRelax(($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">QTE 按键简化仅空格</span>
<label class="toggle">
<input type="checkbox" :checked="store.qteSingleKey" @change="store.setQteSingleKey(($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">防误触延迟 (0.5s)</span>
<label class="toggle">
<input type="checkbox" :checked="store.antiMistap" @change="store.setAntiMistap(($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="setting-row">
<span class="setting-label">可暂停 (Space)</span>
<label class="toggle">
<input type="checkbox" :checked="store.pauseEnabled" @change="store.setPauseEnabled(($event.target as HTMLInputElement).checked)" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<button class="settings-close" @click="emit('close')">关闭</button>
</div>
</div>
</template>
<style scoped>
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.settings-panel {
background: #1a1a2e;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
padding: 36px 40px;
min-width: 400px;
max-width: 480px;
}
.settings-title {
text-align: center;
font-size: 22px;
font-weight: 400;
color: #ddd;
letter-spacing: 3px;
margin-bottom: 28px;
}
.settings-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.setting-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(255, 255, 255, 0.03);
border-radius: 4px;
}
.setting-label {
font-size: 13px;
color: #bbb;
}
select {
padding: 4px 10px;
font-size: 13px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 3px;
color: #ddd;
outline: none;
}
.toggle {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
}
.toggle input { display: none; }
.toggle-slider {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.15);
border-radius: 11px;
cursor: pointer;
transition: background 0.2s;
}
.toggle-slider::after {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
top: 3px;
background: #fff;
border-radius: 50%;
transition: transform 0.2s;
}
.toggle input:checked + .toggle-slider { background: #4caf50; }
.toggle input:checked + .toggle-slider::after { transform: translateX(18px); }
.settings-close {
display: block;
margin: 24px auto 0;
padding: 10px 36px;
font-size: 14px;
color: #888;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.settings-close:hover { background: rgba(255, 255, 255, 0.1); color: #ccc; }
</style>

View File

@@ -2,6 +2,7 @@
import { ref, watch, nextTick } from 'vue'
import type { Choice } from '@engine/types'
import { useI18n } from '@/composables/useI18n'
import { useGameStore } from '@/stores/gameStore'
const props = defineProps<{
choices: Choice[]
@@ -15,8 +16,11 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const store = useGameStore()
const focusIndex = ref(0)
const btnRefs = ref<(HTMLButtonElement | null)[]>([])
const choiceEnabled = ref(!store.antiMistap)
let enableTimer: ReturnType<typeof setTimeout> | null = null
function timerPercent(): number {
if (props.timerTotal <= 0) return 0
@@ -34,6 +38,13 @@ function setRef(el: HTMLButtonElement | null, index: number) {
watch(() => props.choices.length, async (len) => {
if (len > 0) {
if (store.antiMistap) {
choiceEnabled.value = false
if (enableTimer) clearTimeout(enableTimer)
enableTimer = setTimeout(() => { choiceEnabled.value = true }, 500)
} else {
choiceEnabled.value = true
}
focusIndex.value = 0
await nextTick()
btnRefs.value[0]?.focus()
@@ -55,6 +66,7 @@ function onKeydown(e: KeyboardEvent, index: number) {
}
function handleChoose(index: number) {
if (!choiceEnabled.value) return
const choice = props.choices[index]
if (choice?.prompt) {
emit('prompt', choice.prompt)
@@ -74,7 +86,7 @@ function handleChoose(index: number) {
<span class="timer-text">{{ timerRemaining.toFixed(1) }}s</span>
</div>
<div class="choice-prompt">{{ t('ui.choose') }}</div>
<div class="choice-list">
<div class="choice-list" :class="{ disabled: !choiceEnabled }">
<button
v-for="(choice, index) in choices"
:key="index"
@@ -145,6 +157,11 @@ function handleChoose(index: number) {
margin: 0 auto;
}
.choice-list.disabled {
opacity: 0.5;
pointer-events: none;
}
.choice-btn {
position: relative;
padding: 14px 24px;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useGameStore } from '@/stores/gameStore'
interface SubCue {
start: number
@@ -14,6 +15,7 @@ const props = defineProps<{
lang?: string
}>()
const store = useGameStore()
const cues = ref<SubCue[]>([])
const currentText = ref('')
const loadedUrl = ref('')
@@ -96,7 +98,13 @@ function vttTimeToSeconds(ts: string): number {
<template>
<div class="subtitles" v-if="currentText">
<div class="sub-text">{{ currentText }}</div>
<div
class="sub-text"
:style="{
fontSize: store.subFontSize + 'px',
background: `rgba(0, 0, 0, ${store.subBgAlpha})`,
}"
>{{ currentText }}</div>
</div>
</template>

View File

@@ -37,6 +37,14 @@ export const useGameStore = defineStore('game', () => {
const endings = ref<EndingDef[]>([])
const visitedSceneIds = ref<Set<string>>(new Set())
const subFontSize = ref(Number(localStorage.getItem('subFontSize') || 20))
const subBgAlpha = ref(Number(localStorage.getItem('subBgAlpha') || 0))
const qteTimeRelax = ref(localStorage.getItem('qteTimeRelax') === 'true')
const qteSingleKey = ref(localStorage.getItem('qteSingleKey') === 'true')
const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false')
const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false')
const showSettings = ref(false)
function setScene(scene: SceneNode) {
currentScene.value = scene
}
@@ -172,6 +180,14 @@ export const useGameStore = defineStore('game', () => {
visitedSceneIds.value = new Set(visitedSceneIds.value)
}
function setSubFontSize(v: number) { subFontSize.value = v; localStorage.setItem('subFontSize', String(v)) }
function setSubBgAlpha(v: number) { subBgAlpha.value = v; localStorage.setItem('subBgAlpha', String(v)) }
function setQteTimeRelax(v: boolean) { qteTimeRelax.value = v; localStorage.setItem('qteTimeRelax', String(v)) }
function setQteSingleKey(v: boolean) { qteSingleKey.value = v; localStorage.setItem('qteSingleKey', String(v)) }
function setAntiMistap(v: boolean) { antiMistap.value = v; localStorage.setItem('antiMistap', String(v)) }
function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) }
function setShowSettings(v: boolean) { showSettings.value = v }
function dump() {
console.group('GameStore')
console.log('currentScene:', currentScene.value?.id)
@@ -190,6 +206,8 @@ export const useGameStore = defineStore('game', () => {
hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds,
inputMode, showAchievements, achievementDefs, unlockedAchievementIds,
toastAchievementId, showEndingGallery, endings, visitedSceneIds,
subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled,
showSettings,
setScene, setChoices, clearChoices, setGameEnded,
setTimer, clearTimer, setSaves,
showQTE, updateQTE, resolveQTE, clearQTE, setVideoTime,
@@ -199,6 +217,8 @@ export const useGameStore = defineStore('game', () => {
setShowAchievements, setAchievementDefs, setUnlockedAchievementIds,
addUnlockedAchievement, clearToastAchievement,
setEndings, setShowEndingGallery, setVisitedSceneIds, addVisitedSceneId,
setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled,
setShowSettings,
dump,
}
})