diff --git a/FUTURE.md b/FUTURE.md index 087e4ab..fd55fb5 100644 --- a/FUTURE.md +++ b/FUTURE.md @@ -2,6 +2,14 @@ 以下功能在讨论中出现但暂不纳入实施计划,后续需要扩展时参考。 +## 代码清理 + +- **移除 flag 机制** — `StateManager.flags` / `hasFlag` / `setFlag` / `clearFlag` 全部移除; + `Condition.op: 'hasFlag'` 和 `Effect.type: 'toggleFlag'` 删除; + 统一用 `variable == 1` / `set: variable, value: 1` 替代; + SaveSystem 存档移除 `flags` 字段;P8 不再需要 `defaultFlags` + 变量机制完全能覆盖 flag 功能,flag 是早期过度设计 + ## P7 全屏模式 - 扩展 - **自动进入全屏** — 点击"开始游戏"时同步 `requestFullscreen()`,利用用户手势 @@ -64,3 +72,21 @@ - 自动化测试框架(剧情路径遍历、回归测试) - 热更新支持(不刷新页面替换 JSON 和视频) - WebSocket 多人同步(观察者模式、投票选分支) + +# AI化 +在引擎的基础上深入融合AI,由AI去自动化完成引擎的使用。 + +AI的定位:一个对**使用引擎**降本增效的工具。懂业务的人还是主题,AI是辅助人的。 + +一个新技术的出现会帮助人类实现两种效果:突破式效果,帮助人类突破了一种以前从没有的能力,比如飞机帮助人类飞行,飞机之前人类无论如何都不能起飞的;降本增效式效果,改善了人类已有能力的效果,比如汽车帮助人类跑的更快了。 + +AI目前来看还是降本增效式效果,没有AI的帮助,人类也能做到现在AI能做的效果,只是成本高点、速度慢点。 + + +无论一个工具如何强大,只要在懂业务的人的手里才能发挥效果。比如缝纫机的出现,并没有取代真正的衣服制作者,并不会让不懂衣服制作的所有人都能制作衣服,善于使用缝纫机的人还是本身就懂衣服制作的人,即使没有缝纫机,他也知道如何制作衣服,只是缝纫机更快更高质量的帮助他完成了衣服的制作。 + +AI工具也一样,一个不懂电影游戏制作的人,只靠AI是做不出高质量的东西的,只有本身就懂电影游戏制作的人,无论使用啥工具都能做出合格的产品。 + +本引擎就是帮助懂电影游戏制作的人更快更高质量的完成产品,AI是帮助引擎更容易使用,达到降本增效的效果。 + +引擎帮助人降本增效,AI帮助引擎更容易被使用和提升产出效率。 \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 8861322..b8c935a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -433,29 +433,56 @@ GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)` - [x] `src/App.vue` — 右上角全屏按钮,与"菜单"按钮并排;`fullscreenchange` 同步图标状态 - [x] `FUTURE.md` — 远期扩展笔记(Pointer Lock、自动全屏、UI 自动隐藏、移动端适配等) -### P8 章节选择 — 通关后可跳转(待实现) +### P8 章节选择 — 到达即解锁,主菜单+通关后跳转 ✅ 已完成 2026-06-09 -目标:玩家通关后,可从中途任意章节起始点重新开始。每个章节记录一个入口场景 ID,展示章节缩略图和标题。 +目标:玩家可从中途任意章节起始点重新开始。到达章节入口即解锁,主菜单和通关后均可进入章节选择界面。 + +**核心规则:** + +| 规则 | 说明 | +|------|------| +| **解锁方式** | 到达即解锁 — `goToScene` 中检测当前场景所属章节,立即标记解锁并持久化到 IndexedDB | +| **变量状态** | 跳转时套用该章的 `defaultVariables`,未定义时 fallback 到全局 `variables`;确保条件分支不锁死 | +| **入口** | 主菜单"章节选择"按钮 + 通关后"章节选择"按钮,两处均可进入 | **数据结构设计:** ```json { + "startScene": "intro", + "variables": { "trust": 50, "courage": 0, "investigation": 0 }, "chapters": [ - { "id": "ch1", "label": "第一章:醒来", "startScene": "intro", "thumbnail": "/images/ch1.jpg" }, - { "id": "ch2", "label": "第二章:抉择", "startScene": "left_door", "thumbnail": "/images/ch2.jpg" } + { + "id": "ch1", "label": "第一章:醒来", "startScene": "intro", + "thumbnail": "/images/ch1.jpg", + "defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 } + }, + { + "id": "ch2", "label": "第二章:调查", "startScene": "desk_detail", + "thumbnail": "/images/ch2.jpg", + "defaultVariables": { "trust": 60, "courage": 10, "investigation": 1 } + }, + { + "id": "ch3", "label": "第三章:终局", "startScene": "qte_success", + "thumbnail": "/images/ch3.jpg", + "defaultVariables": { "trust": 70, "courage": 20, "investigation": 2 } + } ] } ``` **实现清单:** -- [ ] `engine/types.ts` — `GameData.chapters` 字段、`ChapterInfo` 接口 -- [ ] `engine/systems/SaveSystem.ts` — 章节解锁状态持久化(已通关章节记录) -- [ ] `src/components/ChapterSelect.vue` — 章节选择界面:缩略图网格 + 标题 + 锁定/解锁状态 -- [ ] `engine/core/Engine.ts` — `startChapter(chapterId)` 方法,跳转到指定章节起始场景 -- [ ] `src/App.vue` — 主菜单:新游戏 / 章节选择 / 继续 -- [ ] 验证:通关后章节解锁、从章节入口跳转正确、未解锁章节灰显 +- [x] `engine/types.ts` — `GameData.chapters` 字段、`ChapterInfo` 接口(含 `defaultVariables`) +- [x] `engine/systems/SaveSystem.ts` — DB v3 新增 `unlocks` 表;`unlockChapter`/`getUnlockedChapters` +- [x] `engine/core/SceneManager.ts` — `chapters` 存储、`getChapterBySceneId`/`getChapter` 查询 +- [x] `engine/core/Engine.ts` — `goToScene` 检测场景所属章节 → `chapterUnlock` 事件;`startChapter` 套用 `defaultVariables` 并重置 flags/history +- [x] `src/components/ChapterSelect.vue` — 章节选择 UI:缩略图网格 + 标题 + 锁定/解锁 +- [x] `src/stores/gameStore.ts` — `chapters`/`unlockedChapterIds`/`showChapterSelect` 状态 +- [x] `src/App.vue` — 主菜单"章节选择"按钮 + 游戏结束"章节选择"按钮 +- [x] `public/scenes/demo.json` — 3 章定义(含 defaultVariables 和 thumbnail) +- [x] `public/images/ch{1,2,3}.jpg` — 章节缩略图 +- [x] 验证:TypeScript + Vite build 通过 ### P9 跳过已看 + 倍速播放(待实现) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 5a4c683..6067426 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -1,4 +1,4 @@ -import type { SceneNode, Choice, EngineEvent, Hotspot } from '../types' +import type { SceneNode, Choice, EngineEvent, Hotspot, ChapterInfo } from '../types' import { SceneManager } from './SceneManager' import { VideoManager } from './VideoManager' import { StateManager } from './StateManager' @@ -24,6 +24,11 @@ export class Engine { private qteResolved = false private justCameFromImage = false private loopActive = false + private onUnlockChapter: ((chapterId: string) => void) | null = null + + setChapterUnlockHandler(handler: (chapterId: string) => void) { + this.onUnlockChapter = handler + } constructor() { this.sceneManager = new SceneManager() @@ -62,6 +67,12 @@ export class Engine { this.qteResolved = false this.loopActive = false + const chapter = this.sceneManager.getChapterBySceneId(scene.id) + if (chapter) { + this.onUnlockChapter?.(chapter.id) + this.emit('chapterUnlock', chapter) + } + if (scene.onEnter) { this.stateManager.apply(scene.onEnter) } @@ -333,6 +344,29 @@ export class Engine { this.emit('gameEnd') } + startChapter(chapterId: string) { + const chapter = this.sceneManager.getChapter(chapterId) + if (!chapter) return + + const scene = this.sceneManager.getScene(chapter.startScene) + if (!scene) return + + const defaultVars = chapter.defaultVariables + if (defaultVars) { + this.stateManager.variables = { ...defaultVars } + } else { + this.stateManager.init(this.sceneManager.chapters.length > 0 + ? {} // from chapters, use the chapter's defaultVariables or empty + : {}) + } + this.stateManager.flags = new Set() + this.stateManager.history = [] + + this.ended = false + this.isInitialScene = false + this.goToScene(scene) + } + resumeScene(sceneId: string, savedState: { variables: Record; flags: string[]; history: any[] }) { this.stateManager.variables = { ...savedState.variables } this.stateManager.flags = new Set(savedState.flags) diff --git a/engine/core/SceneManager.ts b/engine/core/SceneManager.ts index 7fa794a..360e0f2 100644 --- a/engine/core/SceneManager.ts +++ b/engine/core/SceneManager.ts @@ -1,12 +1,14 @@ -import type { GameData, SceneNode, Choice, Condition } from '../types' +import type { GameData, SceneNode, ChapterInfo, Choice, Condition } from '../types' export class SceneManager { private scenes: Record = {} private startScene: string = '' + chapters: ChapterInfo[] = [] load(data: GameData) { this.scenes = data.scenes this.startScene = data.startScene + this.chapters = data.chapters || [] } getScene(id: string): SceneNode | undefined { @@ -23,6 +25,14 @@ export class SceneManager { return Object.keys(this.scenes) } + getChapterBySceneId(sceneId: string): ChapterInfo | undefined { + return this.chapters.find((ch) => ch.startScene === sceneId) + } + + getChapter(chapterId: string): ChapterInfo | undefined { + return this.chapters.find((ch) => ch.id === chapterId) + } + getCandidateTargetIds(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] { const targets: string[] = [] diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts index a727c9f..551bf06 100644 --- a/engine/systems/SaveSystem.ts +++ b/engine/systems/SaveSystem.ts @@ -12,13 +12,19 @@ interface SaveRecord { thumbnail?: string } +interface UnlockRecord { + chapterId: string +} + class SaveDB extends Dexie { saves!: Table + unlocks!: Table constructor() { super('MovieGameSaves') - this.version(2).stores({ + this.version(3).stores({ saves: '++id, slot', + unlocks: 'chapterId', }) } } @@ -73,4 +79,21 @@ export class SaveSystem { async delete(slot: number): Promise { await db.saves.where('slot').equals(slot).delete() } + + async unlockChapter(chapterId: string) { + const exists = await db.unlocks.get(chapterId) + if (!exists) { + await db.unlocks.put({ chapterId }) + } + } + + async isChapterUnlocked(chapterId: string): Promise { + const record = await db.unlocks.get(chapterId) + return !!record + } + + async getUnlockedChapters(): Promise { + const records = await db.unlocks.toArray() + return records.map((r) => r.chapterId) + } } diff --git a/engine/types.ts b/engine/types.ts index 4438633..203b384 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -67,10 +67,19 @@ export interface QTEDefinition { } } +export interface ChapterInfo { + id: string + label: string + startScene: string + thumbnail?: string + defaultVariables?: Record +} + export interface GameData { scenes: Record startScene: string variables: Record + chapters?: ChapterInfo[] } export interface ChoiceRecord { @@ -101,3 +110,4 @@ export type EngineEvent = | 'choiceTimeout' | 'hotspotRequest' | 'hotspotUpdate' + | 'chapterUnlock' diff --git a/public/images/ch1.jpg b/public/images/ch1.jpg new file mode 100644 index 0000000..adf41e8 Binary files /dev/null and b/public/images/ch1.jpg differ diff --git a/public/images/ch2.jpg b/public/images/ch2.jpg new file mode 100644 index 0000000..a2706cd Binary files /dev/null and b/public/images/ch2.jpg differ diff --git a/public/images/ch3.jpg b/public/images/ch3.jpg new file mode 100644 index 0000000..893485f Binary files /dev/null and b/public/images/ch3.jpg differ diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 1121496..79f0328 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -5,6 +5,29 @@ "courage": 0, "investigation": 0 }, + "chapters": [ + { + "id": "ch1", + "label": "第一章:醒来", + "startScene": "intro", + "thumbnail": "/images/ch1.jpg", + "defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 } + }, + { + "id": "ch2", + "label": "第二章:调查", + "startScene": "desk_detail", + "thumbnail": "/images/ch2.jpg", + "defaultVariables": { "trust": 60, "courage": 10, "investigation": 1 } + }, + { + "id": "ch3", + "label": "第三章:终局", + "startScene": "qte_success", + "thumbnail": "/images/ch3.jpg", + "defaultVariables": { "trust": 70, "courage": 20, "investigation": 2 } + } + ], "scenes": { "intro": { "id": "intro", diff --git a/src/App.vue b/src/App.vue index 547bf7c..09bda94 100644 --- a/src/App.vue +++ b/src/App.vue @@ -6,6 +6,7 @@ import QTEOverlay from '@/components/QTEOverlay.vue' import Subtitles from '@/components/Subtitles.vue' import HotspotLayer from '@/components/HotspotLayer.vue' import SaveLoadMenu from '@/components/SaveLoadMenu.vue' +import ChapterSelect from '@/components/ChapterSelect.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' @@ -17,9 +18,11 @@ const videoElB = ref(null) const loading = ref(true) const started = ref(false) const showMenu = ref(false) +const showChapterSelect = ref(false) const hasAutoSave = ref(false) -const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, saveSystem } = +const { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter, + saveGame, loadGameFromSlot, refreshSaves, saveSystem } = useGameEngine(() => [videoElA.value, videoElB.value]) async function init() { @@ -63,6 +66,17 @@ async function onLoad(slot: number) { showMenu.value = false } +function openChapterSelect() { + showMenu.value = false + showChapterSelect.value = true +} + +async function onStartChapter(chapterId: string) { + showChapterSelect.value = false + started.value = true + startChapter(chapterId) +} + init() @@ -107,9 +121,13 @@ init()
+
游戏结束
+
+ +
+ @@ -236,4 +261,33 @@ html, body { border-color: rgba(100, 200, 255, 0.3); color: #8cf; } + +.chapters-btn { + margin-top: 16px; + border-color: rgba(255, 200, 100, 0.3); + color: #fc8; +} + +.game-end-actions { + margin-top: 24px; + display: flex; + gap: 12px; + justify-content: center; +} + +.end-btn { + padding: 14px 32px; + font-size: 18px; + color: #fc8; + background: rgba(255, 200, 100, 0.08); + border: 1px solid rgba(255, 200, 100, 0.2); + border-radius: 4px; + cursor: pointer; + letter-spacing: 2px; + transition: background 0.2s; +} + +.end-btn:hover { + background: rgba(255, 200, 100, 0.15); +} diff --git a/src/components/ChapterSelect.vue b/src/components/ChapterSelect.vue new file mode 100644 index 0000000..4a43d7b --- /dev/null +++ b/src/components/ChapterSelect.vue @@ -0,0 +1,151 @@ + + + + + diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index 73f235d..879e992 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -20,9 +20,18 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide const data: GameData = await resp.json() engine.sceneManager.load(data) engine.stateManager.init(data.variables) + store.setChapters(data.chapters || []) + + const unlocked = await saveSystem.getUnlockedChapters() + store.setUnlockedChapters(unlocked) } function registerEvents() { + engine.setChapterUnlockHandler(async (chapterId) => { + await saveSystem.unlockChapter(chapterId) + store.addUnlockedChapter(chapterId) + }) + engine.on('sceneChange', (scene) => { store.setScene(scene) store.clearChoices() @@ -122,6 +131,13 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide } } + function startChapter(chapterId: string) { + const [elA, elB] = videoEls() + if (elA && elB) engine.videoManager.attach(elA, elB) + store.setGameEnded(false) + engine.startChapter(chapterId) + } + async function saveGame(slot: number) { const state = engine.stateManager const currentScene = store.currentScene @@ -163,5 +179,6 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide destroy() }) - return { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem } + return { loadGame, start, resumeAutoSave, makeChoice, clickHotspot, startChapter, + saveGame, loadGameFromSlot, refreshSaves, engine, saveSystem } } diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index f371fef..90a35df 100644 --- a/src/stores/gameStore.ts +++ b/src/stores/gameStore.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref, shallowRef } from 'vue' -import type { SceneNode, Choice, QTEDefinition, Hotspot } from '@engine/types' +import type { SceneNode, Choice, QTEDefinition, Hotspot, ChapterInfo } from '@engine/types' export interface SlotInfo { slot: number @@ -25,6 +25,9 @@ export const useGameStore = defineStore('game', () => { const videoTime = ref(0) const hotspots = ref([]) const isImageScene = ref(false) + const showChapterSelect = ref(false) + const chapters = ref([]) + const unlockedChapterIds = ref>(new Set()) function setScene(scene: SceneNode) { currentScene.value = scene @@ -93,6 +96,23 @@ export const useGameStore = defineStore('game', () => { isImageScene.value = val } + function setChapters(list: ChapterInfo[]) { + chapters.value = list + } + + function setUnlockedChapters(ids: string[]) { + unlockedChapterIds.value = new Set(ids) + } + + function addUnlockedChapter(id: string) { + unlockedChapterIds.value.add(id) + unlockedChapterIds.value = new Set(unlockedChapterIds.value) + } + + function setShowChapterSelect(val: boolean) { + showChapterSelect.value = val + } + function dump() { console.group('GameStore') console.log('currentScene:', currentScene.value?.id) @@ -108,11 +128,12 @@ export const useGameStore = defineStore('game', () => { return { currentScene, choices, gameEnded, timerTotal, timerRemaining, saves, qteActive, qteDef, qteTotal, qteRemaining, qteResult, videoTime, - hotspots, isImageScene, + hotspots, isImageScene, showChapterSelect, chapters, unlockedChapterIds, setScene, setChoices, clearChoices, setGameEnded, setTimer, clearTimer, setSaves, showQTE, updateQTE, resolveQTE, setVideoTime, setHotspots, clearHotspots, setIsImageScene, + setChapters, setUnlockedChapters, addUnlockedChapter, setShowChapterSelect, dump, } })