feat: accessibility settings, subtitle/QTE improvements, docs update
This commit is contained in:
13
FUTURE.md
13
FUTURE.md
@@ -2,6 +2,19 @@
|
||||
|
||||
以下功能在讨论中出现但暂不纳入实施计划,后续需要扩展时参考。
|
||||
|
||||
## 云存档(移自 P16)
|
||||
|
||||
- 存档上传/下载 API 接口设计(REST: `PUT /saves/:slot`, `GET /saves`)
|
||||
- `engine/systems/SaveSystem.ts` 升级 — save/load 支持 `remote: boolean` 参数
|
||||
- 登录态管理(不强制登录,本地存档为主,云存档为可选)
|
||||
- 存档同步冲突解决策略(最后写入胜出 / 时间戳比对)
|
||||
|
||||
## 自适应码率(移自 P16)
|
||||
|
||||
- `engine/core/VideoManager.ts` — 支持 HLS(`.m3u8`)和 DASH(`.mpd`)流媒体源
|
||||
- `SceneNode.videoUrl` 支持多码率:`{ auto: '/videos/hls/scene.m3u8', hd: '/videos/scene_1080p.mp4' }`
|
||||
- 网络质量检测(`navigator.connection` API),自动降级
|
||||
|
||||
## 代码清理
|
||||
|
||||
- **移除 flag 机制** — `StateManager.flags` / `hasFlag` / `setFlag` / `clearFlag` 全部移除;
|
||||
|
||||
71
ROADMAP.md
71
ROADMAP.md
@@ -856,9 +856,76 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e
|
||||
- [x] 验证:TypeScript + Vite build 通过
|
||||
- [ ] 未来:Dagre 关键节点时间线图(`SceneNode.keyMoment?: boolean`)
|
||||
|
||||
### P16 平台化 — 云存档 + 可访问性 + 自适应码率 + 全局统计(待实现)
|
||||
<!--
|
||||
### P16 自适应码率 — HLS/DASH 流媒体支持(已废弃,移入 FUTURE.md)
|
||||
|
||||
目标:面向分发和用户多样性的补全功能。含原 P15 + 原 P13d 全局统计。
|
||||
离线应用模式下视频文件本地存储,无网络波动和缓冲需求。HLS/DASH 在离线场景完全多余。
|
||||
|
||||
- [x] ~~engine/core/VideoManager.ts~~
|
||||
- [x] ~~package.json hls.js~~
|
||||
- [x] ~~验证~~
|
||||
|
||||
-->
|
||||
|
||||
### P16 可访问性设置 — 字幕 + QTE 辅助 + 防误触 + 暂停 ✅ 已完成 2026-06-09
|
||||
|
||||
目标:让不同身体条件的玩家都能舒适游戏。保留 6 个高价值设置和交互改进。
|
||||
|
||||
**设置项:**
|
||||
|
||||
| 设置 | 默认 | 说明 |
|
||||
|------|------|------|
|
||||
| 字幕字号 | 20px | 20/24/28/32px 可选,全局统一,所有场景生效 |
|
||||
| 字幕背景透明度 | 0 | 0/0.3/0.5/0.7/0.9 可选,0=无背景(电影字幕风格) |
|
||||
| QTE 时限放宽 | 关 | 开启后所有 QTE 时限 × 1.5 |
|
||||
| QTE 按键简化 | 关 | 开启后所有 QTE 映射为空格键 |
|
||||
| 防误触延迟 | 开 | 选项出现后 0.5 秒内不接受点击/按键确认,防止连续按跳过误选 |
|
||||
| 可暂停 | 开 | Space 键暂停/恢复,画面冻结 + 半透明遮罩。非 ESC 菜单式覆盖 |
|
||||
|
||||
**入口:** 主菜单"设置"按钮 + 游戏内 ESC 菜单"设置"按钮,两处均可进入。
|
||||
|
||||
设置项存 localStorage,QTE 参数通过 Engine API 传入 QTESystem。暂停为引擎级功能。
|
||||
|
||||
**实现清单:**
|
||||
|
||||
- [x] `src/stores/gameStore.ts` — 6 个设置项状态 + localStorage 读写
|
||||
- [x] `src/components/AccessibilitySettings.vue` — 设置面板 UI(下拉 + 开关 + 滑块)
|
||||
- [x] `src/components/Subtitles.vue` — `:style` 绑定 `store.subFontSize` / `store.subBgAlpha`
|
||||
- [x] `src/components/ChoicePanel.vue` — 防误触延迟(选项出现后 0.5s `pointer-events: none`)
|
||||
- [x] `engine/systems/QTESystem.ts` — `timeLimitMultiplier` 和 `singleKeyMode` 参数
|
||||
- [x] `src/App.vue` — 主菜单 + 游戏内"设置"按钮;Space 暂停/恢复带遮罩;QTE 参数传入引擎
|
||||
- [x] 验证:TypeScript + Vite build 通过
|
||||
|
||||
### P17 全局统计 + 主菜单 — 通关数据展示 + 统一入口(待实现)
|
||||
|
||||
目标:通关后展示统计数据(线索数、结局数、QTE 成功/失败次数),所有入口整合到统一主菜单。
|
||||
|
||||
**全局统计数据定义:**
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{ "id": "clues_found", "label": "线索发现数", "variable": "investigation", "icon": "🔍" },
|
||||
{ "id": "qte_wins", "label": "QTE 成功次数", "variable": "qte_succeeded", "icon": "🎯" },
|
||||
{ "id": "endings_count", "label": "达成结局数", "type": "endings", "icon": "🏁" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
数据从 `StateManager.variables` 读取。`type: "endings"` 的统计项从 `visitedSceneIds ∩ endings[].sceneId` 计算。
|
||||
|
||||
**主菜单界面:**
|
||||
|
||||
统一入口组件 `MainMenu.vue`,整合:新游戏 / 继续 / 章节选择 / 成就 / 画廊 / 设置 / 语言切换。
|
||||
|
||||
**实现清单:**
|
||||
|
||||
- [ ] `engine/types.ts` — `GameData.stats: StatDef[]`
|
||||
- [ ] `src/components/MainMenu.vue` — 主菜单统一入口,所有按钮整齐排列
|
||||
- [ ] `src/components/StatsPanel.vue` — 通关后统计面板
|
||||
- [ ] `src/App.vue` — 游戏结束后展示 StatsPanel;主菜单用 MainMenu 替代当前散装按钮
|
||||
- [ ] `public/scenes/demo.json` — `stats` 定义
|
||||
- [ ] 验证:主菜单入口完整、通关后统计数据正确、统计刷新后仍然准确
|
||||
|
||||
## 依赖清单
|
||||
|
||||
|
||||
@@ -4,14 +4,13 @@ type QTEUpdateCallback = (remaining: number, total: number) => void
|
||||
type QTEResultCallback = (success: boolean) => void
|
||||
|
||||
export class QTESystem {
|
||||
// QTE (Quick Time Event / 快速反应事件):
|
||||
// 视频播放到特定时间点时弹出按键提示,玩家在倒计时内按下指定按键,
|
||||
// 成功/失败/超时分别导向不同剧情分支,并应用对应的 effects 效果。
|
||||
private timerId: ReturnType<typeof setInterval> | null = null
|
||||
private timeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
private keyHandler: ((e: KeyboardEvent) => void) | null = null
|
||||
private tickMs = 50
|
||||
private active = false
|
||||
timeLimitMultiplier = 1
|
||||
singleKeyMode = false
|
||||
|
||||
trigger(
|
||||
qte: QTEDefinition,
|
||||
@@ -22,11 +21,13 @@ export class QTESystem {
|
||||
this.active = true
|
||||
|
||||
const startTime = Date.now()
|
||||
const total = qte.timeLimit * 1000
|
||||
const adjustedLimit = qte.timeLimit * this.timeLimitMultiplier
|
||||
const total = adjustedLimit * 1000
|
||||
|
||||
this.keyHandler = (e: KeyboardEvent) => {
|
||||
if (!this.active) return
|
||||
const matched = qte.keys.some(
|
||||
const targetKeys = this.singleKeyMode ? [' '] : qte.keys
|
||||
const matched = targetKeys.some(
|
||||
(k) => k.toLowerCase() === e.key.toLowerCase()
|
||||
)
|
||||
if (matched) {
|
||||
@@ -40,7 +41,7 @@ export class QTESystem {
|
||||
if (!this.active) return
|
||||
const elapsed = Date.now() - startTime
|
||||
const remaining = Math.max(0, total - elapsed)
|
||||
onUpdate(remaining / 1000, qte.timeLimit)
|
||||
onUpdate(remaining / 1000, adjustedLimit)
|
||||
if (remaining <= 0) {
|
||||
this.clear()
|
||||
onResult(false)
|
||||
|
||||
73
src/App.vue
73
src/App.vue
@@ -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;
|
||||
|
||||
179
src/components/AccessibilitySettings.vue
Normal file
179
src/components/AccessibilitySettings.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user