From ace5ed1fb399e5875d21f3575da0ed8b12fda5d0 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Tue, 9 Jun 2026 11:35:11 +0800 Subject: [PATCH] feat: chapter select system, multi-chapter support, scene manager refactor, and docs update --- FUTURE.md | 26 ++++++ ROADMAP.md | 47 ++++++++-- engine/core/Engine.ts | 36 +++++++- engine/core/SceneManager.ts | 12 ++- engine/systems/SaveSystem.ts | 25 ++++- engine/types.ts | 10 ++ public/images/ch1.jpg | Bin 0 -> 1899 bytes public/images/ch2.jpg | Bin 0 -> 2164 bytes public/images/ch3.jpg | Bin 0 -> 1966 bytes public/scenes/demo.json | 23 +++++ src/App.vue | 56 +++++++++++- src/components/ChapterSelect.vue | 151 +++++++++++++++++++++++++++++++ src/composables/useGameEngine.ts | 19 +++- src/stores/gameStore.ts | 25 ++++- 14 files changed, 413 insertions(+), 17 deletions(-) create mode 100644 public/images/ch1.jpg create mode 100644 public/images/ch2.jpg create mode 100644 public/images/ch3.jpg create mode 100644 src/components/ChapterSelect.vue 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 0000000000000000000000000000000000000000..adf41e82fe116b605a880d26d0fbf0053ac6d0e0 GIT binary patch literal 1899 zcmd5+eNfV89RC6NmXeXPX3n_snwABjB@!m9c63uuPcO7WF%Msw8m5MrB2?GeRw6my z3+)_Z6cn6R^M#UL6kjNvr5OQ{5?G)al%j$%w%zS+cm2P8p1be!dA|4gem~EB@43hR z!u}dq9f|)A4}ib`0CFtAz6`96PAw#dq7Wzy0<{AL*pY_1_tx?_V)3C+q;1aj`m;($l>u1oK`ryfFNK; z?%#P}1sDWzTJ6v~;5e)VIsH`z2CaZN!Paba-h}en`$dgQ+`-_d{%gJ0?L3ng(y}iV z26A))fgvt`s~t8V7z(_xa_<_i<_DWn^Wx3~w|p?g+wTJ~SKv128wh9<0EPh&7+`-1 zct9L>FbBs0&yVh*UH<2i{}q9>l=HWiXX&;i6YxTbL`onKW+g68T~RFgL?&!Xv)1Ms}m^Qv)zxze~ z&h+$mWyUc(uzqgn5j-YYoxmvKws zzlMh?TDb+)JnEMtSysm^q77HJNz3H5=(Pd68u}=&O%{4wciJE}laEa(pK46(FP+x> zK1Ns$wxtu0Z8&~ov5-A#;aBT_)8(tumN&m{uBzGHt2MRc=Qks8NYdgt)U2hKT70); zG)zMa5*USZpYA`rVF!NTA>h^xhr${z^Ycb~W#&(B%ifOJErgc%5jN zUYqfllo>*4Ixx#*w8Or)VK+MrH>ps90&l%TY6eucFYG)JaVTTg`dAaH5i?xrq%(#@oo|g(Q(gAU!4{T&a@Ny-Y}d zB9h(z#6r}~F;Og9&73)XPAg49h(rd7Nhqe0YH9lc|%j1;1dp|U-5`(+i@yygV`3%ewhDBrnU2z8;~ad~blTO!GB&w7D~wtk+G}Id0;d+5w=O3B>=XXH3r;~=;9J6% zI^r7ju@2QJ%eDB~uQ;vsdJJPS$PP@7MJDN|7-WuSn;!?Csi$R^U1az8><*<=b6xQ$ z*|ScrPg2*B@@&mgj0*z|8zjrIxv=wxl!XVVnjRmF*J$9w6YZ};r^nnPd1h5QsY`k` zlzx}Cq-fmmRd$!fLYcjj+YP5IK(C|@9H^_EF-*%V%2|HX)#8ERGW@*)vqM8c=U4w>OAT|NIPMK!uJ&}Ms|tvaW_c0{#Ya>RCSCz)u-OGL)l{rR`|s$g3c92 z9+Dv9uc;4;CW9ol4G9pHWO=We)oQi9GBhpa e>xg?ttQLl?MEdd<_kK=L-)BFZ|4|ZWSNs7`*8j5r literal 0 HcmV?d00001 diff --git a/public/images/ch2.jpg b/public/images/ch2.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a2706cd2f0802df74d1cdd2fb62fa15d2b897152 GIT binary patch literal 2164 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 literal 0 HcmV?d00001 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, } })