From d0e901bd1fa682ca76fa5c0fccbf830e3f41b441 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sun, 14 Jun 2026 15:35:31 +0800 Subject: [PATCH] feat: battle system, state manager enhancements, engine and demo updates --- ROADMAP.md | 159 +++++++++++++++++++++++++++++++ engine/core/Engine.ts | 4 +- engine/core/StateManager.ts | 4 +- engine/types.ts | 30 ++++++ public/scenes/demo.json | 16 ++++ src/App.vue | 11 +++ src/components/BattleHUD.vue | 147 ++++++++++++++++++++++++++++ src/components/BattleResult.vue | 114 ++++++++++++++++++++++ src/composables/useGameEngine.ts | 8 ++ src/locales/en.json | 4 +- src/locales/zh.json | 4 +- src/stores/gameStore.ts | 20 ++++ 12 files changed, 515 insertions(+), 6 deletions(-) create mode 100644 src/components/BattleHUD.vue create mode 100644 src/components/BattleResult.vue diff --git a/ROADMAP.md b/ROADMAP.md index 744df5e..dc56b02 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -144,6 +144,165 @@ QTE 场景和过渡/路由场景被过滤,子节点上浮一级。 - [x] `src/components/StoryGallery.vue` — `isKeyMoment()` 三层逻辑 + `collectKeyTargets()` 扁平化非关键节点 - [x] 验证:TypeScript + Vite build 通过 +### P27 全局计时器 — 跨场景时间压力(待实现) + +目标:跨场景倒计时,时间用尽强制跳转。独立的 `TimerSystem` 类 + 三个新 Effect 类型, +支持启动/停止/重置/加减时间。 + +**Effect 类型:** + +| Effect type | 参数 | 说明 | +|-------------|------|------| +| `startTimer` | `duration`(秒), `expireScene` | 启动倒计时。如已存在则重置 | +| `stopTimer` | — | 暂停计时器 | +| `addTime` | `value`(秒) | 增加剩余时间(正数)或扣减(负数) | + +**使用场景:** + +```json +{ + "id": "chapter_start", + "onEnter": [ + { "type": "startTimer", "duration": 3600, "value": 3600, "target": "timeout_ending" } + ] +} +``` + +**TimerSystem 核心逻辑:** + +```typescript +class TimerSystem { + private remaining: number = 0 + private expireScene: string = '' + private intervalId: ReturnType | null = null + private onExpire: ((sceneId: string) => void) | null = null + + start(duration: number, expireScene: string) { ... } + stop() { ... } + addTime(seconds: number) { ... } + getRemaining(): number { ... } +} +``` + +setInterval 每秒递减,剩余 ≤0 时调用 `onExpire(expireScene)`。 + +**UI 显示:** PlaybackBar 右下角 `MM:SS` 格式,最后一分钟变红。 + +**实现清单:** + +- [ ] `engine/systems/TimerSystem.ts` — **新建** — 计时器核心逻辑 +- [ ] `engine/types.ts` — Effect 新增 `startTimer`/`stopTimer`/`addTime` 类型 +- [ ] `engine/core/StateManager.ts` — `apply` 中处理新 Effect +- [ ] `engine/core/Engine.ts` — 集成 `TimerSystem`;`startChapter` 停止旧 Timer +- [ ] `engine/systems/SaveSystem.ts` — 存档/读档包含 Timer 状态 +- [ ] `src/components/PlaybackBar.vue` — HUD 显示倒计时 + +### P28 随机路由 — 变量初始值/场景随机选择(待实现) + +目标:`nextScene` / 变量初始值支持随机选择,每次玩法不同。 + +### P29 背包/装备系统 — 物品持有影响叙事(待实现) + +目标:玩家可持有物品,物品影响 `conditions` 判断、选择可见性、场景解锁。 + +### P30 通关评分/反馈 — 结算面板展示统计(待实现) + +目标:通关后展示玩家行为统计(线索数、成就数、结局数)。DeathPanel 升级为通用的 `ResultPanel`,死亡和通关统一走这里。 + +**数据设计(GameData 顶层):** + +```json +{ + "stats": [ + { "label": "线索发现", "variable": "investigation", "max": 5 }, + { "label": "QTE 成功次数", "variable": "qte_succeeded" }, + { "label": "达成结局数", "type": "endingsCount" } + ] +} +``` + +| 字段 | 说明 | +|------|------| +| `label` | 统计项名称 | +| `variable` | 从 `variables` 读值 | +| `max` | 满分(可选),用于进度条 | +| `type: "endingsCount"` | 特殊统计 — `visitedSceneIds ∩ endings[].sceneId` 计数 | + +**实现清单:** + +- [ ] `engine/types.ts` — `GameData.stats?: StatDef[]` +- [ ] `src/components/DeathPanel.vue` → 升级为 `ResultPanel.vue` +- [ ] `src/App.vue` — `gameEnd` 触发后展示 ResultPanel + +### P31 战斗 HUD + 结算面板 — RPG HUD 流派 ✅ 已完成 2026-06-12 + +目标:战斗场景中展示角色属性 HUD(头像 + HP/MP 条 + 数值),胜利后弹出结算面板。 +走 RPG HUD 流派,非极简派。战败不做结算面板,直接走战败叙事。 + +**SceneNode 新增字段:** + +```json +{ + "id": "combat", + "videoUrl": "combat/combat.mp4", + "qte": { ... }, + "battleHUD": [ + { + "label": "你", + "portrait": "images/player.jpg", + "stats": [ + { "variable": "player_hp", "label": "HP", "max": 100 }, + { "variable": "player_mp", "label": "MP", "max": 50 }, + { "variable": "combo_score", "label": "连击" } + ] + }, + { + "label": "敌人", + "portrait": "images/enemy.jpg", + "stats": [ + { "variable": "enemy_hp", "label": "HP", "max": 100 } + ] + } + ], + "battleResult": { + "title": "战斗胜利!", + "stats": [ + { "label": "剩余生命", "variable": "player_hp" }, + { "label": "QTE 成功次数", "variable": "qte_succeeded" } + ] + } +} +``` + +**BattleHUD 字段说明:** + +| 字段 | 说明 | +|------|------| +| `label` | 角色名称 | +| `portrait` | 角色头像路径 | +| `stats` | 属性数组。`variable`/`label`/`max`/`style`(`"bar"` 或 `"number"`,缺省时根据有无 `max` 自动判断) | + +**布局:** 角色头像左侧,stats 竖排叠在头像右侧。多角色水平排列在屏幕一侧。 + +**组件:** + +| 组件 | 说明 | +|------|------| +| `BattleHUD.vue` | 战斗场景中显示角色属性条,`variables` 实时响应 | +| `BattleResult.vue` | 胜利结算面板 — 标题 + stats + "继续"按钮 → 下一场景 | + +**实现清单:** + +- [x] `engine/types.ts` — `BattleHUDStat` / `BattleHUDEntry` / `BattleResultStat` / `BattleResultDef` 接口 +- [x] `src/components/BattleHUD.vue` — **新建** — 角色头像 + stats 进度条/数值,i18n labelKey +- [x] `src/components/BattleResult.vue` — **新建** — 胜利结算面板 + titleKey + "继续"按钮 +- [x] `src/stores/gameStore.ts` — `variable()` 读值 + `showBattleResult` 状态 +- [x] `src/composables/useGameEngine.ts` — `sceneChange` 中检测 `scene.battleResult` 自动弹出 +- [x] `src/App.vue` — 整合 BattleHUD + BattleResult +- [x] `src/locales/zh.json` + `en.json` — `continue` / `toMenu` i18n +- [x] `public/scenes/demo.json` — `right_door` 场景添加 `battleHUD` 示例 +- [x] 验证:TypeScript + Vite build 通过 + ## 已完成 P0~P23 全部实现(除 P18)。详见 [CHANGELOG.md](CHANGELOG.md)。 diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index f155ef2..eb22462 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -51,9 +51,9 @@ export class Engine { this.audioSystem = new AudioSystem() this.achievementSystem = new AchievementSystem() - this.stateManager.onAfterApply = (vars) => { + this.stateManager.onAfterApply.add((vars) => { this.achievementSystem.check(vars) - } + }) this.videoManager.onTimeUpdate(this.onTimeUpdate) } diff --git a/engine/core/StateManager.ts b/engine/core/StateManager.ts index a3bb674..85db364 100644 --- a/engine/core/StateManager.ts +++ b/engine/core/StateManager.ts @@ -4,7 +4,7 @@ export class StateManager { variables: Record = {} flags: Set = new Set() history: ChoiceRecord[] = [] - onAfterApply: ((variables: Record) => void) | null = null + onAfterApply: Set<((variables: Record) => void)> = new Set() init(initialVars: Record) { this.variables = { ...initialVars } @@ -73,7 +73,7 @@ export class StateManager { break } } - this.onAfterApply?.(this.variables) + this.onAfterApply.forEach((cb) => cb(this.variables)) } recordChoice(choice: ChoiceRecord) { diff --git a/engine/types.ts b/engine/types.ts index 3f8be20..89a5b6f 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -23,6 +23,8 @@ export interface SceneNode { skippable?: boolean streamingUrl?: Record keyMoment?: boolean + battleHUD?: BattleHUDEntry[] + battleResult?: BattleResultDef } export interface Choice { @@ -166,3 +168,31 @@ export interface PlayerTreeNode { isGateway?: boolean gatewayChapterId?: string } + +export interface BattleHUDStat { + variable: string + label: string + labelKey?: string + max?: number + style?: 'bar' | 'number' +} + +export interface BattleHUDEntry { + label: string + labelKey?: string + portrait?: string + stats: BattleHUDStat[] +} + +export interface BattleResultStat { + label: string + labelKey?: string + variable: string + max?: number +} + +export interface BattleResultDef { + title: string + titleKey?: string + stats: BattleResultStat[] +} diff --git a/public/scenes/demo.json b/public/scenes/demo.json index b62623f..8979e17 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -359,6 +359,22 @@ "bgmVolume": 0.7, "bgmCrossFade": 2.0, "videoMuted": true, + "battleHUD": [ + { + "label": "你", + "labelKey": "battle.hud.player", + "stats": [ + { "variable": "courage", "label": "勇气", "labelKey": "stat.courage", "max": 100 } + ] + } + ], + "battleResult": { + "title": "击退成功!", + "titleKey": "battle.result.victory", + "stats": [ + { "label": "勇气", "labelKey": "stat.courage", "variable": "courage", "max": 100 } + ] + }, "qte": { "triggerTime": 1.0, "prompt": "躲避飞来的石块!", diff --git a/src/App.vue b/src/App.vue index 41bd051..e2c9438 100644 --- a/src/App.vue +++ b/src/App.vue @@ -13,6 +13,8 @@ import AchievementToast from '@/components/AchievementToast.vue' import AchievementPanel from '@/components/AchievementPanel.vue' import AccessibilitySettings from '@/components/AccessibilitySettings.vue' import StoryGallery from '@/components/StoryGallery.vue' +import BattleHUD from '@/components/BattleHUD.vue' +import BattleResult from '@/components/BattleResult.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' import { useFullscreen } from '@/composables/useFullscreen' @@ -346,6 +348,10 @@ init()
{{ promptToast }}
+
@@ -361,6 +367,11 @@ init() @skip="handleSkip" @speed-change="handleSpeedChange" /> + +import { computed } from 'vue' +import type { BattleHUDEntry } from '@engine/types' +import { useGameStore } from '@/stores/gameStore' +import { useI18n } from '@/composables/useI18n' + +const props = defineProps<{ + entries: BattleHUDEntry[] +}>() + +const store = useGameStore() +const { t } = useI18n() + +function statValue(variable: string): number { + return store.variable(variable) +} + +function barPercent(value: number, max: number): number { + if (!max || max <= 0) return 0 + return Math.min(100, Math.max(0, (value / max) * 100)) +} + +function barClass(percent: number): string { + if (percent <= 25) return 'danger' + if (percent <= 50) return 'warning' + return '' +} + +function statStyle(stat: { variable: string; max?: number; style?: string }): 'bar' | 'number' { + if (stat.style === 'bar' || stat.style === 'number') return stat.style + return stat.max !== undefined ? 'bar' : 'number' +} + + + + + diff --git a/src/components/BattleResult.vue b/src/components/BattleResult.vue new file mode 100644 index 0000000..51bbe23 --- /dev/null +++ b/src/components/BattleResult.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index 8c24773..747faa5 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -12,6 +12,10 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide const { t } = useI18n() let lastThumbnail: string | undefined + engine.stateManager.onAfterApply.add((vars) => { + store.syncVariables(vars) + }) + if (import.meta.env.DEV) { ;(window as any).__sm = engine.stateManager ;(window as any).__store = store @@ -42,6 +46,10 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide store.clearTimer() store.clearHotspots() store.setIsImageScene(scene.type === 'image') + if (scene.battleResult) { + store.setBattleResult(scene.battleResult) + } + store.syncVariables(engine.stateManager.variables) saveGame(0) }) diff --git a/src/locales/en.json b/src/locales/en.json index eeb2229..da4d635 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -41,6 +41,8 @@ "qualityAuto": "Auto", "quality1080p": "1080p 2.5Mbps", "quality720p": "720p 2Mbps", - "quality480p": "480p 0.8Mbps" + "quality480p": "480p 0.8Mbps", + "continue": "Continue", + "toMenu": "Back to Menu" } } \ No newline at end of file diff --git a/src/locales/zh.json b/src/locales/zh.json index 8e5ff68..09bafc8 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -41,6 +41,8 @@ "qualityAuto": "自动", "quality1080p": "超清 320KB/s", "quality720p": "高清 256KB/s", - "quality480p": "标清 100KB/s" + "quality480p": "标清 100KB/s", + "continue": "继续", + "toMenu": "返回菜单" } } \ No newline at end of file diff --git a/src/stores/gameStore.ts b/src/stores/gameStore.ts index 5dbac7f..8c60e33 100644 --- a/src/stores/gameStore.ts +++ b/src/stores/gameStore.ts @@ -45,6 +45,9 @@ export const useGameStore = defineStore('game', () => { const antiMistap = ref(localStorage.getItem('antiMistap') !== 'false') const pauseEnabled = ref(localStorage.getItem('pauseEnabled') !== 'false') const showSettings = ref(false) + const showBattleResult = ref(false) + const battleResultData = ref(null) + const variables = ref>({}) const preferredQuality = ref(localStorage.getItem('preferredQuality') || '') const introVideo = ref('') const menuVideo = ref('') @@ -200,6 +203,21 @@ export const useGameStore = defineStore('game', () => { function setPauseEnabled(v: boolean) { pauseEnabled.value = v; localStorage.setItem('pauseEnabled', String(v)) } function setShowSettings(v: boolean) { showSettings.value = v } + function variable(name: string): number { + return variables.value[name] ?? 0 + } + + function syncVariables(vars: Record) { + variables.value = { ...vars } + } + + function setBattleResult(data: any) { + battleResultData.value = data + showBattleResult.value = true + } + + function setShowBattleResult(v: boolean) { showBattleResult.value = v } + function setPreferredQuality(q: string) { preferredQuality.value = q; localStorage.setItem('preferredQuality', q) } function setIntroVideo(url: string) { introVideo.value = url } @@ -226,6 +244,7 @@ export const useGameStore = defineStore('game', () => { storyLocales, subFontSize, subBgAlpha, qteTimeRelax, qteSingleKey, antiMistap, pauseEnabled, showSettings, introVideo, menuVideo, + showBattleResult, battleResultData, variables, preferredQuality, setScene, setChoices, clearChoices, setGameEnded, setTimer, clearTimer, setSaves, @@ -239,6 +258,7 @@ export const useGameStore = defineStore('game', () => { setStoryLocales, setSubFontSize, setSubBgAlpha, setQteTimeRelax, setQteSingleKey, setAntiMistap, setPauseEnabled, setShowSettings, setIntroVideo, setMenuVideo, + variable, setBattleResult, setShowBattleResult, syncVariables, setPreferredQuality, dump, }