diff --git a/FUTURE.md b/FUTURE.md index f2fd0a9..6b29c3c 100644 --- a/FUTURE.md +++ b/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` 全部移除; diff --git a/ROADMAP.md b/ROADMAP.md index 9255e9e..32b9ad3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -856,9 +856,76 @@ QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 e - [x] 验证:TypeScript + Vite build 通过 - [ ] 未来:Dagre 关键节点时间线图(`SceneNode.keyMoment?: boolean`) -### P16 平台化 — 云存档 + 可访问性 + 自适应码率 + 全局统计(待实现) + + +### 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` 定义 +- [ ] 验证:主菜单入口完整、通关后统计数据正确、统计刷新后仍然准确 ## 依赖清单 diff --git a/engine/systems/QTESystem.ts b/engine/systems/QTESystem.ts index 57c767c..df47813 100644 --- a/engine/systems/QTESystem.ts +++ b/engine/systems/QTESystem.ts @@ -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 | null = null private timeoutId: ReturnType | 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) diff --git a/src/App.vue b/src/App.vue index 424bed4..58e5449 100644 --- a/src/App.vue +++ b/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(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 ? '⛶' : '⛶' }} +
@@ -208,6 +241,7 @@ init() +
{{ t('ui.gameEnd') }}
@@ -249,6 +283,14 @@ init() :visited-ids="store.visitedSceneIds" @close="recapChapterId = null" /> + +
+
已暂停
+
点击或按 Space 继续
+
+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] + + + + + diff --git a/src/components/ChoicePanel.vue b/src/components/ChoicePanel.vue index a0e1003..ca57571 100644 --- a/src/components/ChoicePanel.vue +++ b/src/components/ChoicePanel.vue @@ -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 | 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) { {{ timerRemaining.toFixed(1) }}s
{{ t('ui.choose') }}
-
+